feat(scheduler): extension project, job model, cron and at parsing, validation

This commit is contained in:
EugeneTes
2026-09-27 10:07:28 +02:00
parent 96a6a5518b
commit d699593458
10 changed files with 414 additions and 0 deletions

View File

@@ -0,0 +1,80 @@
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.Reminder when !hasText: throw new JobValidationException("A reminder needs text (the message to send).");
case JobKind.Prompt when !hasText: throw new JobValidationException("A prompt job needs text (what to ask the agent).");
case JobKind.Reminder or 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.Reminder or JobKind.Prompt or JobKind.Watcher): throw new JobValidationException("kind must be reminder, 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);
}

View File

@@ -0,0 +1,42 @@
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>Reminder: fixed text to the chat, no model call. Prompt: fixed text through the agent. Watcher: a script whose output goes to the agent.</summary>
public enum JobKind { Reminder, Prompt, Watcher }
/// <summary>Exactly one is set. <see cref="At"/> is an absolute instant; <see cref="Cron"/> is 5-field wall-clock time in the target's zone.</summary>
public sealed record JobSchedule(DateTimeOffset? At, string? Cron);
/// <summary>The outcome of a job's last fire. <see cref="StderrTail"/> is stored redacted.</summary>
public sealed record JobResult(bool Ok, int? ExitCode, bool TimedOut, string? StderrTail, DateTimeOffset At);
/// <summary>A persisted job (<c>jobs.json</c>).</summary>
public sealed record ScheduledJob
{
public required string Id { get; init; }
/// <summary>Who the job belongs to: the chat id (invariant culture) on Telegram.</summary>
public required string Target { get; init; }
public required string Name { get; init; }
public required JobKind Kind { get; init; }
public required JobSchedule Schedule { get; init; }
/// <summary>Reminder and prompt only.</summary>
public string? Text { get; init; }
/// <summary>Watcher only.</summary>
public string? Command { get; init; }
/// <summary>Watcher only; default <see cref="SchedulerOptions.DefaultScriptTimeout"/>, at most 3600.</summary>
public int? TimeoutSeconds { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastRunAt { get; init; }
public JobResult? LastResult { get; init; }
}
/// <summary>What the <c>schedule</c> tool asks for. <see cref="At"/> is ISO 8601 text; without an offset it's read in the target's zone.</summary>
public sealed record NewJob(string Name, JobKind Kind, string? At = null, string? Cron = null, string? Text = null, string? Command = null, int? TimeoutSeconds = null);
/// <summary>A job and its next fire time (null: never, e.g. a one-off already firing).</summary>
public sealed record JobView(ScheduledJob Job, DateTimeOffset? NextRunAt);
/// <summary>What a zone change did to one target's jobs.</summary>
public sealed record ZoneChange(int CronJobs, int OneOffs);
/// <summary>A job the model (or a stored file) got wrong. The message is a sentence the model can act on.</summary>
public sealed class JobValidationException(string message) : Exception(message);

View File

@@ -0,0 +1,34 @@
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>The <c>Scheduler</c> configuration section. There is no zone here: zones are per target (the host's <c>IJobDelivery.TimeZoneFor</c>).</summary>
public sealed class SchedulerOptions
{
public const string SectionName = "Scheduler";
public bool Enabled { get; set; } = true;
/// <summary><c>jobs.json</c>. Empty: the host fills <see cref="DefaultStorePath"/> under its agent directory.</summary>
public string? StorePath { get; set; }
/// <summary>Watcher timeout when the job sets none.</summary>
public TimeSpan DefaultScriptTimeout { get; set; } = TimeSpan.FromSeconds(300);
public int MaxJobsPerTarget { get; set; } = 50;
/// <summary>Executable and leading arguments; the command is appended. Empty: <c>/bin/sh -c</c>, or <c>cmd.exe /d /s /c</c> on Windows (the <c>bash</c> tool's shell).</summary>
public List<string> Shell { get; set; } = [];
/// <summary><c>&lt;agentDir&gt;/scheduler/jobs.json</c>.</summary>
public static string DefaultStorePath(string agentDir) => Path.Combine(agentDir, "scheduler", "jobs.json");
/// <summary>Configuration problems that must stop the host, empty when valid.</summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (DefaultScriptTimeout < TimeSpan.FromSeconds(1) || DefaultScriptTimeout > TimeSpan.FromSeconds(JobValidation.MaxTimeoutSeconds))
errors.Add("Scheduler:DefaultScriptTimeout must be between 1 second and 1 hour.");
if (MaxJobsPerTarget < 1) errors.Add("Scheduler:MaxJobsPerTarget must be at least 1.");
if (Shell.Any(string.IsNullOrWhiteSpace)) errors.Add("Scheduler:Shell entries must not be empty.");
return errors;
}
}

View File

@@ -0,0 +1,64 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Cronos;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>Cron (Cronos, 5 fields, in a zone) and one-off times.</summary>
public static partial class Schedules
{
private const string FieldsHint = "Use 5 fields: minute hour day-of-month month day-of-week, e.g. \"0 8 * * 1-5\".";
/// <summary>Parses a standard 5-field expression or a macro (<c>@daily</c>). Seconds and <c>H</c> are rejected.</summary>
public static CronExpression ParseCron(string expression)
{
var text = expression?.Trim() ?? "";
if (text.Length == 0) throw new JobValidationException("cron is empty. " + FieldsHint);
if (!text.StartsWith('@'))
{
var fields = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (fields.Any(f => f.Split(',').Any(t => t == "H" || t.StartsWith("H(", StringComparison.Ordinal) || t.StartsWith("H/", StringComparison.Ordinal))))
throw new JobValidationException($"cron \"{text}\": the H (hash) syntax isn't supported; write the minute explicitly. {FieldsHint}");
if (fields.Length == 6)
throw new JobValidationException($"cron \"{text}\" has 6 fields; seconds aren't supported. {FieldsHint}");
if (fields.Length != 5)
throw new JobValidationException($"cron \"{text}\" has {fields.Length.ToString(CultureInfo.InvariantCulture)} fields. {FieldsHint}");
}
try
{
return CronExpression.Parse(text, CronFormat.Standard);
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
throw new JobValidationException($"cron \"{text}\" is invalid: {ex.Message.TrimEnd('.')}. {FieldsHint}");
}
}
/// <summary>The next occurrence strictly after <paramref name="after"/>, as wall-clock time in <paramref name="zone"/> (Cronos DST rules).</summary>
public static DateTimeOffset? NextCron(string cron, DateTimeOffset after, TimeZoneInfo zone) =>
ParseCron(cron).GetNextOccurrence(after, zone, inclusive: false);
/// <summary>ISO 8601. With an offset (or <c>Z</c>) it's used as given; without one it's wall-clock time in <paramref name="zone"/>.</summary>
public static DateTimeOffset ParseAt(string text, TimeZoneInfo zone)
{
var trimmed = text?.Trim() ?? "";
var hint = $"at \"{trimmed}\" isn't a date-time. Use ISO 8601, e.g. 2026-09-27T09:00 (read in the chat's time zone) or 2026-09-27T09:00+02:00.";
if (HasOffset().IsMatch(trimmed))
{
return DateTimeOffset.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.None, out var absolute)
? absolute
: throw new JobValidationException(hint);
}
if (!DateTime.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.None, out var wall)) throw new JobValidationException(hint);
wall = DateTime.SpecifyKind(wall, DateTimeKind.Unspecified);
// A time skipped by a spring-forward change doesn't exist: move it past the gap (Europe/US gaps are one hour).
if (zone.IsInvalidTime(wall)) wall = wall.AddHours(1);
// An ambiguous fall-back time is read as the first of the two (the larger offset).
var offset = zone.IsAmbiguousTime(wall) ? zone.GetAmbiguousTimeOffsets(wall).Max() : zone.GetUtcOffset(wall);
return new DateTimeOffset(wall, offset);
}
/// <summary>A time of day followed by <c>Z</c> or an offset. A bare date such as <c>2026-09-27</c> must not match (its <c>-27</c> isn't an offset).</summary>
[GeneratedRegex(@"\d{1,2}:\d{2}(:\d{2}(\.\d+)?)?\s*(Z|[+-]\d{2}(:?\d{2})?)$", RegexOptions.IgnoreCase)]
private static partial Regex HasOffset();
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>Scheduler extension: one-off and cron reminders, scheduled agent prompts and watcher scripts.</Description>
</PropertyGroup>
<ItemGroup>
<!-- Private="false": the host already ships Abstractions; an extension must not bring its own copy (docs/extensions.md). -->
<ProjectReference Include="../../src/Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj" Private="false" />
<PackageReference Include="Cronos" Version="0.13.0" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Volcanic.AgenticCore.Extensions.Tests" />
<InternalsVisibleTo Include="Volcanic.AgenticCore.Telegram.Tests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,22 @@
using System.Globalization;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>Zone ids and offsets for display and <c>TZ</c> (the extension can't reference the core's <c>TimeZoneIds</c>).</summary>
internal static class ZoneNames
{
/// <summary>The IANA id (converting a Windows id), or null for an unnamed <c>Local</c> zone.</summary>
public static string? Iana(TimeZoneInfo zone)
{
if (string.Equals(zone.Id, "Local", StringComparison.OrdinalIgnoreCase)) return null;
if (zone.HasIanaId) return zone.Id;
return TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
}
/// <summary>The IANA id when there is one, else the zone's own id.</summary>
public static string Name(TimeZoneInfo zone) => Iana(zone) ?? zone.Id;
/// <summary><c>UTC+09:00</c>.</summary>
public static string Offset(TimeSpan offset) =>
(offset < TimeSpan.Zero ? "UTC-" : "UTC+") + offset.Duration().ToString(@"hh\:mm", CultureInfo.InvariantCulture);
}