Files
cc-connect/core/cron.go
T
kevinWangSheng b3ddd1a0f5 fix(cron): log json.Unmarshal error instead of ignoring it
When the cron jobs file is corrupted, json.Unmarshal fails silently
and all scheduled tasks are lost without any log output. Add error
logging so the issue is visible in logs.
2026-03-08 17:30:14 -07:00

509 lines
12 KiB
Go

package core
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/robfig/cron/v3"
)
// CronJob represents a persisted scheduled task.
type CronJob struct {
ID string `json:"id"`
Project string `json:"project"`
SessionKey string `json:"session_key"`
CronExpr string `json:"cron_expr"`
Prompt string `json:"prompt"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
Silent *bool `json:"silent,omitempty"` // suppress start notification; nil = use global default
CreatedAt time.Time `json:"created_at"`
LastRun time.Time `json:"last_run,omitempty"`
LastError string `json:"last_error,omitempty"`
}
// CronStore persists cron jobs to a JSON file.
type CronStore struct {
path string
mu sync.Mutex
jobs []*CronJob
}
func NewCronStore(dataDir string) (*CronStore, error) {
dir := filepath.Join(dataDir, "crons")
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
path := filepath.Join(dir, "jobs.json")
s := &CronStore{path: path}
s.load()
return s, nil
}
func (s *CronStore) load() {
data, err := os.ReadFile(s.path)
if err != nil {
return
}
if err := json.Unmarshal(data, &s.jobs); err != nil {
slog.Error("cron: failed to load jobs", "path", s.path, "error", err)
}
}
func (s *CronStore) save() error {
data, err := json.MarshalIndent(s.jobs, "", " ")
if err != nil {
return err
}
return AtomicWriteFile(s.path, data, 0o644)
}
func (s *CronStore) Add(job *CronJob) error {
s.mu.Lock()
defer s.mu.Unlock()
s.jobs = append(s.jobs, job)
return s.save()
}
func (s *CronStore) Remove(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for i, j := range s.jobs {
if j.ID == id {
s.jobs = append(s.jobs[:i], s.jobs[i+1:]...)
s.save()
return true
}
}
return false
}
func (s *CronStore) SetEnabled(id string, enabled bool) bool {
s.mu.Lock()
defer s.mu.Unlock()
for _, j := range s.jobs {
if j.ID == id {
j.Enabled = enabled
s.save()
return true
}
}
return false
}
func (s *CronStore) MarkRun(id string, err error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, j := range s.jobs {
if j.ID == id {
j.LastRun = time.Now()
if err != nil {
j.LastError = err.Error()
} else {
j.LastError = ""
}
s.save()
return
}
}
}
func (s *CronStore) List() []*CronJob {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]*CronJob, len(s.jobs))
copy(out, s.jobs)
return out
}
func (s *CronStore) ListByProject(project string) []*CronJob {
s.mu.Lock()
defer s.mu.Unlock()
var out []*CronJob
for _, j := range s.jobs {
if j.Project == project {
out = append(out, j)
}
}
return out
}
func (s *CronStore) ListBySessionKey(sessionKey string) []*CronJob {
s.mu.Lock()
defer s.mu.Unlock()
var out []*CronJob
for _, j := range s.jobs {
if j.SessionKey == sessionKey {
out = append(out, j)
}
}
return out
}
func (s *CronStore) Get(id string) *CronJob {
s.mu.Lock()
defer s.mu.Unlock()
for _, j := range s.jobs {
if j.ID == id {
return j
}
}
return nil
}
// CronScheduler runs cron jobs by injecting synthetic messages into engines.
type CronScheduler struct {
store *CronStore
cron *cron.Cron
engines map[string]*Engine // project name → engine
mu sync.RWMutex
entries map[string]cron.EntryID // job ID → cron entry
defaultSilent bool // global default for suppressing cron start notifications
}
func NewCronScheduler(store *CronStore) *CronScheduler {
return &CronScheduler{
store: store,
cron: cron.New(),
engines: make(map[string]*Engine),
entries: make(map[string]cron.EntryID),
}
}
func (cs *CronScheduler) RegisterEngine(name string, e *Engine) {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.engines[name] = e
}
func (cs *CronScheduler) SetDefaultSilent(silent bool) {
cs.defaultSilent = silent
}
// IsSilent returns whether the cron job should suppress the start notification.
func (cs *CronScheduler) IsSilent(job *CronJob) bool {
if job.Silent != nil {
return *job.Silent
}
return cs.defaultSilent
}
func (cs *CronScheduler) Start() error {
jobs := cs.store.List()
for _, job := range jobs {
if job.Enabled {
if err := cs.scheduleJob(job); err != nil {
slog.Warn("cron: failed to schedule job", "id", job.ID, "error", err)
}
}
}
cs.cron.Start()
slog.Info("cron: scheduler started", "jobs", len(jobs))
return nil
}
func (cs *CronScheduler) Stop() {
cs.cron.Stop()
}
func (cs *CronScheduler) AddJob(job *CronJob) error {
if _, err := cron.ParseStandard(job.CronExpr); err != nil {
return fmt.Errorf("invalid cron expression %q: %w", job.CronExpr, err)
}
if err := cs.store.Add(job); err != nil {
return err
}
if job.Enabled {
return cs.scheduleJob(job)
}
return nil
}
func (cs *CronScheduler) RemoveJob(id string) bool {
cs.mu.Lock()
if entryID, ok := cs.entries[id]; ok {
cs.cron.Remove(entryID)
delete(cs.entries, id)
}
cs.mu.Unlock()
return cs.store.Remove(id)
}
func (cs *CronScheduler) EnableJob(id string) error {
if !cs.store.SetEnabled(id, true) {
return fmt.Errorf("job %q not found", id)
}
job := cs.store.Get(id)
if job != nil {
return cs.scheduleJob(job)
}
return nil
}
func (cs *CronScheduler) DisableJob(id string) error {
if !cs.store.SetEnabled(id, false) {
return fmt.Errorf("job %q not found", id)
}
cs.mu.Lock()
if entryID, ok := cs.entries[id]; ok {
cs.cron.Remove(entryID)
delete(cs.entries, id)
}
cs.mu.Unlock()
return nil
}
func (cs *CronScheduler) Store() *CronStore {
return cs.store
}
// NextRun returns the next scheduled run time for a job, or zero if not scheduled.
func (cs *CronScheduler) NextRun(jobID string) time.Time {
cs.mu.RLock()
entryID, ok := cs.entries[jobID]
cs.mu.RUnlock()
if !ok {
return time.Time{}
}
for _, e := range cs.cron.Entries() {
if e.ID == entryID {
return e.Next
}
}
return time.Time{}
}
func (cs *CronScheduler) scheduleJob(job *CronJob) error {
cs.mu.Lock()
defer cs.mu.Unlock()
// Remove existing schedule if any
if old, ok := cs.entries[job.ID]; ok {
cs.cron.Remove(old)
}
jobID := job.ID
entryID, err := cs.cron.AddFunc(job.CronExpr, func() {
cs.executeJob(jobID)
})
if err != nil {
return err
}
cs.entries[jobID] = entryID
return nil
}
const cronJobTimeout = 30 * time.Minute
func (cs *CronScheduler) executeJob(jobID string) {
job := cs.store.Get(jobID)
if job == nil || !job.Enabled {
return
}
cs.mu.RLock()
engine, ok := cs.engines[job.Project]
cs.mu.RUnlock()
if !ok {
slog.Error("cron: project not found", "job", jobID, "project", job.Project)
cs.store.MarkRun(jobID, fmt.Errorf("project %q not found", job.Project))
return
}
slog.Info("cron: executing job", "id", jobID, "project", job.Project, "prompt", truncateStr(job.Prompt, 60))
done := make(chan error, 1)
go func() {
done <- engine.ExecuteCronJob(job)
}()
var err error
select {
case err = <-done:
case <-time.After(cronJobTimeout):
err = fmt.Errorf("job timed out after %v", cronJobTimeout)
}
cs.store.MarkRun(jobID, err)
if err != nil {
slog.Error("cron: job failed", "id", jobID, "error", err)
} else {
slog.Info("cron: job completed", "id", jobID)
}
}
func GenerateCronID() string {
b := make([]byte, 4)
rand.Read(b)
return hex.EncodeToString(b)
}
func truncateStr(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
var cronWeekdays = map[Language][7]string{
LangEnglish: {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"},
LangChinese: {"周日", "周一", "周二", "周三", "周四", "周五", "周六"},
LangTraditionalChinese: {"週日", "週一", "週二", "週三", "週四", "週五", "週六"},
LangJapanese: {"日曜", "月曜", "火曜", "水曜", "木曜", "金曜", "土曜"},
LangSpanish: {"domingo", "lunes", "martes", "miércoles", "jueves", "viernes", "sábado"},
}
var cronMonths = map[Language][13]string{
LangEnglish: {"", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"},
LangChinese: {"", "1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"},
LangTraditionalChinese: {"", "1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"},
LangJapanese: {"", "1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"},
LangSpanish: {"", "ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sep", "oct", "nov", "dic"},
}
func cronLangNames(lang Language) (weekdays [7]string, months [13]string) {
if w, ok := cronWeekdays[lang]; ok {
weekdays = w
} else {
weekdays = cronWeekdays[LangEnglish]
}
if m, ok := cronMonths[lang]; ok {
months = m
} else {
months = cronMonths[LangEnglish]
}
return
}
func isZhLikeLang(lang Language) bool {
return lang == LangChinese || lang == LangTraditionalChinese || lang == LangJapanese
}
// CronExprToHuman converts a standard 5-field cron expression to a human-readable string.
func CronExprToHuman(expr string, lang Language) string {
fields := strings.Fields(expr)
if len(fields) != 5 {
return expr
}
minute, hour, dom, month, dow := fields[0], fields[1], fields[2], fields[3], fields[4]
weekdays, months := cronLangNames(lang)
cjk := isZhLikeLang(lang)
var parts []string
// Weekday
if dow != "*" {
if d, err := fmt.Sscanf(dow, "%d", new(int)); err == nil && d == 1 {
var n int
fmt.Sscanf(dow, "%d", &n)
if n >= 0 && n <= 6 {
if cjk {
parts = append(parts, weekdays[n])
} else {
parts = append(parts, "Every "+weekdays[n])
}
}
} else {
parts = append(parts, "weekday("+dow+")")
}
}
// Month
if month != "*" {
if m, err := fmt.Sscanf(month, "%d", new(int)); err == nil && m == 1 {
var n int
fmt.Sscanf(month, "%d", &n)
if n >= 1 && n <= 12 {
parts = append(parts, months[n])
}
}
}
// Day of month
if dom != "*" {
if cjk {
parts = append(parts, dom+"日")
} else {
parts = append(parts, "day "+dom)
}
}
// Time
if hour != "*" && minute != "*" {
parts = append(parts, fmt.Sprintf("%s:%s", padZero(hour), padZero(minute)))
} else if hour != "*" {
if cjk {
parts = append(parts, hour+"時")
} else {
parts = append(parts, "hour "+hour)
}
} else if minute != "*" {
switch lang {
case LangChinese, LangTraditionalChinese:
parts = append(parts, "每小时第"+minute+"分")
case LangJapanese:
parts = append(parts, "毎時"+minute+"分")
default:
parts = append(parts, "minute "+minute+" of every hour")
}
}
// Frequency hint
if dow == "*" && month == "*" && dom == "*" {
switch lang {
case LangChinese, LangTraditionalChinese:
return "每天 " + strings.Join(parts, " ")
case LangJapanese:
return "毎日 " + strings.Join(parts, " ")
case LangSpanish:
return "Diario a las " + strings.Join(parts, " ")
default:
return "Daily at " + strings.Join(parts, " ")
}
}
if dow != "*" && month == "*" && dom == "*" {
switch lang {
case LangChinese, LangTraditionalChinese:
return "每" + strings.Join(parts, " ")
case LangJapanese:
return "毎" + strings.Join(parts, " ")
default:
return strings.Join(parts, " at ")
}
}
if dom != "*" && month == "*" && dow == "*" {
switch lang {
case LangChinese, LangTraditionalChinese:
return "每月" + strings.Join(parts, " ")
case LangJapanese:
return "毎月" + strings.Join(parts, " ")
case LangSpanish:
return "Mensual, " + strings.Join(parts, ", ")
default:
return "Monthly, " + strings.Join(parts, ", ")
}
}
if cjk {
return strings.Join(parts, " ")
}
return strings.Join(parts, ", ")
}
func padZero(s string) string {
if len(s) == 1 {
return "0" + s
}
return s
}