feat: add cron tool integration with agent
- Add adhocore/gronx dependency for cron expression parsing - Fix CronService race conditions and add cron expression support - Add CronTool with add/list/remove/enable/disable actions - Add ContextualTool interface for tools needing channel/chatID context - Add ProcessDirectWithChannel to AgentLoop for cron job execution - Register CronTool in gateway and wire up onJob handler - Fix slice bounds panic in addJob for short messages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,17 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/adhocore/gronx"
|
||||
)
|
||||
|
||||
type CronSchedule struct {
|
||||
@@ -58,6 +63,7 @@ type CronService struct {
|
||||
mu sync.RWMutex
|
||||
running bool
|
||||
stopChan chan struct{}
|
||||
gronx *gronx.Gronx
|
||||
}
|
||||
|
||||
func NewCronService(storePath string, onJob JobHandler) *CronService {
|
||||
@@ -65,7 +71,9 @@ func NewCronService(storePath string, onJob JobHandler) *CronService {
|
||||
storePath: storePath,
|
||||
onJob: onJob,
|
||||
stopChan: make(chan struct{}),
|
||||
gronx: gronx.New(),
|
||||
}
|
||||
// Initialize and load store on creation
|
||||
cs.loadStore()
|
||||
return cs
|
||||
}
|
||||
@@ -83,7 +91,7 @@ func (cs *CronService) Start() error {
|
||||
}
|
||||
|
||||
cs.recomputeNextRuns()
|
||||
if err := cs.saveStore(); err != nil {
|
||||
if err := cs.saveStoreUnsafe(); err != nil {
|
||||
return fmt.Errorf("failed to save store: %w", err)
|
||||
}
|
||||
|
||||
@@ -120,30 +128,47 @@ func (cs *CronService) runLoop() {
|
||||
}
|
||||
|
||||
func (cs *CronService) checkJobs() {
|
||||
cs.mu.RLock()
|
||||
cs.mu.Lock()
|
||||
|
||||
if !cs.running {
|
||||
cs.mu.RUnlock()
|
||||
cs.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
var dueJobs []*CronJob
|
||||
|
||||
// Collect jobs that are due (we need to copy them to execute outside lock)
|
||||
for i := range cs.store.Jobs {
|
||||
job := &cs.store.Jobs[i]
|
||||
if job.Enabled && job.State.NextRunAtMS != nil && *job.State.NextRunAtMS <= now {
|
||||
dueJobs = append(dueJobs, job)
|
||||
// Create a shallow copy of the job for execution
|
||||
jobCopy := *job
|
||||
dueJobs = append(dueJobs, &jobCopy)
|
||||
}
|
||||
}
|
||||
cs.mu.RUnlock()
|
||||
|
||||
// Update next run times for due jobs immediately (before executing)
|
||||
for i := range cs.store.Jobs {
|
||||
for _, dueJob := range dueJobs {
|
||||
if cs.store.Jobs[i].ID == dueJob.ID {
|
||||
// Reset NextRunAtMS temporarily so we don't re-execute
|
||||
cs.store.Jobs[i].State.NextRunAtMS = nil
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := cs.saveStoreUnsafe(); err != nil {
|
||||
log.Printf("[cron] failed to save store: %v", err)
|
||||
}
|
||||
|
||||
cs.mu.Unlock()
|
||||
|
||||
// Execute jobs outside the lock
|
||||
for _, job := range dueJobs {
|
||||
cs.executeJob(job)
|
||||
}
|
||||
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.saveStore()
|
||||
}
|
||||
|
||||
func (cs *CronService) executeJob(job *CronJob) {
|
||||
@@ -154,30 +179,42 @@ func (cs *CronService) executeJob(job *CronJob) {
|
||||
_, err = cs.onJob(job)
|
||||
}
|
||||
|
||||
// Now acquire lock to update state
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
job.State.LastRunAtMS = &startTime
|
||||
job.UpdatedAtMS = time.Now().UnixMilli()
|
||||
// Find the job in store and update it
|
||||
for i := range cs.store.Jobs {
|
||||
if cs.store.Jobs[i].ID == job.ID {
|
||||
cs.store.Jobs[i].State.LastRunAtMS = &startTime
|
||||
cs.store.Jobs[i].UpdatedAtMS = time.Now().UnixMilli()
|
||||
|
||||
if err != nil {
|
||||
job.State.LastStatus = "error"
|
||||
job.State.LastError = err.Error()
|
||||
} else {
|
||||
job.State.LastStatus = "ok"
|
||||
job.State.LastError = ""
|
||||
if err != nil {
|
||||
cs.store.Jobs[i].State.LastStatus = "error"
|
||||
cs.store.Jobs[i].State.LastError = err.Error()
|
||||
} else {
|
||||
cs.store.Jobs[i].State.LastStatus = "ok"
|
||||
cs.store.Jobs[i].State.LastError = ""
|
||||
}
|
||||
|
||||
// Compute next run time
|
||||
if cs.store.Jobs[i].Schedule.Kind == "at" {
|
||||
if cs.store.Jobs[i].DeleteAfterRun {
|
||||
cs.removeJobUnsafe(job.ID)
|
||||
} else {
|
||||
cs.store.Jobs[i].Enabled = false
|
||||
cs.store.Jobs[i].State.NextRunAtMS = nil
|
||||
}
|
||||
} else {
|
||||
nextRun := cs.computeNextRun(&cs.store.Jobs[i].Schedule, time.Now().UnixMilli())
|
||||
cs.store.Jobs[i].State.NextRunAtMS = nextRun
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if job.Schedule.Kind == "at" {
|
||||
if job.DeleteAfterRun {
|
||||
cs.removeJobUnsafe(job.ID)
|
||||
} else {
|
||||
job.Enabled = false
|
||||
job.State.NextRunAtMS = nil
|
||||
}
|
||||
} else {
|
||||
nextRun := cs.computeNextRun(&job.Schedule, time.Now().UnixMilli())
|
||||
job.State.NextRunAtMS = nextRun
|
||||
if err := cs.saveStoreUnsafe(); err != nil {
|
||||
log.Printf("[cron] failed to save store: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +234,23 @@ func (cs *CronService) computeNextRun(schedule *CronSchedule, nowMS int64) *int6
|
||||
return &next
|
||||
}
|
||||
|
||||
if schedule.Kind == "cron" {
|
||||
if schedule.Expr == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use gronx to calculate next run time
|
||||
now := time.UnixMilli(nowMS)
|
||||
nextTime, err := gronx.NextTickAfter(schedule.Expr, now, false)
|
||||
if err != nil {
|
||||
log.Printf("[cron] failed to compute next run for expr '%s': %v", schedule.Expr, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
nextMS := nextTime.UnixMilli()
|
||||
return &nextMS
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -223,9 +277,17 @@ func (cs *CronService) getNextWakeMS() *int64 {
|
||||
}
|
||||
|
||||
func (cs *CronService) Load() error {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
return cs.loadStore()
|
||||
}
|
||||
|
||||
func (cs *CronService) SetOnJob(handler JobHandler) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.onJob = handler
|
||||
}
|
||||
|
||||
func (cs *CronService) loadStore() error {
|
||||
cs.store = &CronStore{
|
||||
Version: 1,
|
||||
@@ -243,7 +305,7 @@ func (cs *CronService) loadStore() error {
|
||||
return json.Unmarshal(data, cs.store)
|
||||
}
|
||||
|
||||
func (cs *CronService) saveStore() error {
|
||||
func (cs *CronService) saveStoreUnsafe() error {
|
||||
dir := filepath.Dir(cs.storePath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
@@ -284,7 +346,7 @@ func (cs *CronService) AddJob(name string, schedule CronSchedule, message string
|
||||
}
|
||||
|
||||
cs.store.Jobs = append(cs.store.Jobs, job)
|
||||
if err := cs.saveStore(); err != nil {
|
||||
if err := cs.saveStoreUnsafe(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -310,7 +372,9 @@ func (cs *CronService) removeJobUnsafe(jobID string) bool {
|
||||
removed := len(cs.store.Jobs) < before
|
||||
|
||||
if removed {
|
||||
cs.saveStore()
|
||||
if err := cs.saveStoreUnsafe(); err != nil {
|
||||
log.Printf("[cron] failed to save store after remove: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return removed
|
||||
@@ -332,7 +396,9 @@ func (cs *CronService) EnableJob(jobID string, enabled bool) *CronJob {
|
||||
job.State.NextRunAtMS = nil
|
||||
}
|
||||
|
||||
cs.saveStore()
|
||||
if err := cs.saveStoreUnsafe(); err != nil {
|
||||
log.Printf("[cron] failed to save store after enable: %v", err)
|
||||
}
|
||||
return job
|
||||
}
|
||||
}
|
||||
@@ -377,5 +443,11 @@ func (cs *CronService) Status() map[string]interface{} {
|
||||
}
|
||||
|
||||
func generateID() string {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
// Use crypto/rand for better uniqueness under concurrent access
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// Fallback to time-based if crypto/rand fails
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user