mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-11 18:12:09 +00:00
bdb60de7ae
- Update go.mod and Dockerfile to Go 1.26 - Apply `go fix ./...` stdlib modernizations across 170+ files - Add `go fix` to post-implementation checklist in CLAUDE.md - Fix go fix misapplied rewrite in loop_history.go
272 lines
6.2 KiB
Go
272 lines
6.2 KiB
Go
package cron
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
)
|
|
|
|
// Service manages cron jobs with persistence, scheduling, and execution.
|
|
type Service struct {
|
|
storePath string
|
|
store Store
|
|
onJob JobHandler
|
|
running bool
|
|
stopChan chan struct{}
|
|
mu sync.Mutex
|
|
runLog []RunLogEntry // in-memory run history (last 200 entries)
|
|
retryCfg RetryConfig // retry config for failed jobs
|
|
}
|
|
|
|
// NewService creates a new cron service.
|
|
// storePath is the path to the JSON file for job persistence.
|
|
// onJob is the callback invoked when a job fires (can be set later via SetOnJob).
|
|
func NewService(storePath string, onJob JobHandler) *Service {
|
|
return &Service{
|
|
storePath: storePath,
|
|
store: Store{Version: 1},
|
|
onJob: onJob,
|
|
retryCfg: DefaultRetryConfig(),
|
|
}
|
|
}
|
|
|
|
// SetRetryConfig overrides the default retry configuration.
|
|
func (cs *Service) SetRetryConfig(cfg RetryConfig) {
|
|
cs.mu.Lock()
|
|
defer cs.mu.Unlock()
|
|
cs.retryCfg = cfg
|
|
}
|
|
|
|
// SetOnJob sets the job execution callback.
|
|
func (cs *Service) SetOnJob(handler JobHandler) {
|
|
cs.mu.Lock()
|
|
defer cs.mu.Unlock()
|
|
cs.onJob = handler
|
|
}
|
|
|
|
// Start loads persisted jobs and begins the scheduling loop.
|
|
func (cs *Service) Start() error {
|
|
cs.mu.Lock()
|
|
defer cs.mu.Unlock()
|
|
|
|
if cs.running {
|
|
return nil
|
|
}
|
|
|
|
if err := cs.loadUnsafe(); err != nil {
|
|
slog.Warn("cron: failed to load store, starting fresh", "error", err)
|
|
cs.store = Store{Version: 1}
|
|
}
|
|
|
|
// Compute next runs for all enabled jobs
|
|
now := nowMS()
|
|
for i := range cs.store.Jobs {
|
|
job := &cs.store.Jobs[i]
|
|
if job.Enabled && job.State.NextRunAtMS == nil {
|
|
next := cs.computeNextRun(&job.Schedule, now)
|
|
job.State.NextRunAtMS = next
|
|
}
|
|
}
|
|
cs.saveUnsafe()
|
|
|
|
cs.stopChan = make(chan struct{})
|
|
cs.running = true
|
|
|
|
go cs.runLoop(cs.stopChan)
|
|
|
|
slog.Info("cron service started", "jobs", len(cs.store.Jobs))
|
|
return nil
|
|
}
|
|
|
|
// Stop halts the scheduling loop.
|
|
func (cs *Service) Stop() {
|
|
cs.mu.Lock()
|
|
defer cs.mu.Unlock()
|
|
|
|
if !cs.running {
|
|
return
|
|
}
|
|
|
|
close(cs.stopChan)
|
|
cs.running = false
|
|
slog.Info("cron service stopped")
|
|
}
|
|
|
|
// AddJob creates and registers a new cron job.
|
|
func (cs *Service) AddJob(name string, schedule Schedule, message string, deliver bool, channel, to, agentID string) (*Job, error) {
|
|
cs.mu.Lock()
|
|
defer cs.mu.Unlock()
|
|
|
|
// Validate schedule
|
|
if err := cs.validateSchedule(&schedule); err != nil {
|
|
return nil, fmt.Errorf("invalid schedule: %w", err)
|
|
}
|
|
|
|
now := nowMS()
|
|
job := Job{
|
|
ID: generateID(),
|
|
Name: name,
|
|
AgentID: agentID,
|
|
Enabled: true,
|
|
Schedule: schedule,
|
|
Payload: Payload{
|
|
Kind: "agent_turn",
|
|
Message: message,
|
|
Deliver: deliver,
|
|
Channel: channel,
|
|
To: to,
|
|
},
|
|
CreatedAtMS: now,
|
|
UpdatedAtMS: now,
|
|
DeleteAfterRun: schedule.Kind == "at",
|
|
}
|
|
|
|
next := cs.computeNextRun(&job.Schedule, now)
|
|
job.State.NextRunAtMS = next
|
|
|
|
cs.store.Jobs = append(cs.store.Jobs, job)
|
|
cs.saveUnsafe()
|
|
|
|
slog.Info("cron job added", "id", job.ID, "name", name, "kind", schedule.Kind)
|
|
return &job, nil
|
|
}
|
|
|
|
// RemoveJob deletes a job by ID.
|
|
func (cs *Service) RemoveJob(jobID string) error {
|
|
cs.mu.Lock()
|
|
defer cs.mu.Unlock()
|
|
|
|
for i, job := range cs.store.Jobs {
|
|
if job.ID == jobID {
|
|
cs.store.Jobs = append(cs.store.Jobs[:i], cs.store.Jobs[i+1:]...)
|
|
cs.saveUnsafe()
|
|
slog.Info("cron job removed", "id", jobID)
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("job %s not found", jobID)
|
|
}
|
|
|
|
// EnableJob toggles a job's enabled state.
|
|
func (cs *Service) EnableJob(jobID string, enabled bool) error {
|
|
cs.mu.Lock()
|
|
defer cs.mu.Unlock()
|
|
|
|
for i := range cs.store.Jobs {
|
|
if cs.store.Jobs[i].ID == jobID {
|
|
cs.store.Jobs[i].Enabled = enabled
|
|
cs.store.Jobs[i].UpdatedAtMS = nowMS()
|
|
if enabled {
|
|
next := cs.computeNextRun(&cs.store.Jobs[i].Schedule, nowMS())
|
|
cs.store.Jobs[i].State.NextRunAtMS = next
|
|
} else {
|
|
cs.store.Jobs[i].State.NextRunAtMS = nil
|
|
}
|
|
cs.saveUnsafe()
|
|
slog.Info("cron job toggled", "id", jobID, "enabled", enabled)
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("job %s not found", jobID)
|
|
}
|
|
|
|
// ListJobs returns all jobs, optionally including disabled ones.
|
|
func (cs *Service) ListJobs(includeDisabled bool) []Job {
|
|
cs.mu.Lock()
|
|
defer cs.mu.Unlock()
|
|
|
|
var result []Job
|
|
for _, job := range cs.store.Jobs {
|
|
if includeDisabled || job.Enabled {
|
|
result = append(result, job)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetJob returns a job by ID.
|
|
func (cs *Service) GetJob(jobID string) (*Job, bool) {
|
|
cs.mu.Lock()
|
|
defer cs.mu.Unlock()
|
|
|
|
for i, job := range cs.store.Jobs {
|
|
if job.ID == jobID {
|
|
return &cs.store.Jobs[i], true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// UpdateJob patches an existing job's fields.
|
|
// Matching TS cron.update — only non-zero/non-nil fields are applied.
|
|
func (cs *Service) UpdateJob(jobID string, patch JobPatch) (*Job, error) {
|
|
cs.mu.Lock()
|
|
defer cs.mu.Unlock()
|
|
|
|
for i := range cs.store.Jobs {
|
|
if cs.store.Jobs[i].ID != jobID {
|
|
continue
|
|
}
|
|
job := &cs.store.Jobs[i]
|
|
|
|
if patch.Name != "" {
|
|
job.Name = patch.Name
|
|
}
|
|
if patch.AgentID != nil {
|
|
job.AgentID = *patch.AgentID
|
|
}
|
|
if patch.Enabled != nil {
|
|
job.Enabled = *patch.Enabled
|
|
}
|
|
if patch.Schedule != nil {
|
|
if err := cs.validateSchedule(patch.Schedule); err != nil {
|
|
return nil, fmt.Errorf("invalid schedule: %w", err)
|
|
}
|
|
job.Schedule = *patch.Schedule
|
|
}
|
|
if patch.Message != "" {
|
|
job.Payload.Message = patch.Message
|
|
}
|
|
if patch.Deliver != nil {
|
|
job.Payload.Deliver = *patch.Deliver
|
|
}
|
|
if patch.Channel != nil {
|
|
job.Payload.Channel = *patch.Channel
|
|
}
|
|
if patch.To != nil {
|
|
job.Payload.To = *patch.To
|
|
}
|
|
if patch.DeleteAfterRun != nil {
|
|
job.DeleteAfterRun = *patch.DeleteAfterRun
|
|
}
|
|
|
|
job.UpdatedAtMS = nowMS()
|
|
|
|
// Recompute next run if schedule or enabled changed
|
|
if job.Enabled {
|
|
next := cs.computeNextRun(&job.Schedule, nowMS())
|
|
job.State.NextRunAtMS = next
|
|
} else {
|
|
job.State.NextRunAtMS = nil
|
|
}
|
|
|
|
cs.saveUnsafe()
|
|
slog.Info("cron job updated", "id", jobID)
|
|
result := cs.store.Jobs[i] // copy
|
|
return &result, nil
|
|
}
|
|
return nil, fmt.Errorf("job %s not found", jobID)
|
|
}
|
|
|
|
// Status returns the service status.
|
|
func (cs *Service) Status() map[string]any {
|
|
cs.mu.Lock()
|
|
defer cs.mu.Unlock()
|
|
|
|
return map[string]any{
|
|
"enabled": cs.running,
|
|
"jobs": len(cs.store.Jobs),
|
|
"nextWakeAtMs": cs.getNextWakeMS(),
|
|
}
|
|
}
|