Skip to content
Open
Show file tree
Hide file tree
Changes from all 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