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
This commit is contained in:
liugangjian 2026-03-04 21:36:58 +08:00
parent ada020ebeb
commit ab4b201a85
5 changed files with 141 additions and 21 deletions

View file

@ -35,6 +35,7 @@ import (
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam" _ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
_ "github.com/sipeed/picoclaw/pkg/channels/onebot" _ "github.com/sipeed/picoclaw/pkg/channels/onebot"
_ "github.com/sipeed/picoclaw/pkg/channels/slack" _ "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/telegram"
_ "github.com/sipeed/picoclaw/pkg/channels/wecom" _ "github.com/sipeed/picoclaw/pkg/channels/wecom"
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp" _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp"
@ -61,6 +62,48 @@ func gatewayCmd(debug bool) error {
if err != nil { if err != nil {
return fmt.Errorf("error loading config: %w", err) 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) provider, modelID, err := providers.CreateProvider(cfg)
if err != nil { if err != nil {
@ -217,6 +260,12 @@ func gatewayCmd(debug bool) error {
heartbeatService.Stop() heartbeatService.Stop()
cronService.Stop() cronService.Stop()
mediaStore.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() agentLoop.Stop()
fmt.Println("✓ Gateway stopped") fmt.Println("✓ Gateway stopped")
@ -234,8 +283,14 @@ func setupCronTool(
cronStorePath := filepath.Join(workspace, "cron", "jobs.json") cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
// Create cron service // 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 // Create and register CronTool
cronTool, err := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg) cronTool, err := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
if err != nil { if err != nil {

View file

@ -243,7 +243,8 @@
"proxy": "" "proxy": ""
}, },
"cron": { "cron": {
"exec_timeout_minutes": 5 "exec_timeout_minutes": 5,
"default_timezone": "Asia/Shanghai"
}, },
"mcp": { "mcp": {
"enabled": false, "enabled": false,
@ -337,5 +338,17 @@
"gateway": { "gateway": {
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 18790 "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
}
]
} }
} }

View file

@ -8,11 +8,7 @@ import (
"strings" "strings"
"sync/atomic" "sync/atomic"
"time" "time"
import (
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
"sync"
"github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/fileutil"
) )
@ -533,6 +529,11 @@ func (c *ModelConfig) Validate() error {
return nil return nil
} }
// HotReloadConfig handles configuration for hot reload functionality
type HotReloadConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_HOT_RELOAD_ENABLED"`
}
type GatewayConfig struct { type GatewayConfig struct {
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
@ -585,7 +586,8 @@ type WebToolsConfig struct {
} }
type CronToolsConfig 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 { type ExecConfig struct {
@ -1382,3 +1384,25 @@ func isValidName(name string) bool {
return true 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"`
}

View file

@ -14,6 +14,10 @@ import (
"github.com/sipeed/picoclaw/pkg/fileutil" "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 { type CronSchedule struct {
Kind string `json:"kind"` Kind string `json:"kind"`
@ -66,13 +70,15 @@ type CronService struct {
running bool running bool
stopChan chan struct{} stopChan chan struct{}
gronx *gronx.Gronx gronx *gronx.Gronx
config CronConfig
} }
func NewCronService(storePath string, onJob JobHandler) *CronService { func NewCronService(storePath string, onJob JobHandler, config CronConfig) *CronService {
cs := &CronService{ cs := &CronService{
storePath: storePath, storePath: storePath,
onJob: onJob, onJob: onJob,
gronx: gronx.New(), gronx: gronx.New(),
config: config,
} }
// Initialize and load store on creation // Initialize and load store on creation
cs.loadStore() cs.loadStore()
@ -264,15 +270,32 @@ func (cs *CronService) computeNextRun(schedule *CronSchedule, nowMS int64) *int6
return nil return nil
} }
// Use gronx to calculate next run time // 3-level fallback: schedule.TZ > config default_timezone > "Asia/Shanghai"
now := time.UnixMilli(nowMS) 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) nextTime, err := gronx.NextTickAfter(schedule.Expr, now, false)
if err != nil { 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 return nil
} }
nextMS := nextTime.UnixMilli() // Convert the calculated next time back to UTC Unix milli for storage
nextMS := nextTime.UTC().UnixMilli()
return &nextMS return &nextMS
} }

View file

@ -173,6 +173,11 @@ func (t *CronTool) addJob(args map[string]any) *ToolResult {
Kind: "cron", Kind: "cron",
Expr: cronExpr, 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 { } else {
return ErrorResult("one of at_seconds, every_seconds, or cron_expr is required") return ErrorResult("one of at_seconds, every_seconds, or cron_expr is required")
} }