Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion feeds/skills/.system/files/netclaw-operations/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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=<time-zone-id>` (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:

Expand Down
113 changes: 113 additions & 0 deletions src/Netclaw.Actors.Tests/Reminders/CronScheduleHelperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Cronos.CronFormatException>(
() => 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<Cronos.CronFormatException>(
() => 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);
}

}
55 changes: 55 additions & 0 deletions src/Netclaw.Actors.Tests/Reminders/SetReminderToolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object?>
{
["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<SaveReminderCommand>(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<string, object?>
{
["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()
{
Expand Down
108 changes: 101 additions & 7 deletions src/Netclaw.Actors/Reminders/CronScheduleHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,29 @@ namespace Netclaw.Actors.Reminders;
/// <summary>
/// Wraps Cronos for cron expression parsing and next-occurrence computation.
/// Uses <see cref="TimeProvider"/> for testable time.
/// Supports an optional leading <c>CRON_TZ=&lt;time-zone-id&gt;</c> prefix (Vixie crontab syntax)
/// to evaluate the schedule in a specific time zone; without it, schedules are evaluated in UTC.
/// </summary>
/// <remarks>
/// Time zone identifiers in the <c>CRON_TZ</c> prefix must be IANA identifiers without spaces
/// (e.g. <c>Europe/Brussels</c>, <c>America/New_York</c>). Windows display names such as
/// <c>Eastern Standard Time</c> 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.
/// </remarks>
public static class CronScheduleHelper
{
private const string CronTzPrefix = "CRON_TZ=";

/// <summary>
/// Computes the next occurrence of the given cron expression after <paramref name="from"/>.
/// Returns null if the expression has no future occurrence.
/// Throws <see cref="CronFormatException"/> if the expression is invalid or references an unknown time zone.
/// </summary>
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);
}

/// <summary>
Expand All @@ -32,31 +44,113 @@ public static class CronScheduleHelper
}

/// <summary>
/// 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 <c>CRON_TZ=&lt;time-zone-id&gt;</c> prefix.
/// </summary>
public static bool TryParse(string expression)
{
return TryParse(expression, out _);
}

/// <summary>
/// Validates whether the given string is a valid 5-field cron expression,
/// optionally preceded by a <c>CRON_TZ=&lt;time-zone-id&gt;</c> prefix.
/// When valid, <paramref name="timeZone"/> receives the resolved zone (UTC when no prefix is present).
/// </summary>
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;
}

/// <summary>
/// Splits an optional <c>CRON_TZ=&lt;time-zone-id&gt;</c> prefix from the expression and
/// resolves the zone. Returns <see cref="TimeZoneInfo.Utc"/> when no prefix is present.
/// Throws <see cref="CronFormatException"/> when the prefix references an unknown time zone.
/// The zone id must be an IANA identifier without spaces (e.g. <c>Europe/Brussels</c>);
/// it ends at the first space, so multi-word Windows names like <c>Eastern Standard Time</c>
/// are not supported.
/// </summary>
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;

/// <summary>
/// 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 <c>CRON_TZ=&lt;time-zone-id&gt;</c> prefix and reports the zone.
/// </summary>
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;

Expand All @@ -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 == "*")
Expand Down
4 changes: 3 additions & 1 deletion src/Netclaw.Actors/Reminders/ReminderScheduleParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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=<IANA-time-zone-id>' (no spaces in the zone id, e.g. 'Europe/Brussels') " +
"to evaluate the schedule in a specific time zone.");
}

return (new ReminderSchedule
Expand Down
Loading
Loading