From ea49a2c26a0a4248fe80bafabeaf8a64f43474a2 Mon Sep 17 00:00:00 2001 From: Nixie Date: Thu, 6 Aug 2026 22:09:13 +0000 Subject: [PATCH 1/2] feat(reminders): support CRON_TZ prefix for timezone-aware cron schedules (#1788) Cron reminder schedules were always evaluated in UTC because CronScheduleHelper hardcoded TimeZoneInfo.Utc. Add support for the Vixie crontab CRON_TZ= prefix: - CronScheduleHelper.SplitTimeZone strips/resolves the optional prefix via TimeZoneInfo.FindSystemTimeZoneById and passes the zone to Cronos GetNextOccurrence (DST-aware). Unknown zones produce a clear CronFormatException instead of a confusing parse error. - TryParse gains an out TimeZoneInfo overload; Describe reports the zone instead of hardcoded 'UTC'. - Stored expressions keep the prefix as-is, so re-scheduling in ReminderManagerActor.ScheduleDefinitionAsync picks up the zone with no proto or schema changes. - set_reminder tool description and ReminderScheduleParser error message document the prefix. Default behavior is unchanged: expressions without the prefix still evaluate in UTC. Fixes netclaw-dev/netclaw#1788 --- .../Reminders/CronScheduleHelperTests.cs | 99 +++++++++++++++++++ .../Reminders/SetReminderToolTests.cs | 55 +++++++++++ .../Reminders/CronScheduleHelper.cs | 96 ++++++++++++++++-- .../Reminders/ReminderScheduleParser.cs | 3 +- .../Reminders/SetReminderTool.cs | 6 +- 5 files changed, 249 insertions(+), 10 deletions(-) diff --git a/src/Netclaw.Actors.Tests/Reminders/CronScheduleHelperTests.cs b/src/Netclaw.Actors.Tests/Reminders/CronScheduleHelperTests.cs index e2005671b..f7e0a525b 100644 --- a/src/Netclaw.Actors.Tests/Reminders/CronScheduleHelperTests.cs +++ b/src/Netclaw.Actors.Tests/Reminders/CronScheduleHelperTests.cs @@ -97,4 +97,103 @@ 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=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); + } + + [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..7a630fea4 100644 --- a/src/Netclaw.Actors/Reminders/CronScheduleHelper.cs +++ b/src/Netclaw.Actors/Reminders/CronScheduleHelper.cs @@ -10,17 +10,23 @@ 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. /// 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 +38,107 @@ 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. + /// + 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."); + + try + { + var zone = TimeZoneInfo.FindSystemTimeZoneById(zoneId); + return (rest[spaceIndex..].Trim(), zone); + } + catch (TimeZoneNotFoundException) + { + throw new CronFormatException($"Unknown time zone '{zoneId}' in CRON_TZ prefix."); + } + catch (InvalidTimeZoneException) + { + throw new CronFormatException($"Invalid time zone '{zoneId}' in CRON_TZ prefix."); + } } + 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 +157,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..762219d60 100644 --- a/src/Netclaw.Actors/Reminders/ReminderScheduleParser.cs +++ b/src/Netclaw.Actors/Reminders/ReminderScheduleParser.cs @@ -78,7 +78,8 @@ 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=' 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..d5c9d4976 100644 --- a/src/Netclaw.Actors/Reminders/SetReminderTool.cs +++ b/src/Netclaw.Actors/Reminders/SetReminderTool.cs @@ -18,7 +18,9 @@ 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.", Grant = "scheduling")] public sealed partial class SetReminderTool : NetclawTool { @@ -36,7 +38,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).")] 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, From 6f5bd619eaafdc42df8df3885d70bcf9aa0b68c7 Mon Sep 17 00:00:00 2001 From: Nixie Date: Fri, 7 Aug 2026 20:11:51 +0000 Subject: [PATCH 2/2] address review: require IANA zone ids for CRON_TZ, document in skill Per https://github.com/netclaw-dev/netclaw/pull/1789#pullrequestreview-4885657597: 1. Make the CRON_TZ zone contract explicit instead of supporting quoted Windows IDs: XML docs on CronScheduleHelper and SplitTimeZone state IANA-only, unknown/empty-zone errors now point at the IANA format (e.g. 'Europe/Brussels') so 'CRON_TZ=Eastern Standard Time' fails with guidance rather than a bare 'Unknown time zone Eastern'. Tool description and ReminderScheduleParser error carry the same rule. 2. Add a 'Cron time zones (CRON_TZ)' section to the netclaw-operations scheduling reference (UTC default, syntax, DST note, IANA-only rule, loose-name translation guidance) and add a prefixed example to the schedule-type table. Bump netclaw-operations 2.40.0 -> 2.41.0. Tests: Windows-style zone rejection (with IANA hint in the message), IANA guidance in unknown-zone errors. --- .../.system/files/netclaw-operations/SKILL.md | 2 +- .../references/scheduling.md | 20 ++++++++++++++++++- .../Reminders/CronScheduleHelperTests.cs | 14 +++++++++++++ .../Reminders/CronScheduleHelper.cs | 18 ++++++++++++++--- .../Reminders/ReminderScheduleParser.cs | 3 ++- .../Reminders/SetReminderTool.cs | 6 ++++-- 6 files changed, 55 insertions(+), 8 deletions(-) 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 f7e0a525b..35c9bac63 100644 --- a/src/Netclaw.Actors.Tests/Reminders/CronScheduleHelperTests.cs +++ b/src/Netclaw.Actors.Tests/Reminders/CronScheduleHelperTests.cs @@ -106,6 +106,7 @@ public void Describe_falls_back_for_complex_expressions() [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) { @@ -168,6 +169,19 @@ public void GetNextOccurrence_unknown_cron_tz_zone_throws() 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] diff --git a/src/Netclaw.Actors/Reminders/CronScheduleHelper.cs b/src/Netclaw.Actors/Reminders/CronScheduleHelper.cs index 7a630fea4..40a12e415 100644 --- a/src/Netclaw.Actors/Reminders/CronScheduleHelper.cs +++ b/src/Netclaw.Actors/Reminders/CronScheduleHelper.cs @@ -13,6 +13,12 @@ namespace Netclaw.Actors.Reminders; /// 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="; @@ -85,6 +91,9 @@ public static bool TryParse(string expression, out TimeZoneInfo timeZone) /// 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) { @@ -100,7 +109,8 @@ internal static (string Fields, TimeZoneInfo TimeZone) SplitTimeZone(string cron var zoneId = rest[..spaceIndex].Trim(); if (zoneId.Length == 0) - throw new CronFormatException("CRON_TZ prefix requires a time zone identifier."); + throw new CronFormatException( + "CRON_TZ prefix requires a time zone identifier. Use an IANA time zone id without spaces (e.g. 'Europe/Brussels')."); try { @@ -109,11 +119,13 @@ internal static (string Fields, TimeZoneInfo TimeZone) SplitTimeZone(string cron } catch (TimeZoneNotFoundException) { - throw new CronFormatException($"Unknown time zone '{zoneId}' in CRON_TZ prefix."); + 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."); + throw new CronFormatException( + $"Invalid time zone '{zoneId}' in CRON_TZ prefix. Use an IANA time zone id without spaces (e.g. 'Europe/Brussels')."); } } diff --git a/src/Netclaw.Actors/Reminders/ReminderScheduleParser.cs b/src/Netclaw.Actors/Reminders/ReminderScheduleParser.cs index 762219d60..339e5a5aa 100644 --- a/src/Netclaw.Actors/Reminders/ReminderScheduleParser.cs +++ b/src/Netclaw.Actors/Reminders/ReminderScheduleParser.cs @@ -79,7 +79,8 @@ public static (ReminderSchedule? Schedule, string? Error) Parse( { return (null, $"Invalid cron expression '{scheduleValue}'. Use standard 5-field format (minute hour day month weekday), " + - "optionally preceded by 'CRON_TZ=' to evaluate the schedule in a specific time zone."); + "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 d5c9d4976..2e395a487 100644 --- a/src/Netclaw.Actors/Reminders/SetReminderTool.cs +++ b/src/Netclaw.Actors/Reminders/SetReminderTool.cs @@ -20,7 +20,9 @@ namespace Netclaw.Actors.Reminders; "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 * * *'). " + "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.", + "(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 { @@ -38,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 (optional 'CRON_TZ=' prefix for timezone-aware cron).")] + [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,