mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user