Files
Playa/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobValidation.cs
EugeneTes 6e3241fed9 feat(scheduler): scheduled jobs inside skills (.volcanic/skills/<skill>/jobs/<job>.md)
Skill directories may now declare prompt or watcher jobs as files (cron or one-off at).
The file is the source of truth: the scheduler re-reads every chat's jobs/ every minute,
around agent runs and before /jobs and /skills, and schedules, re-plans or removes them.
Ids are <skill>/<job>; run state lives in .volcanic/scheduler/skill-jobs-state.json, never
in jobs.json. Watchers run from the skill directory with VOLCANIC_WORKSPACE and
VOLCANIC_SKILL_DIR. Broken files are shown with the reason in /jobs, /skills, schedule list
and the new <skill_jobs> prompt section; cancel answers with the file to edit. Every part of
a skill directory is optional; file reading and frontmatter parsing move to Abstractions.
2026-10-14 12:54:40 +00:00

81 lines
4.5 KiB
C#

using System.Globalization;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>Rules shared by the <c>schedule</c> tool (new jobs) and the store loader (stored jobs).</summary>
internal static class JobValidation
{
public const int MaxNameLength = 80;
public const int MaxTextLength = 4000;
public const int MaxTimeoutSeconds = 3600;
public static readonly TimeSpan PastGrace = TimeSpan.FromSeconds(60);
/// <summary>Validates a new job and returns its schedule (<c>at</c> resolved in <paramref name="zone"/>). Throws <see cref="JobValidationException"/>.</summary>
public static JobSchedule ValidateNew(NewJob job, DateTimeOffset now, TimeZoneInfo zone)
{
var name = job.Name?.Trim() ?? "";
if (name.Length == 0) throw new JobValidationException("name is required (a short label such as \"Call the dentist\").");
if (name.Length > MaxNameLength) throw new JobValidationException($"name is {Count(name.Length)} characters; the limit is {Count(MaxNameLength)}.");
var hasAt = !string.IsNullOrWhiteSpace(job.At);
var hasCron = !string.IsNullOrWhiteSpace(job.Cron);
if (hasAt == hasCron) throw new JobValidationException("Set exactly one of at (a one-off time) or cron (a recurring schedule).");
CheckFields(job.Kind, job.Text, job.Command, job.TimeoutSeconds);
if (hasCron)
{
Schedules.ParseCron(job.Cron!);
return new JobSchedule(null, job.Cron!.Trim());
}
var at = Schedules.ParseAt(job.At!, zone);
if (at < now - PastGrace)
{
var local = TimeZoneInfo.ConvertTime(at, zone);
var localNow = TimeZoneInfo.ConvertTime(now, zone);
throw new JobValidationException(string.Create(CultureInfo.InvariantCulture,
$"at {local:yyyy-MM-dd HH:mm} ({ZoneNames.Name(zone)}) is in the past; now is {localNow:yyyy-MM-dd HH:mm}. Pick a future time."));
}
return new JobSchedule(at, null);
}
/// <summary>Null when a stored job is well formed, else why it is dropped. An overdue one-off is fine (it fires late).</summary>
public static string? CheckStored(ScheduledJob job)
{
try
{
if (string.IsNullOrWhiteSpace(job.Id) || string.IsNullOrWhiteSpace(job.Target)) return "missing id or target";
if (string.IsNullOrWhiteSpace(job.Name) || job.Name.Length > MaxNameLength) return "missing or too long name";
if (job.Schedule is null || (job.Schedule.At is null) == (job.Schedule.Cron is null)) return "needs exactly one of at and cron";
if (job.Schedule.Cron is { } cron) Schedules.ParseCron(cron);
CheckFields(job.Kind, job.Text, job.Command, job.TimeoutSeconds);
return null;
}
catch (JobValidationException ex)
{
return ex.Message;
}
}
/// <summary>Kind, text, command and timeout. Skill job files allow a longer text (<paramref name="maxTextLength"/>).</summary>
public static void CheckFields(JobKind kind, string? text, string? command, int? timeoutSeconds, int maxTextLength = MaxTextLength)
{
var hasText = !string.IsNullOrWhiteSpace(text);
var hasCommand = !string.IsNullOrWhiteSpace(command);
switch (kind)
{
case JobKind.Prompt when !hasText: throw new JobValidationException("A prompt job needs text (what to ask the agent).");
case JobKind.Prompt when hasCommand: throw new JobValidationException("command is only for watchers.");
case JobKind.Watcher when !hasCommand: throw new JobValidationException("A watcher needs command (the shell command to run).");
case JobKind.Watcher when hasText: throw new JobValidationException("A watcher takes no text: its script's output becomes the prompt.");
case not (JobKind.Prompt or JobKind.Watcher): throw new JobValidationException("kind must be prompt or watcher.");
}
if (hasText && text!.Length > maxTextLength) throw new JobValidationException($"text is {Count(text.Length)} characters; the limit is {Count(maxTextLength)}.");
if (timeoutSeconds is { } seconds)
{
if (kind != JobKind.Watcher) throw new JobValidationException("timeoutSeconds is only for watchers.");
if (seconds is < 1 or > MaxTimeoutSeconds) throw new JobValidationException("timeoutSeconds must be between 1 and 3600.");
}
}
private static string Count(int n) => n.ToString(CultureInfo.InvariantCulture);
}