Add context-aware select statements when sending events to prevent blocking on closed channels. Implement secure argument logging using RedactArgs to mask sensitive flags. Add atomic file writes and locks for config safety. Improve session handling with mutex protection and deduplication checks. generated by llmgit Co-Authored-By: Claude <noreply@anthropic.com>
493 lines
11 KiB
Go
493 lines
11 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"`
|
|
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
|
|
}
|
|
json.Unmarshal(data, &s.jobs)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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) 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
|
|
}
|