Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/reference/cron.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,41 @@ picoclaw cron add --name "Daily summary" --message "Summarize today's logs" --cr
picoclaw cron add --name "Ping" --message "heartbeat" --every 300 --deliver
```

## Agent Tool Actions

The agent-facing `cron` tool supports these actions:

- `add`: create a new job.
- `list`: show accessible job names, ids, and schedules.
- `get`: fetch one accessible persisted job by `job_id`, including its saved payload.
- `update`: partially update one accessible job by `job_id`; omitted fields are preserved.
- `remove`, `enable`, `disable`: existing management actions.

When rescheduling an existing task, use `list -> get -> update`. Do not use
`remove -> add` just to change the schedule, because recreating a job can drop
the original prompt, delivery target, or command payload.

Remote channel access is scoped to the current `channel/chat_id`: remote callers
can only list, get, or update jobs whose saved `payload.channel` and `payload.to`
match the current conversation. Command jobs include a shell command payload, so
they can only be listed, inspected, or updated from internal channels.

Example tool calls:

```json
{"action":"get","job_id":"79095b2f5685a0f2"}
```

```json
{"action":"update","job_id":"79095b2f5685a0f2","cron_expr":"30 10 * * *"}
```

`update` accepts `name`, `message`, `command`, and exactly one schedule field
(`at_seconds`, `every_seconds`, or `cron_expr`).
Omit `command` to preserve it, set `command` to a non-empty string to replace
it, or set `command` to `""` to clear it. Command updates require the same
internal channel and confirmation gates as command creation.

## Execution Modes

Jobs are stored with a message payload and can execute in three stable user-facing modes:
Expand Down
63 changes: 61 additions & 2 deletions pkg/cron/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -447,14 +447,37 @@ func (cs *CronService) AddJob(
return &job, nil
}

func (cs *CronService) GetJob(jobID string) (*CronJob, bool) {
cs.mu.RLock()
defer cs.mu.RUnlock()

for i := range cs.store.Jobs {
if cs.store.Jobs[i].ID == jobID {
jobCopy := cloneCronJob(cs.store.Jobs[i])
return &jobCopy, true
}
}
return nil, false
}

func (cs *CronService) UpdateJob(job *CronJob) error {
cs.mu.Lock()
defer cs.mu.Unlock()

for i := range cs.store.Jobs {
if cs.store.Jobs[i].ID == job.ID {
cs.store.Jobs[i] = *job
cs.store.Jobs[i].UpdatedAtMS = time.Now().UnixMilli()
previous := cs.store.Jobs[i]
updated := cloneCronJob(*job)
now := time.Now().UnixMilli()
updated.UpdatedAtMS = now
if updated.Enabled {
if previous.Enabled != updated.Enabled || !sameSchedule(previous.Schedule, updated.Schedule) {
updated.State.NextRunAtMS = cs.computeNextRun(&updated.Schedule, now)
}
} else {
updated.State.NextRunAtMS = nil
}
cs.store.Jobs[i] = updated

cs.notify()

Expand All @@ -464,6 +487,42 @@ func (cs *CronService) UpdateJob(job *CronJob) error {
return fmt.Errorf("job not found")
}

func cloneCronJob(job CronJob) CronJob {
clone := job
if job.Schedule.AtMS != nil {
atMS := *job.Schedule.AtMS
clone.Schedule.AtMS = &atMS
}
if job.Schedule.EveryMS != nil {
everyMS := *job.Schedule.EveryMS
clone.Schedule.EveryMS = &everyMS
}
if job.State.NextRunAtMS != nil {
nextRunAtMS := *job.State.NextRunAtMS
clone.State.NextRunAtMS = &nextRunAtMS
}
if job.State.LastRunAtMS != nil {
lastRunAtMS := *job.State.LastRunAtMS
clone.State.LastRunAtMS = &lastRunAtMS
}
return clone
}

func sameSchedule(a, b CronSchedule) bool {
return a.Kind == b.Kind &&
sameInt64(a.AtMS, b.AtMS) &&
sameInt64(a.EveryMS, b.EveryMS) &&
a.Expr == b.Expr &&
a.TZ == b.TZ
}

func sameInt64(a, b *int64) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}

func (cs *CronService) RemoveJob(jobID string) bool {
cs.mu.Lock()
defer cs.mu.Unlock()
Expand Down
130 changes: 130 additions & 0 deletions pkg/cron/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,136 @@ func TestCronService_CRUD(t *testing.T) {
}
}

func TestCronService_GetJobReturnsCopy(t *testing.T) {
cs, path := setupService(nil)
defer os.Remove(path)

everyMS := int64(60_000)
job, err := cs.AddJob("Task1", CronSchedule{Kind: "every", EveryMS: &everyMS}, "msg", "ch", "to")
if err != nil {
t.Fatalf("AddJob failed: %v", err)
}
if job.State.NextRunAtMS == nil {
t.Fatal("expected initial next run")
}
nextRun := *job.State.NextRunAtMS

got, ok := cs.GetJob(job.ID)
if !ok {
t.Fatal("GetJob should find job")
}
got.Name = "mutated"
got.Payload.Message = "changed"
if got.Schedule.EveryMS != nil {
*got.Schedule.EveryMS = 120_000
}
if got.State.NextRunAtMS != nil {
*got.State.NextRunAtMS = time.Now().Add(3 * time.Hour).UnixMilli()
}

again, ok := cs.GetJob(job.ID)
if !ok {
t.Fatal("GetJob should still find job")
}
if again.Name != "Task1" || again.Payload.Message != "msg" {
t.Fatalf("GetJob should return a copy, got %+v", again)
}
if again.Schedule.EveryMS == nil || *again.Schedule.EveryMS != everyMS {
t.Fatalf("GetJob should not alias schedule pointers, got %+v", again.Schedule)
}
if again.State.NextRunAtMS == nil || *again.State.NextRunAtMS != nextRun {
t.Fatalf("GetJob should not alias state pointers, got %+v", again.State)
}
}

func TestCronService_UpdateJobRecomputesNextRunOnScheduleOrEnabledChange(t *testing.T) {
cs, path := setupService(nil)
defer os.Remove(path)

at := time.Now().Add(time.Hour).UnixMilli()
job, err := cs.AddJob("Task1", CronSchedule{Kind: "at", AtMS: &at}, "msg", "ch", "to")
if err != nil {
t.Fatalf("AddJob failed: %v", err)
}
if job.State.NextRunAtMS == nil {
t.Fatal("expected initial next run")
}
initialNextRun := *job.State.NextRunAtMS

everyMS := int64(30_000)
job.Schedule = CronSchedule{Kind: "every", EveryMS: &everyMS}
if err := cs.UpdateJob(job); err != nil {
t.Fatalf("UpdateJob schedule failed: %v", err)
}
updated, ok := cs.GetJob(job.ID)
if !ok {
t.Fatal("updated job not found")
}
if updated.State.NextRunAtMS == nil {
t.Fatal("expected recomputed next run after schedule change")
}
if *updated.State.NextRunAtMS == initialNextRun {
t.Fatalf("next run should be recomputed, still %d", initialNextRun)
}

if disabled := cs.EnableJob(job.ID, false); disabled == nil {
t.Fatal("EnableJob(false) returned nil")
}
disabled, ok := cs.GetJob(job.ID)
if !ok {
t.Fatal("disabled job not found")
}
disabled.Enabled = true
if err := cs.UpdateJob(disabled); err != nil {
t.Fatalf("UpdateJob enabled failed: %v", err)
}
reenabled, ok := cs.GetJob(job.ID)
if !ok {
t.Fatal("reenabled job not found")
}
if !reenabled.Enabled || reenabled.State.NextRunAtMS == nil {
t.Fatalf("expected enabled job with next run, got %+v", reenabled)
}
}

func TestCronService_UpdateJobPreservesRunStateOnPayloadOnlyChange(t *testing.T) {
cs, path := setupService(nil)
defer os.Remove(path)

everyMS := int64(60_000)
job, err := cs.AddJob("Task1", CronSchedule{Kind: "every", EveryMS: &everyMS}, "msg", "ch", "to")
if err != nil {
t.Fatalf("AddJob failed: %v", err)
}
lastRun := time.Now().Add(-time.Minute).UnixMilli()
job.State.LastRunAtMS = &lastRun
job.State.LastStatus = "ok"
job.State.LastError = "previous"
if job.State.NextRunAtMS == nil {
t.Fatal("expected next run before update")
}
nextRun := *job.State.NextRunAtMS

job.Payload.Message = "updated msg"
if err := cs.UpdateJob(job); err != nil {
t.Fatalf("UpdateJob failed: %v", err)
}

updated, ok := cs.GetJob(job.ID)
if !ok {
t.Fatal("updated job not found")
}
if updated.State.LastRunAtMS == nil || *updated.State.LastRunAtMS != lastRun {
t.Fatalf("last run changed: %+v", updated.State)
}
if updated.State.LastStatus != "ok" || updated.State.LastError != "previous" {
t.Fatalf("last status changed: %+v", updated.State)
}
if updated.State.NextRunAtMS == nil || *updated.State.NextRunAtMS != nextRun {
t.Fatalf("next run should be preserved: before=%d after=%+v", nextRun, updated.State.NextRunAtMS)
}
}

// 2. Test Cron Expression Calculation Logic
func TestCronService_ComputeNextRun(t *testing.T) {
cs, path := setupService(nil)
Expand Down
Loading