diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index 174f5db627..da2de6535d 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -230,6 +230,11 @@ func setupCronTool( // Create cron service cronService := cron.NewCronService(cronStorePath, nil) + // Apply default timezone from config for cron expressions + if cfg.Tools.Cron.DefaultTimezone != "" { + cronService.SetDefaultTimezone(cfg.Tools.Cron.DefaultTimezone) + } + // Create and register CronTool if enabled var cronTool *tools.CronTool if cfg.Tools.IsToolEnabled("cron") { diff --git a/pkg/config/config.go b/pkg/config/config.go index 0ee3acfe05..423e19e16e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -578,8 +578,9 @@ type WebToolsConfig struct { } type CronToolsConfig struct { - ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"` - ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout + ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"` + ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout + DefaultTimezone string ` env:"PICOCLAW_TOOLS_CRON_DEFAULT_TIMEZONE" json:"default_timezone"` } type ExecConfig struct { diff --git a/pkg/cron/service.go b/pkg/cron/service.go index 6962041c1a..c2e225743a 100644 --- a/pkg/cron/service.go +++ b/pkg/cron/service.go @@ -15,6 +15,8 @@ import ( "github.com/sipeed/picoclaw/pkg/fileutil" ) +const defaultTimezone = "UTC" + type CronSchedule struct { Kind string `json:"kind"` AtMS *int64 `json:"atMs,omitempty"` @@ -66,6 +68,7 @@ type CronService struct { running bool stopChan chan struct{} gronx *gronx.Gronx + defaultTZ string // default timezone for cron expressions } func NewCronService(storePath string, onJob JobHandler) *CronService { @@ -266,6 +269,19 @@ func (cs *CronService) computeNextRun(schedule *CronSchedule, nowMS int64) *int6 // Use gronx to calculate next run time now := time.UnixMilli(nowMS) + // Apply timezone: schedule.TZ > service default > UTC + tz := schedule.TZ + if tz == "" { + tz = cs.defaultTZ + } + if tz == "" { + tz = defaultTimezone + } + if loc, err := time.LoadLocation(tz); err == nil { + now = now.In(loc) + } else { + log.Printf("[cron] warning: failed to load timezone '%s': %v, using UTC", tz, err) + } nextTime, err := gronx.NextTickAfter(schedule.Expr, now, false) if err != nil { log.Printf("[cron] failed to compute next run for expr '%s': %v", schedule.Expr, err) @@ -313,6 +329,14 @@ func (cs *CronService) SetOnJob(handler JobHandler) { cs.onJob = handler } +// SetDefaultTimezone sets the default timezone for cron expressions. +// If empty, falls back to UTC. +func (cs *CronService) SetDefaultTimezone(tz string) { + cs.mu.Lock() + defer cs.mu.Unlock() + cs.defaultTZ = tz +} + func (cs *CronService) loadStore() error { cs.store = &CronStore{ Version: 1, diff --git a/pkg/cron/timezone_test.go b/pkg/cron/timezone_test.go new file mode 100644 index 0000000000..2b788f0280 --- /dev/null +++ b/pkg/cron/timezone_test.go @@ -0,0 +1,147 @@ +package cron + +import ( + "path/filepath" + "testing" + "time" +) + +// TestComputeNextRun_CronTimezone verifies that cron expressions respect +// the schedule.TZ field instead of always using UTC. +func TestComputeNextRun_CronTimezone(t *testing.T) { + tmpDir := t.TempDir() + storePath := filepath.Join(tmpDir, "jobs.json") + cs := NewCronService(storePath, nil) + + now := time.Date(2026, 3, 4, 0, 0, 0, 0, time.UTC) // midnight UTC + nowMS := now.UnixMilli() + + // Cron expr "0 9 * * *" = daily at 9:00 + tests := []struct { + name string + tz string + wantHour int // expected hour in UTC of the next run + }{ + { + name: "UTC timezone", + tz: "UTC", + wantHour: 9, // 9:00 UTC + }, + { + name: "Asia/Shanghai timezone", + tz: "Asia/Shanghai", + wantHour: 1, // 9:00 CST = 1:00 UTC + }, + { + name: "empty TZ defaults to UTC", + tz: "", + wantHour: 9, // should default to UTC + }, + { + name: "US/Eastern timezone", + tz: "US/Eastern", + wantHour: 14, // 9:00 EST = 14:00 UTC (or 13:00 during DST) + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + schedule := &CronSchedule{ + Kind: "cron", + Expr: "0 9 * * *", + TZ: tt.tz, + } + + nextMS := cs.computeNextRun(schedule, nowMS) + if nextMS == nil { + t.Fatal("computeNextRun returned nil") + } + + nextTime := time.UnixMilli(*nextMS).UTC() + + if tt.name == "US/Eastern timezone" { + // Allow for DST variation (13 or 14) + if nextTime.Hour() != 13 && nextTime.Hour() != 14 { + t.Errorf("next run hour = %d, want 13 or 14 (UTC)", nextTime.Hour()) + } + } else { + if nextTime.Hour() != tt.wantHour { + t.Errorf("next run hour = %d, want %d (UTC)", nextTime.Hour(), tt.wantHour) + } + } + }) + } +} + +// TestComputeNextRun_DefaultTZ_AppliesWhenSet verifies that SetDefaultTimezone +// takes effect when schedule.TZ is empty (service default overrides UTC fallback). +func TestComputeNextRun_DefaultTZ_AppliesWhenSet(t *testing.T) { + tmpDir := t.TempDir() + storePath := filepath.Join(tmpDir, "jobs.json") + cs := NewCronService(storePath, nil) + + // Set a non-UTC default to verify it takes effect + cs.SetDefaultTimezone("Asia/Shanghai") + + now := time.Date(2026, 3, 4, 2, 0, 0, 0, time.UTC) // 02:00 UTC = 10:00 CST + nowMS := now.UnixMilli() + + scheduleEmpty := &CronSchedule{Kind: "cron", Expr: "0 9 * * *", TZ: ""} + scheduleUTC := &CronSchedule{Kind: "cron", Expr: "0 9 * * *", TZ: "UTC"} + + nextEmpty := cs.computeNextRun(scheduleEmpty, nowMS) + nextUTC := cs.computeNextRun(scheduleUTC, nowMS) + + if nextEmpty == nil || nextUTC == nil { + t.Fatal("computeNextRun returned nil") + } + + // With service default Asia/Shanghai, 9:00 CST already passed (it's 10:00 CST), + // so next run should be tomorrow 9:00 CST. + // With explicit UTC, 9:00 UTC hasn't happened yet (it's 02:00 UTC). + // They must differ, proving SetDefaultTimezone takes effect. + if *nextEmpty == *nextUTC { + t.Errorf("service default TZ (Asia/Shanghai) and UTC produced same next run time") + } +} + +// TestComputeNextRun_ServiceDefaultTZ verifies that SetDefaultTimezone() +// takes effect when schedule.TZ is empty (3-tier fallback). +func TestComputeNextRun_ServiceDefaultTZ(t *testing.T) { + tmpDir := t.TempDir() + storePath := filepath.Join(tmpDir, "jobs.json") + cs := NewCronService(storePath, nil) + + // Set service-level default to US/Eastern + cs.SetDefaultTimezone("US/Eastern") + + now := time.Date(2026, 3, 4, 0, 0, 0, 0, time.UTC) + nowMS := now.UnixMilli() + + // Schedule with empty TZ should use service default (US/Eastern), not UTC + scheduleEmpty := &CronSchedule{Kind: "cron", Expr: "0 9 * * *", TZ: ""} + scheduleCST := &CronSchedule{Kind: "cron", Expr: "0 9 * * *", TZ: "Asia/Shanghai"} + + nextEmpty := cs.computeNextRun(scheduleEmpty, nowMS) + nextCST := cs.computeNextRun(scheduleCST, nowMS) + + if nextEmpty == nil || nextCST == nil { + t.Fatal("computeNextRun returned nil") + } + + // US/Eastern 9:00 != Asia/Shanghai 9:00, so they must differ + if *nextEmpty == *nextCST { + t.Errorf("service default TZ (US/Eastern) and Asia/Shanghai produced same time; SetDefaultTimezone not working") + } + + // Schedule with explicit TZ should override service default + scheduleExplicit := &CronSchedule{Kind: "cron", Expr: "0 9 * * *", TZ: "UTC"} + nextExplicit := cs.computeNextRun(scheduleExplicit, nowMS) + if nextExplicit == nil { + t.Fatal("computeNextRun returned nil") + } + nextUTC := time.UnixMilli(*nextExplicit).UTC() + if nextUTC.Hour() != 9 { + t.Errorf("explicit TZ=UTC: next run hour = %d, want 9", nextUTC.Hour()) + } +}