using System.Globalization; namespace Volcanic.AgenticCore.Extensions.Scheduler; /// Rules shared by the schedule tool (new jobs) and the store loader (stored jobs). 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); /// Validates a new job and returns its schedule (at resolved in ). Throws . 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); } /// Null when a stored job is well formed, else why it is dropped. An overdue one-off is fine (it fires late). 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); }