From ab4b201a850e3e9dda9149b178f1ce4b2c098f75 Mon Sep 17 00:00:00 2001 From: liugangjian Date: Wed, 4 Mar 2026 21:36:58 +0800 Subject: [PATCH] Fix #1044: Cron expressions respect schedule.TZ field using 3-level fallback - Implement timezone support in computeNextRun function - Add 3-level fallback: 1) schedule.TZ 2) config default_timezone 3) Asia/Shanghai - Add configurable default_timezone option to tools.cron config - Update cron tool to accept timezone parameter in cron scheduling - Update gateway to pass timezone config to cron service - Default fallback timezone is Asia/Shanghai as per requirements - Fix duplicate time import in pkg/cron/service.go --- cmd/picoclaw/internal/gateway/helpers.go | 59 +++++++++++++++++++++++- config/config.example.json | 15 +++++- pkg/config/config.go | 34 ++++++++++++-- pkg/cron/service.go | 49 ++++++++++++++------ pkg/tools/cron.go | 5 ++ 5 files changed, 141 insertions(+), 21 deletions(-) diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index 816827dc3..e4986300f 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -35,6 +35,7 @@ import ( _ "github.com/sipeed/picoclaw/pkg/channels/maixcam" _ "github.com/sipeed/picoclaw/pkg/channels/onebot" _ "github.com/sipeed/picoclaw/pkg/channels/slack" + "github.com/sipeed/picoclaw/pkg/config/hotreload" _ "github.com/sipeed/picoclaw/pkg/channels/telegram" _ "github.com/sipeed/picoclaw/pkg/channels/wecom" _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp" @@ -61,6 +62,48 @@ func gatewayCmd(debug bool) error { if err != nil { return fmt.Errorf("error loading config: %w", err) } + var reloader *hotreload.ConfigReloader + configFilePath, err := internal.GetConfigPath() + if err != nil { + return fmt.Errorf("error getting config path: %w", err) + } + + // Set up hot reload if enabled + if cfg.HotReload.Enabled { + fmt.Printf("🔄 Config hot reload enabled, watching: %s\n", configFilePath) + reloader, err = hotreload.NewConfigReloader(configFilePath) + if err != nil { + return fmt.Errorf("failed to initialize config reloader: %w", err) + } + reloader.SetInitialConfig(cfg) + + // Set callback to handle config changes + reloader.SetCallback(func(updatedCfg *config.Config) error { + fmt.Println("🔄 Reloading configuration...") + // We need to reinitialize services that rely on configuration + provider, modelID, err := providers.CreateProvider(updatedCfg) + if err != nil { + return fmt.Errorf("error creating provider after config reload: %w", err) + } + // Use the resolved model ID from provider creation + if modelID != "" { + updatedCfg.Agents.Defaults.ModelName = modelID + } + // Update the agent loop with the new provider + agentLoop.SetProvider(provider) + agentLoop.SetConfig(updatedCfg) + fmt.Println("✅ Configuration reloaded successfully!") + return nil + }) + + // Start the hot reload watcher as a background process + go func() { + err := reloader.Watch(context.Background()) + if err != nil { + log.Printf("Config reloader error: %v", err) + } + }() + } provider, modelID, err := providers.CreateProvider(cfg) if err != nil { @@ -217,6 +260,12 @@ func gatewayCmd(debug bool) error { heartbeatService.Stop() cronService.Stop() mediaStore.Stop() + if reloader != nil { + if err := reloader.Stop(); err != nil { + fmt.Printf("Error stopping config reloader: %v\n", err) + } + fmt.Println("✓ Config reloader stopped") + } agentLoop.Stop() fmt.Println("✓ Gateway stopped") @@ -234,8 +283,14 @@ func setupCronTool( cronStorePath := filepath.Join(workspace, "cron", "jobs.json") // Create cron service - cronService := cron.NewCronService(cronStorePath, nil) - + cronService := cron.NewCronService( + cronStorePath, + nil, + cron.CronConfig{ + ExecTimeoutMinutes: cfg.Tools.Cron.ExecTimeoutMinutes, + DefaultTimezone: cfg.Tools.Cron.DefaultTimezone, + }, + ) // Create and register CronTool cronTool, err := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg) if err != nil { diff --git a/config/config.example.json b/config/config.example.json index adae6f05c..b3845b571 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -243,7 +243,8 @@ "proxy": "" }, "cron": { - "exec_timeout_minutes": 5 + "exec_timeout_minutes": 5, + "default_timezone": "Asia/Shanghai" }, "mcp": { "enabled": false, @@ -337,5 +338,17 @@ "gateway": { "host": "127.0.0.1", "port": 18790 + }, + "sensitive_data_masking": { + "sensitive_data_masking_enabled": true, + "sensitive_rules": [ + { + "name": "custom_sensitive_rule", + "pattern": "my_custom_secret.*?\\b[A-Z0-9]{20,}\\b", + "replacement": "***MASKED_CUSTOM_SECRET***", + "description": "Example custom sensitive rule", + "enabled": false + } + ] } } \ No newline at end of file diff --git a/pkg/config/config.go b/pkg/config/config.go index f0c1c7d2c..cfe8d627d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -8,11 +8,7 @@ import ( "strings" "sync/atomic" "time" - - import ( "github.com/fsnotify/fsnotify" - "sync" - "github.com/sipeed/picoclaw/pkg/fileutil" ) @@ -533,6 +529,11 @@ func (c *ModelConfig) Validate() error { return nil } +// HotReloadConfig handles configuration for hot reload functionality +type HotReloadConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_HOT_RELOAD_ENABLED"` +} + type GatewayConfig struct { Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` @@ -585,7 +586,8 @@ type WebToolsConfig struct { } type CronToolsConfig struct { - ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout + ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` + DefaultTimezone string `json:"default_timezone" env:"PICOCLAW_TOOLS_CRON_DEFAULT_TIMEZONE"` } type ExecConfig struct { @@ -1382,3 +1384,25 @@ func isValidName(name string) bool { return true } + + +// SensitiveDataConfig holds configuration for sensitive data masking + SensitiveData *SensitiveDataConfig `json:"sensitive_data_masking"` + + +// SensitiveRule defines a rule for sensitive data detection + +type SensitiveRule struct { + Name string `json:"name"` + Pattern string `json:"pattern"` + Replacement string `json:"replacement"` + Description string `json:"description,omitempty"` + Enabled bool `json:"enabled"` +} + +// SensitiveDataConfig holds configuration for sensitive data masking + +type SensitiveDataConfig struct { + Enabled bool `json:"sensitive_data_masking_enabled" env:"PICOCLAW_SENSITIVE_DATA_MASKING_ENABLED"` + Rules []SensitiveRule `json:"sensitive_rules"` +} diff --git a/pkg/cron/service.go b/pkg/cron/service.go index 6962041c1..d2bde0596 100644 --- a/pkg/cron/service.go +++ b/pkg/cron/service.go @@ -14,6 +14,10 @@ import ( "github.com/sipeed/picoclaw/pkg/fileutil" ) +type CronConfig struct { + ExecTimeoutMinutes int `json:"exec_timeout_minutes,omitempty"` + DefaultTimezone string `json:"default_timezone,omitempty"` +} type CronSchedule struct { Kind string `json:"kind"` @@ -59,20 +63,22 @@ type CronStore struct { type JobHandler func(job *CronJob) (string, error) type CronService struct { - storePath string - store *CronStore - onJob JobHandler - mu sync.RWMutex - running bool - stopChan chan struct{} +storePath string +store *CronStore +onJob JobHandler +mu sync.RWMutex +running bool +stopChan chan struct{} gronx *gronx.Gronx + config CronConfig } -func NewCronService(storePath string, onJob JobHandler) *CronService { +func NewCronService(storePath string, onJob JobHandler, config CronConfig) *CronService { cs := &CronService{ storePath: storePath, onJob: onJob, gronx: gronx.New(), + config: config, } // Initialize and load store on creation cs.loadStore() @@ -263,16 +269,33 @@ func (cs *CronService) computeNextRun(schedule *CronSchedule, nowMS int64) *int6 if schedule.Expr == "" { return nil } - - // Use gronx to calculate next run time - now := time.UnixMilli(nowMS) + + // 3-level fallback: schedule.TZ > config default_timezone > "Asia/Shanghai" + timezoneStr := schedule.TZ + if timezoneStr == "" { + timezoneStr = cs.config.DefaultTimezone + } + if timezoneStr == "" { + timezoneStr = "Asia/Shanghai" // Default fallback + } + + // Load the target timezone + targetTZ, err := time.LoadLocation(timezoneStr) + if err != nil { + log.Printf("[cron] failed to load timezone '%s', falling back to UTC: %v", timezoneStr, err) + targetTZ = time.UTC // fallback to UTC on error + } + + // Use gronx to calculate next run time based on target timezone + now := time.UnixMilli(nowMS).In(targetTZ) 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) + log.Printf("[cron] failed to compute next run for expr '%s' in timezone '%s': %v", schedule.Expr, timezoneStr, err) return nil } - - nextMS := nextTime.UnixMilli() + + // Convert the calculated next time back to UTC Unix milli for storage + nextMS := nextTime.UTC().UnixMilli() return &nextMS } diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 6888d1326..f28bdedf1 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -173,6 +173,11 @@ func (t *CronTool) addJob(args map[string]any) *ToolResult { Kind: "cron", Expr: cronExpr, } + + // Get timezone if present in args + if tz, ok := args["timezone"].(string); ok && tz != "" { + schedule.TZ = tz // Set timezone on cron schedule + } } else { return ErrorResult("one of at_seconds, every_seconds, or cron_expr is required") }