Files
VolcanicAgenticCore/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobValidation.cs
EugeneTes 8014a0e770 refactor(scheduler): drop the reminder job kind
Jobs are prompts or watchers. A reminder is a prompt the agent passes on, so
it lands in the transcript. Stored reminder jobs load as prompts
("Remind me: <text>"). Also records the Coolify deploy target in CLAUDE.md.
2026-09-29 09:30:40 +00:00

80 lines
4.3 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;
}
}
private static void CheckFields(JobKind kind, string? text, string? command, int? timeoutSeconds)
{
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);
}