Files
goclaw/internal/cron/service.go
T
viettranx bdb60de7ae chore: upgrade Go 1.25 → 1.26 and apply go fix modernizations
- 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
2026-03-10 00:09:15 +07:00

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(),
}
}