diff --git a/feeds/skills/.system/files/netclaw-operations/SKILL.md b/feeds/skills/.system/files/netclaw-operations/SKILL.md index c7ff35f96..6ea20671a 100644 --- a/feeds/skills/.system/files/netclaw-operations/SKILL.md +++ b/feeds/skills/.system/files/netclaw-operations/SKILL.md @@ -3,7 +3,7 @@ name: netclaw-operations description: "REQUIRED when the user asks about scheduling, reminders, cron jobs, timers, background jobs, diagnostics, troubleshooting, MCP tools, daemon health, identity updates, or Netclaw capabilities and self-maintenance." metadata: author: netclaw - version: "2.45.0" + version: "2.46.0" --- # Netclaw Operations diff --git a/feeds/skills/.system/files/netclaw-operations/references/scheduling.md b/feeds/skills/.system/files/netclaw-operations/references/scheduling.md index 6d17283d2..2502cf763 100644 --- a/feeds/skills/.system/files/netclaw-operations/references/scheduling.md +++ b/feeds/skills/.system/files/netclaw-operations/references/scheduling.md @@ -11,7 +11,25 @@ audience sessions cannot use scheduling tools regardless of the config flag. |------|---------| | `once` | `"30m"`, `"2h"`, `"2026-03-15T14:30:00Z"` | | `interval` | `"30m"`, `"6h"`, `"1d"` | -| `cron` | `"0 */6 * * *"`, `"0 9 * * MON-FRI"` | +| `cron` | `"0 */6 * * *"`, `"0 9 * * MON-FRI"`, `"CRON_TZ=Europe/Brussels 0 9 * * *"` | + +### Cron time zones (`CRON_TZ`) + +Cron schedules evaluate in **UTC by default**. To anchor a schedule to a local +time zone, prefix the expression with `CRON_TZ=` (Vixie crontab +syntax). The prefix is stored with the expression, so it survives reschedules +and daemon restarts, and it is DST-aware: + +- `CRON_TZ=Europe/Brussels 0 9 * * *` — every day at 09:00 Brussels time + (08:00 UTC in winter, 07:00 UTC during DST; transitions handled automatically). +- `CRON_TZ=America/New_York 0 9 * * MON-FRI` — weekdays at 09:00 New York time. + +The time zone id must be an **IANA identifier without spaces** (e.g. +`Europe/Brussels`, `America/New_York`, `Asia/Tokyo`). Windows display names +such as `Eastern Standard Time` are not supported — the id ends at the first +space, so multi-word names resolve to a truncated, unknown identifier and the +reminder fails to schedule. When a user names a zone loosely ("Eastern time"), +translate it to the IANA id (`America/New_York`) before scheduling. Delivery contract parameters: diff --git a/src/Netclaw.Actors.Tests/Reminders/CronScheduleHelperTests.cs b/src/Netclaw.Actors.Tests/Reminders/CronScheduleHelperTests.cs index e2005671b..35c9bac63 100644 --- a/src/Netclaw.Actors.Tests/Reminders/CronScheduleHelperTests.cs +++ b/src/Netclaw.Actors.Tests/Reminders/CronScheduleHelperTests.cs @@ -97,4 +97,117 @@ public void Describe_falls_back_for_complex_expressions() Assert.StartsWith("cron '", result); } + // ── CRON_TZ time zone prefix ── + + [Theory] + [InlineData("CRON_TZ=Europe/Brussels 0 9 * * *", true)] + [InlineData("cron_tz=Europe/Brussels 0 9 * * *", true)] // case-insensitive prefix + [InlineData("CRON_TZ=UTC 0 9 * * *", true)] + [InlineData("CRON_TZ=Europe/Brussels", false)] // no expression after prefix + [InlineData("CRON_TZ= 0 9 * * *", false)] // empty zone id + [InlineData("CRON_TZ=Not/AZone 0 9 * * *", false)] // unknown zone + [InlineData("CRON_TZ=Eastern Standard Time 0 9 * * *", false)] // Windows names contain spaces; IANA only + [InlineData("CRON_TZ=Europe/Brussels not a cron", false)] + public void TryParse_validates_cron_tz_prefix(string expr, bool expected) + { + Assert.Equal(expected, CronScheduleHelper.TryParse(expr)); + } + + [Fact] + public void TryParse_resolves_cron_tz_time_zone() + { + Assert.True(CronScheduleHelper.TryParse("CRON_TZ=Europe/Brussels 0 9 * * *", out var zone)); + Assert.Equal(TimeZoneInfo.FindSystemTimeZoneById("Europe/Brussels").Id, zone.Id); + + Assert.True(CronScheduleHelper.TryParse("0 9 * * *", out var utc)); + Assert.Equal(TimeZoneInfo.Utc, utc); + } + + [Fact] + public void GetNextOccurrence_evaluates_cron_tz_in_local_zone() + { + // 09:00 CEST (UTC+2) on 2026-08-06 is 07:00 UTC + var from = new DateTimeOffset(2026, 8, 6, 8, 0, 0, TimeSpan.Zero); // 10:00 local, past today's 09:00 + var next = CronScheduleHelper.GetNextOccurrence("CRON_TZ=Europe/Brussels 0 9 * * *", from); + + Assert.NotNull(next); + Assert.Equal(new DateTimeOffset(2026, 8, 7, 7, 0, 0, TimeSpan.Zero), next); + } + + [Fact] + public void GetNextOccurrence_cron_tz_handles_dst_spring_forward() + { + // Europe/Brussels springs forward on 2026-03-29 02:00 -> 03:00 (CET +1 -> CEST +2). + // 09:00 on 2026-03-28 is 08:00 UTC; on 2026-03-30 it is 07:00 UTC. + var before = new DateTimeOffset(2026, 3, 27, 12, 0, 0, TimeSpan.Zero); + var first = CronScheduleHelper.GetNextOccurrence("CRON_TZ=Europe/Brussels 0 9 * * *", before); + Assert.Equal(new DateTimeOffset(2026, 3, 28, 8, 0, 0, TimeSpan.Zero), first); + + var after = new DateTimeOffset(2026, 3, 29, 12, 0, 0, TimeSpan.Zero); + var next = CronScheduleHelper.GetNextOccurrence("CRON_TZ=Europe/Brussels 0 9 * * *", after); + Assert.Equal(new DateTimeOffset(2026, 3, 30, 7, 0, 0, TimeSpan.Zero), next); + } + + [Fact] + public void GetNextOccurrence_cron_tz_handles_dst_fall_back() + { + // Europe/Brussels falls back on 2026-10-25 03:00 -> 02:00 (CEST +2 -> CET +1). + // 09:00 on 2026-10-24 is 07:00 UTC; on 2026-10-26 it is 08:00 UTC. + var before = new DateTimeOffset(2026, 10, 23, 12, 0, 0, TimeSpan.Zero); + var first = CronScheduleHelper.GetNextOccurrence("CRON_TZ=Europe/Brussels 0 9 * * *", before); + Assert.Equal(new DateTimeOffset(2026, 10, 24, 7, 0, 0, TimeSpan.Zero), first); + + var after = new DateTimeOffset(2026, 10, 25, 12, 0, 0, TimeSpan.Zero); + var next = CronScheduleHelper.GetNextOccurrence("CRON_TZ=Europe/Brussels 0 9 * * *", after); + Assert.Equal(new DateTimeOffset(2026, 10, 26, 8, 0, 0, TimeSpan.Zero), next); + } + + [Fact] + public void GetNextOccurrence_unknown_cron_tz_zone_throws() + { + var from = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + var ex = Assert.Throws( + () => CronScheduleHelper.GetNextOccurrence("CRON_TZ=Not/AZone 0 9 * * *", from)); + Assert.Contains("Not/AZone", ex.Message); + Assert.Contains("IANA", ex.Message); + } + + [Fact] + public void GetNextOccurrence_windows_style_cron_tz_zone_throws_with_iana_guidance() + { + // Windows display names contain spaces; the zone id ends at the first space, + // so 'Eastern Standard Time' truncates to unknown zone 'Eastern'. + var from = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + var ex = Assert.Throws( + () => CronScheduleHelper.GetNextOccurrence("CRON_TZ=Eastern Standard Time 0 9 * * *", from)); + Assert.Contains("Eastern", ex.Message); + Assert.Contains("IANA", ex.Message); + } + + [Fact] + public void GetNextOccurrence_without_prefix_still_utc() + { + // Regression: plain expressions keep UTC semantics + var from = new DateTimeOffset(2026, 8, 6, 8, 0, 0, TimeSpan.Zero); + var next = CronScheduleHelper.GetNextOccurrence("0 9 * * *", from); + Assert.Equal(new DateTimeOffset(2026, 8, 6, 9, 0, 0, TimeSpan.Zero), next); + } + + [Theory] + [InlineData("CRON_TZ=Europe/Brussels 0 9 * * *", "daily at 09:00 Europe/Brussels")] + [InlineData("CRON_TZ=America/New_York 0 9 * * MON-FRI", "weekdays at 09:00 America/New_York")] + [InlineData("CRON_TZ=UTC 0 9 * * *", "daily at 09:00 UTC")] + [InlineData("CRON_TZ=Europe/Brussels */15 * * * *", "every 15 minute(s)")] + public void Describe_reports_cron_tz_zone(string cron, string expected) + { + Assert.Equal(expected, CronScheduleHelper.Describe(cron)); + } + + [Fact] + public void Describe_falls_back_for_invalid_cron_tz_zone() + { + var result = CronScheduleHelper.Describe("CRON_TZ=Not/AZone 0 9 * * *"); + Assert.Equal("CRON_TZ=Not/AZone 0 9 * * *", result); + } + } diff --git a/src/Netclaw.Actors.Tests/Reminders/SetReminderToolTests.cs b/src/Netclaw.Actors.Tests/Reminders/SetReminderToolTests.cs index 1b8678835..f85a27505 100644 --- a/src/Netclaw.Actors.Tests/Reminders/SetReminderToolTests.cs +++ b/src/Netclaw.Actors.Tests/Reminders/SetReminderToolTests.cs @@ -146,6 +146,61 @@ public async Task Schedule_cron_every_6_hours() await execution; } + [Fact] + public async Task Schedule_cron_with_cron_tz_prefix_preserves_expression() + { + var probe = CreateTestProbe(); + var tool = new SetReminderTool(probe, _timeProvider, new SchedulingConfig()); + + var execution = Task.Run(async () => + { + var result = await tool.ExecuteAsync(new Dictionary + { + ["Id"] = "cron-tz-check", + ["Name"] = "cron-tz-check", + ["Prompt"] = "Daily local-time check", + ["ScheduleType"] = "cron", + ["Schedule"] = "CRON_TZ=Europe/Brussels 0 9 * * *", + ["DeliveryKind"] = "none" + }, TestToolExecutionContext.CreateUnbound()); + return result; + }); + + var cmd = await probe.ExpectMsgAsync(TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(ReminderScheduleType.Cron, cmd.Definition.Schedule.Type); + // The full prefixed expression is stored as-is so re-scheduling keeps the zone + Assert.Equal("CRON_TZ=Europe/Brussels 0 9 * * *", cmd.Definition.Schedule.CronExpression); + + probe.Reply(new ReminderSavedResponse( + cmd.Definition.Id, + cmd.Definition.Title, + Success: true, + NextFire: new DateTimeOffset(2026, 8, 7, 7, 0, 0, TimeSpan.Zero))); + + await execution; + } + + [Fact] + public async Task Rejects_cron_with_unknown_cron_tz_zone() + { + var probe = CreateTestProbe(); + var tool = new SetReminderTool(probe, _timeProvider, new SchedulingConfig()); + + var result = await tool.ExecuteAsync(new Dictionary + { + ["Id"] = "bad-tz", + ["Name"] = "bad-tz", + ["Prompt"] = "Test", + ["ScheduleType"] = "cron", + ["Schedule"] = "CRON_TZ=Not/AZone 0 9 * * *", + ["DeliveryKind"] = "none" + }, TestToolExecutionContext.CreateUnbound(), TestContext.Current.CancellationToken); + + Assert.Contains("Error:", result); + Assert.Contains("Invalid cron expression", result); + await probe.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); + } + [Fact] public async Task Rejects_invalid_cron_expression() { diff --git a/src/Netclaw.Actors/Reminders/CronScheduleHelper.cs b/src/Netclaw.Actors/Reminders/CronScheduleHelper.cs index cffd6f8a7..40a12e415 100644 --- a/src/Netclaw.Actors/Reminders/CronScheduleHelper.cs +++ b/src/Netclaw.Actors/Reminders/CronScheduleHelper.cs @@ -10,17 +10,29 @@ namespace Netclaw.Actors.Reminders; /// /// Wraps Cronos for cron expression parsing and next-occurrence computation. /// Uses for testable time. +/// Supports an optional leading CRON_TZ=<time-zone-id> prefix (Vixie crontab syntax) +/// to evaluate the schedule in a specific time zone; without it, schedules are evaluated in UTC. /// +/// +/// Time zone identifiers in the CRON_TZ prefix must be IANA identifiers without spaces +/// (e.g. Europe/Brussels, America/New_York). Windows display names such as +/// Eastern Standard Time are not supported: the zone id ends at the first space, so +/// multi-word names would resolve to a truncated, unknown identifier. Use IANA names instead. +/// public static class CronScheduleHelper { + private const string CronTzPrefix = "CRON_TZ="; + /// /// Computes the next occurrence of the given cron expression after . /// Returns null if the expression has no future occurrence. + /// Throws if the expression is invalid or references an unknown time zone. /// public static DateTimeOffset? GetNextOccurrence(string cronExpression, DateTimeOffset from) { - var expression = CronExpression.Parse(cronExpression, CronFormat.Standard); - return expression.GetNextOccurrence(from, TimeZoneInfo.Utc); + var (fields, timeZone) = SplitTimeZone(cronExpression); + var expression = CronExpression.Parse(fields, CronFormat.Standard); + return expression.GetNextOccurrence(from, timeZone); } /// @@ -32,31 +44,113 @@ public static class CronScheduleHelper } /// - /// Validates whether the given string is a valid 5-field cron expression. + /// Validates whether the given string is a valid 5-field cron expression, + /// optionally preceded by a CRON_TZ=<time-zone-id> prefix. /// public static bool TryParse(string expression) { + return TryParse(expression, out _); + } + + /// + /// Validates whether the given string is a valid 5-field cron expression, + /// optionally preceded by a CRON_TZ=<time-zone-id> prefix. + /// When valid, receives the resolved zone (UTC when no prefix is present). + /// + public static bool TryParse(string expression, out TimeZoneInfo timeZone) + { + timeZone = TimeZoneInfo.Utc; + if (string.IsNullOrWhiteSpace(expression)) return false; + TimeZoneInfo parsedZone; + try + { + (_, parsedZone) = SplitTimeZone(expression); + } + catch (CronFormatException) + { + return false; + } + try { - CronExpression.Parse(expression, CronFormat.Standard); - return true; + CronExpression.Parse(StripTimeZone(expression), CronFormat.Standard); } catch (CronFormatException) { return false; } + + timeZone = parsedZone; + return true; + } + + /// + /// Splits an optional CRON_TZ=<time-zone-id> prefix from the expression and + /// resolves the zone. Returns when no prefix is present. + /// Throws when the prefix references an unknown time zone. + /// The zone id must be an IANA identifier without spaces (e.g. Europe/Brussels); + /// it ends at the first space, so multi-word Windows names like Eastern Standard Time + /// are not supported. + /// + internal static (string Fields, TimeZoneInfo TimeZone) SplitTimeZone(string cronExpression) + { + var trimmed = cronExpression.Trim(); + if (!trimmed.StartsWith(CronTzPrefix, StringComparison.OrdinalIgnoreCase)) + return (trimmed, TimeZoneInfo.Utc); + + var rest = trimmed[CronTzPrefix.Length..]; + var spaceIndex = rest.IndexOf(' ', StringComparison.Ordinal); + if (spaceIndex <= 0) + throw new CronFormatException( + "CRON_TZ prefix must be followed by a space and a 5-field cron expression."); + + var zoneId = rest[..spaceIndex].Trim(); + if (zoneId.Length == 0) + throw new CronFormatException( + "CRON_TZ prefix requires a time zone identifier. Use an IANA time zone id without spaces (e.g. 'Europe/Brussels')."); + + try + { + var zone = TimeZoneInfo.FindSystemTimeZoneById(zoneId); + return (rest[spaceIndex..].Trim(), zone); + } + catch (TimeZoneNotFoundException) + { + throw new CronFormatException( + $"Unknown time zone '{zoneId}' in CRON_TZ prefix. Use an IANA time zone id without spaces (e.g. 'Europe/Brussels')."); + } + catch (InvalidTimeZoneException) + { + throw new CronFormatException( + $"Invalid time zone '{zoneId}' in CRON_TZ prefix. Use an IANA time zone id without spaces (e.g. 'Europe/Brussels')."); + } } + private static string StripTimeZone(string cronExpression) => SplitTimeZone(cronExpression).Fields; + /// /// Translates a 5-field cron expression to a human-readable English description. /// Covers common patterns; falls back to the raw expression for complex cases. + /// Handles an optional leading CRON_TZ=<time-zone-id> prefix and reports the zone. /// public static string Describe(string cronExpression) { - var parts = cronExpression.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + string fields; + string zoneLabel; + try + { + (fields, var zone) = SplitTimeZone(cronExpression); + zoneLabel = zone == TimeZoneInfo.Utc ? "UTC" : zone.Id; + } + catch (CronFormatException) + { + return cronExpression; + } + + var parts = fields.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (parts.Length != 5) return cronExpression; @@ -75,7 +169,7 @@ public static string Describe(string cronExpression) // Specific time patterns (minute and hour are fixed numbers) if (int.TryParse(minute, out var m) && int.TryParse(hour, out var h)) { - var timeStr = $"{h:D2}:{m:D2} UTC"; + var timeStr = $"{h:D2}:{m:D2} {zoneLabel}"; // Daily: 0 9 * * * if (dom == "*" && month == "*" && dow == "*") diff --git a/src/Netclaw.Actors/Reminders/ReminderScheduleParser.cs b/src/Netclaw.Actors/Reminders/ReminderScheduleParser.cs index 9a37da400..339e5a5aa 100644 --- a/src/Netclaw.Actors/Reminders/ReminderScheduleParser.cs +++ b/src/Netclaw.Actors/Reminders/ReminderScheduleParser.cs @@ -78,7 +78,9 @@ public static (ReminderSchedule? Schedule, string? Error) Parse( if (!CronScheduleHelper.TryParse(scheduleValue)) { return (null, - $"Invalid cron expression '{scheduleValue}'. Use standard 5-field format (minute hour day month weekday)."); + $"Invalid cron expression '{scheduleValue}'. Use standard 5-field format (minute hour day month weekday), " + + "optionally preceded by 'CRON_TZ=' (no spaces in the zone id, e.g. 'Europe/Brussels') " + + "to evaluate the schedule in a specific time zone."); } return (new ReminderSchedule diff --git a/src/Netclaw.Actors/Reminders/SetReminderTool.cs b/src/Netclaw.Actors/Reminders/SetReminderTool.cs index 17a5fd2cb..2e395a487 100644 --- a/src/Netclaw.Actors/Reminders/SetReminderTool.cs +++ b/src/Netclaw.Actors/Reminders/SetReminderTool.cs @@ -18,7 +18,11 @@ namespace Netclaw.Actors.Reminders; /// [NetclawTool("set_reminder", "Schedule or update a reminder by ID. If a reminder with the given ID already exists it will be updated (upsert). " + - "Supports relative durations ('30m', '2h'), interval schedules ('every 6h'), and cron ('0 */6 * * *').", + "Supports relative durations ('30m', '2h'), interval schedules ('every 6h'), and cron ('0 */6 * * *'). " + + "Cron schedules evaluate in UTC by default; prefix the expression with 'CRON_TZ=' " + + "(e.g. 'CRON_TZ=Europe/Brussels 0 9 * * *') to evaluate it in a specific time zone. " + + "The time zone id must be an IANA identifier without spaces (e.g. 'Europe/Brussels', 'America/New_York'); " + + "Windows names like 'Eastern Standard Time' are not supported.", Grant = "scheduling")] public sealed partial class SetReminderTool : NetclawTool { @@ -36,7 +40,7 @@ public record Params( string Prompt, [property: Description("Schedule type: 'once', 'interval', or 'cron'.")] string ScheduleType, - [property: Description("Schedule value: relative time, ISO 8601 datetime, interval duration, or cron expression.")] + [property: Description("Schedule value: relative time, ISO 8601 datetime, interval duration, or cron expression (optional 'CRON_TZ=' prefix for timezone-aware cron, e.g. 'CRON_TZ=Europe/Brussels 0 9 * * *').")] string Schedule, [property: Description("How to deliver results: 'current_session' (reply in this conversation), 'channel' (post to a specific target), or 'none' (silent execution). Required unless `delivery.kind` is provided.")] string? DeliveryKind = null,