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.
509 lines
12 KiB
Go
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
|
|
}
|