forked from admin/VolcanicAgenticCore
feat(scheduler): extension project, job model, cron and at parsing, validation
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
<Project Path="extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/Volcanic.AgenticCore.Extensions.ClaudeCode.csproj" />
|
<Project Path="extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/Volcanic.AgenticCore.Extensions.ClaudeCode.csproj" />
|
||||||
<Project Path="extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj" />
|
<Project Path="extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj" />
|
||||||
<Project Path="extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
|
<Project Path="extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
|
||||||
|
<Project Path="extensions/Volcanic.AgenticCore.Extensions.Scheduler/Volcanic.AgenticCore.Extensions.Scheduler.csproj" />
|
||||||
<Project Path="extensions/Volcanic.AgenticCore.Extensions.OpenAI/Volcanic.AgenticCore.Extensions.OpenAI.csproj" />
|
<Project Path="extensions/Volcanic.AgenticCore.Extensions.OpenAI/Volcanic.AgenticCore.Extensions.OpenAI.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/hosts/">
|
<Folder Name="/hosts/">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
42
extensions/Volcanic.AgenticCore.Extensions.Scheduler/Jobs.cs
Normal file
42
extensions/Volcanic.AgenticCore.Extensions.Scheduler/Jobs.cs
Normal 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);
|
||||||
@@ -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><agentDir>/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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using Volcanic.AgenticCore.Extensions.Scheduler;
|
||||||
|
|
||||||
|
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler;
|
||||||
|
|
||||||
|
public sealed class JobValidationTests
|
||||||
|
{
|
||||||
|
private static readonly DateTimeOffset Now = new(2026, 9, 26, 12, 3, 0, TimeSpan.Zero); // 14:03 Berlin
|
||||||
|
private static readonly TimeZoneInfo Berlin = ScheduleTests.Berlin;
|
||||||
|
|
||||||
|
private static string Error(NewJob job) =>
|
||||||
|
Assert.Throws<JobValidationException>(() => JobValidation.ValidateNew(job, Now, Berlin)).Message;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Past_at_is_rejected_beyond_the_grace()
|
||||||
|
{
|
||||||
|
Assert.Equal("at 2026-09-26 13:00 (Europe/Berlin) is in the past; now is 2026-09-26 14:03. Pick a future time.",
|
||||||
|
Error(new NewJob("x", JobKind.Reminder, At: "2026-09-26T13:00", Text: "hi")));
|
||||||
|
var schedule = JobValidation.ValidateNew(new NewJob("x", JobKind.Reminder, At: "2026-09-26T12:02:30Z", Text: "hi"), Now, Berlin);
|
||||||
|
Assert.Equal(new DateTimeOffset(2026, 9, 26, 12, 2, 30, TimeSpan.Zero), schedule.At); // within 60 s of grace
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null, null)]
|
||||||
|
[InlineData("2026-09-27T09:00", "0 9 * * *")]
|
||||||
|
public void Exactly_one_of_at_and_cron(string? at, string? cron) =>
|
||||||
|
Assert.Equal("Set exactly one of at (a one-off time) or cron (a recurring schedule).",
|
||||||
|
Error(new NewJob("x", JobKind.Reminder, at, cron, Text: "hi")));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Kind_specific_fields()
|
||||||
|
{
|
||||||
|
const string cron = "0 9 * * *";
|
||||||
|
Assert.Equal("A reminder needs text (the message to send).", Error(new NewJob("x", JobKind.Reminder, Cron: cron)));
|
||||||
|
Assert.Equal("A prompt job needs text (what to ask the agent).", Error(new NewJob("x", JobKind.Prompt, Cron: cron)));
|
||||||
|
Assert.Equal("command is only for watchers.", Error(new NewJob("x", JobKind.Reminder, Cron: cron, Text: "t", Command: "ls")));
|
||||||
|
Assert.Equal("A watcher needs command (the shell command to run).", Error(new NewJob("x", JobKind.Watcher, Cron: cron)));
|
||||||
|
Assert.Equal("A watcher takes no text: its script's output becomes the prompt.", Error(new NewJob("x", JobKind.Watcher, Cron: cron, Text: "t", Command: "ls")));
|
||||||
|
Assert.Equal("timeoutSeconds is only for watchers.", Error(new NewJob("x", JobKind.Prompt, Cron: cron, Text: "t", TimeoutSeconds: 10)));
|
||||||
|
Assert.Equal("timeoutSeconds must be between 1 and 3600.", Error(new NewJob("x", JobKind.Watcher, Cron: cron, Command: "ls", TimeoutSeconds: 0)));
|
||||||
|
Assert.Equal("timeoutSeconds must be between 1 and 3600.", Error(new NewJob("x", JobKind.Watcher, Cron: cron, Command: "ls", TimeoutSeconds: 3601)));
|
||||||
|
Assert.Equal(new JobSchedule(null, cron), JobValidation.ValidateNew(new NewJob("x", JobKind.Watcher, Cron: cron, Command: "ls", TimeoutSeconds: 3600), Now, Berlin));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Name_is_required_and_capped()
|
||||||
|
{
|
||||||
|
Assert.Equal("name is required (a short label such as \"Call the dentist\").", Error(new NewJob(" ", JobKind.Reminder, Cron: "@daily", Text: "t")));
|
||||||
|
Assert.Equal("name is 81 characters; the limit is 80.", Error(new NewJob(new string('n', 81), JobKind.Reminder, Cron: "@daily", Text: "t")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Text_is_capped_at_4000() =>
|
||||||
|
Assert.Equal("text is 4001 characters; the limit is 4000.", Error(new NewJob("x", JobKind.Prompt, Cron: "@daily", Text: new string('t', 4001))));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Stored_jobs_are_checked_without_the_future_rule()
|
||||||
|
{
|
||||||
|
var job = new ScheduledJob
|
||||||
|
{
|
||||||
|
Id = "k7m2q", Target = "111", Name = "Old", Kind = JobKind.Reminder, Text = "hi",
|
||||||
|
Schedule = new JobSchedule(Now.AddDays(-3), null), CreatedAt = Now.AddDays(-4),
|
||||||
|
};
|
||||||
|
Assert.Null(JobValidation.CheckStored(job)); // an overdue one-off is fine on load: it fires late
|
||||||
|
Assert.NotNull(JobValidation.CheckStored(job with { Schedule = new JobSchedule(Now, "0 9 * * *") }));
|
||||||
|
Assert.NotNull(JobValidation.CheckStored(job with { Schedule = new JobSchedule(null, "0 0 9 * * *") }));
|
||||||
|
Assert.NotNull(JobValidation.CheckStored(job with { Kind = JobKind.Watcher }));
|
||||||
|
Assert.NotNull(JobValidation.CheckStored(job with { Id = "" }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using Volcanic.AgenticCore.Extensions.Scheduler;
|
||||||
|
|
||||||
|
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler;
|
||||||
|
|
||||||
|
public sealed class ScheduleTests
|
||||||
|
{
|
||||||
|
internal static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||||
|
internal static readonly TimeZoneInfo Tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
|
||||||
|
|
||||||
|
private static DateTimeOffset Utc(int y, int mo, int d, int h, int mi) => new(y, mo, d, h, mi, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
private static List<DateTimeOffset> Occurrences(string cron, DateTimeOffset from, DateTimeOffset to, TimeZoneInfo zone)
|
||||||
|
{
|
||||||
|
var list = new List<DateTimeOffset>();
|
||||||
|
for (var next = Schedules.NextCron(cron, from, zone); next is { } n && n < to; next = Schedules.NextCron(cron, n, zone)) list.Add(n);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Next_cron_occurrence_is_strictly_after_from_in_the_zone()
|
||||||
|
{
|
||||||
|
var nineBerlin = Utc(2026, 9, 26, 7, 0); // 09:00 CEST
|
||||||
|
Assert.Equal(Utc(2026, 9, 27, 7, 0), Schedules.NextCron("0 9 * * *", nineBerlin, Berlin));
|
||||||
|
Assert.Equal(Utc(2026, 9, 27, 0, 0), Schedules.NextCron("0 9 * * *", nineBerlin, Tokyo)); // 09:00 JST on the 27th
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Spring_forward_day_fires_once_at_the_transition()
|
||||||
|
{
|
||||||
|
// 2026-03-29: Berlin skips 02:00–03:00. 02:30 doesn't exist, so the job fires at the transition (01:00Z).
|
||||||
|
var fires = Occurrences("30 2 * * *", Utc(2026, 3, 28, 12, 0), Utc(2026, 3, 30, 12, 0), Berlin);
|
||||||
|
Assert.Equal([Utc(2026, 3, 29, 1, 0), Utc(2026, 3, 30, 0, 30)], fires);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Fall_back_day_fires_once()
|
||||||
|
{
|
||||||
|
// 2026-10-25: Berlin repeats 02:00–03:00; 02:30 happens twice but the job fires once that day.
|
||||||
|
var fires = Occurrences("30 2 * * *", Utc(2026, 10, 24, 12, 0), Utc(2026, 10, 26, 12, 0), Berlin);
|
||||||
|
Assert.Single(fires, f => TimeZoneInfo.ConvertTime(f, Berlin).Day == 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void At_without_an_offset_is_read_in_the_zone()
|
||||||
|
{
|
||||||
|
Assert.Equal(Utc(2026, 9, 27, 7, 0), Schedules.ParseAt("2026-09-27T09:00", Berlin));
|
||||||
|
Assert.Equal(Utc(2026, 9, 27, 0, 0), Schedules.ParseAt("2026-09-27T09:00", Tokyo));
|
||||||
|
Assert.Equal(Utc(2026, 9, 27, 7, 0), Schedules.ParseAt("2026-09-27 09:00", Berlin));
|
||||||
|
Assert.Equal(Utc(2026, 9, 26, 22, 0), Schedules.ParseAt("2026-09-27", Berlin)); // a bare date is local midnight, not an offset
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void At_with_an_offset_is_used_as_given()
|
||||||
|
{
|
||||||
|
Assert.Equal(Utc(2026, 9, 27, 4, 0), Schedules.ParseAt("2026-09-27T09:00+05:00", Berlin));
|
||||||
|
Assert.Equal(Utc(2026, 9, 27, 9, 0), Schedules.ParseAt("2026-09-27T09:00Z", Tokyo));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("not a cron", "has 3 fields")]
|
||||||
|
[InlineData("61 * * * *", "is invalid")]
|
||||||
|
[InlineData("0 0 8 * * *", "seconds aren't supported")]
|
||||||
|
[InlineData("H * * * *", "H (hash)")]
|
||||||
|
[InlineData("0 H/2 * * *", "H (hash)")]
|
||||||
|
[InlineData("", "cron is empty")]
|
||||||
|
public void Invalid_cron_is_rejected_with_a_readable_message(string cron, string fragment)
|
||||||
|
{
|
||||||
|
var ex = Assert.Throws<JobValidationException>(() => Schedules.ParseCron(cron));
|
||||||
|
Assert.Contains(fragment, ex.Message);
|
||||||
|
if (cron.Length > 0) Assert.Contains("minute hour day-of-month month day-of-week", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("@daily")]
|
||||||
|
[InlineData("@hourly")]
|
||||||
|
[InlineData("0 8 * * THU")]
|
||||||
|
[InlineData("*/10 * * * *")]
|
||||||
|
[InlineData("0 8 * * 1-5")]
|
||||||
|
public void Macros_and_day_names_parse(string cron) => Schedules.ParseCron(cron);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unparseable_at_is_rejected()
|
||||||
|
{
|
||||||
|
var ex = Assert.Throws<JobValidationException>(() => Schedules.ParseAt("tomorrow at nine", Berlin));
|
||||||
|
Assert.Contains("ISO 8601", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.csproj" />
|
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.csproj" />
|
||||||
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj" />
|
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj" />
|
||||||
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
|
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
|
||||||
|
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Scheduler/Volcanic.AgenticCore.Extensions.Scheduler.csproj" />
|
||||||
<ProjectReference Include="../Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer.csproj" />
|
<ProjectReference Include="../Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Reference in New Issue
Block a user