feat(cron): add get and update actions to cron tool

Add GetJob and improved UpdateJob to CronService with proper cloning,
schedule diffing, and next-run recomputation. Expose get/update actions
in the cron tool so agents can inspect and partially update jobs without
losing payloads or needing remove+add cycles. Includes access control
for remote channels and command safety gates.
This commit is contained in:
sutra
2026-05-31 01:47:13 +08:00
parent e81d37108b
commit 1d8ef7dcfb
5 changed files with 767 additions and 7 deletions
+331
View File
@@ -2,6 +2,7 @@ package tools
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
@@ -73,6 +74,19 @@ func newTestCronTool(t *testing.T) *CronTool {
return newTestCronToolWithConfig(t, config.DefaultConfig())
}
func parseCronJobResult(t *testing.T, result *ToolResult) cron.CronJob {
t.Helper()
text := result.ForLLM
if idx := strings.Index(text, "{"); idx >= 0 {
text = text[idx:]
}
var job cron.CronJob
if err := json.Unmarshal([]byte(text), &job); err != nil {
t.Fatalf("failed to parse cron job JSON %q: %v", result.ForLLM, err)
}
return job
}
// TestCronTool_CommandBlockedFromRemoteChannel verifies command scheduling is restricted to internal channels
func TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) {
tool := newTestCronTool(t)
@@ -231,6 +245,323 @@ func TestCronTool_NonCommandJobAllowedFromRemoteChannel(t *testing.T) {
}
}
func TestCronTool_GetReturnsFullJobPayload(t *testing.T) {
tool := newTestCronTool(t)
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
everyMS := int64(60_000)
message := strings.Repeat("daily briefing details ", 8)
job, err := tool.cronService.AddJob(
"daily",
cron.CronSchedule{Kind: "every", EveryMS: &everyMS},
message,
"telegram",
"chat-1",
)
if err != nil {
t.Fatalf("AddJob() error: %v", err)
}
result := tool.Execute(ctx, map[string]any{
"action": "get",
"job_id": job.ID,
})
if result.IsError {
t.Fatalf("get failed: %s", result.ForLLM)
}
got := parseCronJobResult(t, result)
if got.ID != job.ID || got.Payload.Message != message || got.Payload.Channel != "telegram" ||
got.Payload.To != "chat-1" {
t.Fatalf("get returned wrong payload: %+v", got)
}
if got.Schedule.Kind != "every" || got.Schedule.EveryMS == nil || *got.Schedule.EveryMS != everyMS {
t.Fatalf("get returned wrong schedule: %+v", got.Schedule)
}
if got.State.NextRunAtMS == nil {
t.Fatal("get should include next run state")
}
}
func TestCronTool_UpdateSchedulePreservesPayload(t *testing.T) {
tool := newTestCronTool(t)
ctx := WithToolContext(context.Background(), "cli", "direct")
original, err := tool.cronService.AddJob(
"AI daily",
cron.CronSchedule{Kind: "cron", Expr: "0 8 * * *"},
"fetch RSS, include source links",
"weixin",
"chat-1",
)
if err != nil {
t.Fatalf("AddJob() error: %v", err)
}
result := tool.Execute(ctx, map[string]any{
"action": "update",
"job_id": original.ID,
"cron_expr": "30 10 * * *",
})
if result.IsError {
t.Fatalf("update failed: %s", result.ForLLM)
}
updated, ok := tool.cronService.GetJob(original.ID)
if !ok {
t.Fatal("updated job not found")
}
if updated.ID != original.ID || updated.CreatedAtMS != original.CreatedAtMS {
t.Fatalf("identity changed after update: before=%+v after=%+v", original, updated)
}
if updated.Payload.Message != original.Payload.Message || updated.Payload.Channel != original.Payload.Channel ||
updated.Payload.To != original.Payload.To {
t.Fatalf("payload was not preserved: %+v", updated.Payload)
}
if updated.Schedule.Kind != "cron" || updated.Schedule.Expr != "30 10 * * *" {
t.Fatalf("schedule not updated: %+v", updated.Schedule)
}
if updated.DeleteAfterRun {
t.Fatal("cron schedule should not delete after run")
}
}
func TestCronTool_UpdateMessagePreservesScheduleAndNextRun(t *testing.T) {
tool := newTestCronTool(t)
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
everyMS := int64(120_000)
original, err := tool.cronService.AddJob(
"reminder",
cron.CronSchedule{Kind: "every", EveryMS: &everyMS},
"old message",
"telegram",
"chat-1",
)
if err != nil {
t.Fatalf("AddJob() error: %v", err)
}
if original.State.NextRunAtMS == nil {
t.Fatal("expected original next run")
}
nextRunBefore := *original.State.NextRunAtMS
result := tool.Execute(ctx, map[string]any{
"action": "update",
"job_id": original.ID,
"message": "new message",
})
if result.IsError {
t.Fatalf("update failed: %s", result.ForLLM)
}
updated, _ := tool.cronService.GetJob(original.ID)
if updated.Payload.Message != "new message" {
t.Fatalf("message not updated: %+v", updated.Payload)
}
if updated.Name != "reminder" {
t.Fatalf("name should be preserved, got %q", updated.Name)
}
if updated.Schedule.Kind != "every" || updated.Schedule.EveryMS == nil || *updated.Schedule.EveryMS != everyMS {
t.Fatalf("schedule should be preserved: %+v", updated.Schedule)
}
if updated.State.NextRunAtMS == nil || *updated.State.NextRunAtMS != nextRunBefore {
t.Fatalf("next run should be preserved: before=%d after=%v", nextRunBefore, updated.State.NextRunAtMS)
}
}
func TestCronTool_UpdateValidationErrors(t *testing.T) {
tool := newTestCronTool(t)
ctx := WithToolContext(context.Background(), "cli", "direct")
job, err := tool.cronService.AddJob(
"job",
cron.CronSchedule{Kind: "cron", Expr: "0 8 * * *"},
"message",
"cli",
"direct",
)
if err != nil {
t.Fatalf("AddJob() error: %v", err)
}
tests := []struct {
name string
args map[string]any
want string
}{
{
name: "invalid job id",
args: map[string]any{"action": "update", "job_id": "missing", "message": "new"},
want: "not found",
},
{
name: "missing patch",
args: map[string]any{"action": "update", "job_id": job.ID},
want: "at least one update field",
},
{
name: "multiple schedule fields",
args: map[string]any{
"action": "update",
"job_id": job.ID,
"every_seconds": float64(60),
"cron_expr": "0 9 * * *",
},
want: "only one of",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tool.Execute(ctx, tt.args)
if !result.IsError {
t.Fatalf("expected error, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, tt.want) {
t.Fatalf("error = %q, want substring %q", result.ForLLM, tt.want)
}
})
}
}
func TestCronTool_RemoteCannotAccessOtherChatJob(t *testing.T) {
tool := newTestCronTool(t)
job, err := tool.cronService.AddJob(
"private",
cron.CronSchedule{Kind: "cron", Expr: "0 8 * * *"},
"secret",
"telegram",
"chat-1",
)
if err != nil {
t.Fatalf("AddJob() error: %v", err)
}
ctx := WithToolContext(context.Background(), "telegram", "chat-2")
getResult := tool.Execute(ctx, map[string]any{"action": "get", "job_id": job.ID})
if !getResult.IsError || !strings.Contains(getResult.ForLLM, "not accessible") {
t.Fatalf("expected inaccessible get, got: %+v", getResult)
}
updateResult := tool.Execute(ctx, map[string]any{"action": "update", "job_id": job.ID, "message": "changed"})
if !updateResult.IsError || !strings.Contains(updateResult.ForLLM, "not accessible") {
t.Fatalf("expected inaccessible update, got: %+v", updateResult)
}
}
func TestCronTool_RemoteCannotAccessCommandJob(t *testing.T) {
tool := newTestCronTool(t)
job, err := tool.cronService.AddJob(
"command",
cron.CronSchedule{Kind: "cron", Expr: "0 8 * * *"},
"run command",
"telegram",
"chat-1",
)
if err != nil {
t.Fatalf("AddJob() error: %v", err)
}
job.Payload.Command = "df -h"
if err := tool.cronService.UpdateJob(job); err != nil {
t.Fatalf("UpdateJob() error: %v", err)
}
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
getResult := tool.Execute(ctx, map[string]any{"action": "get", "job_id": job.ID})
if !getResult.IsError || !strings.Contains(getResult.ForLLM, "not accessible") {
t.Fatalf("expected inaccessible get, got: %+v", getResult)
}
updateResult := tool.Execute(ctx, map[string]any{"action": "update", "job_id": job.ID, "message": "changed"})
if !updateResult.IsError || !strings.Contains(updateResult.ForLLM, "not accessible") {
t.Fatalf("expected inaccessible update, got: %+v", updateResult)
}
}
func TestCronTool_CommandUpdateSafetyGates(t *testing.T) {
t.Run("exec disabled", func(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Exec.Enabled = false
tool := newTestCronToolWithConfig(t, cfg)
ctx := WithToolContext(context.Background(), "cli", "direct")
job, err := tool.cronService.AddJob(
"job",
cron.CronSchedule{Kind: "cron", Expr: "0 8 * * *"},
"message",
"cli",
"direct",
)
if err != nil {
t.Fatalf("AddJob() error: %v", err)
}
result := tool.Execute(ctx, map[string]any{
"action": "update",
"job_id": job.ID,
"command": "df -h",
"command_confirm": true,
})
if !result.IsError || !strings.Contains(result.ForLLM, "command execution is disabled") {
t.Fatalf("expected exec disabled error, got: %+v", result)
}
})
t.Run("confirm required", func(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Cron.AllowCommand = false
tool := newTestCronToolWithConfig(t, cfg)
ctx := WithToolContext(context.Background(), "cli", "direct")
job, err := tool.cronService.AddJob(
"job",
cron.CronSchedule{Kind: "cron", Expr: "0 8 * * *"},
"message",
"cli",
"direct",
)
if err != nil {
t.Fatalf("AddJob() error: %v", err)
}
result := tool.Execute(ctx, map[string]any{
"action": "update",
"job_id": job.ID,
"command": "df -h",
})
if !result.IsError || !strings.Contains(result.ForLLM, "command_confirm=true") {
t.Fatalf("expected confirm error, got: %+v", result)
}
result = tool.Execute(ctx, map[string]any{
"action": "update",
"job_id": job.ID,
"command": "df -h",
"command_confirm": true,
})
if result.IsError {
t.Fatalf("expected confirmed command update to succeed, got: %s", result.ForLLM)
}
updated, _ := tool.cronService.GetJob(job.ID)
if updated.Payload.Command != "df -h" {
t.Fatalf("command not updated: %+v", updated.Payload)
}
result = tool.Execute(ctx, map[string]any{
"action": "update",
"job_id": job.ID,
"command": "",
"command_confirm": true,
})
if result.IsError {
t.Fatalf("expected empty command update to clear command, got: %s", result.ForLLM)
}
updated, _ = tool.cronService.GetJob(job.ID)
if updated.Payload.Command != "" {
t.Fatalf("command not cleared: %+v", updated.Payload)
}
})
}
func TestCronTool_ExecuteJobPublishesErrorWhenExecDisabled(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Exec.Enabled = false