Files
cc-connect/core/cron.go
T
chenhg5andClaude e8e39413e8 refactor(session): add context-aware event sending and secure arg logging
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>
2026-03-04 12:38:37 +08:00

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
}