mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-03 07:12:50 +00:00
* feat(cron): deterministic command payloads (run a shell command, no LLM) Cron jobs always run an agent turn today, so deterministic work (health probes, backups, syncs) pays model tokens on every fire. This adds a "command" payload kind that runs a shell command directly in the gateway process with zero model tokens, mirroring openclaw's command cron. - store: CronPayload.Command (*CronCommandSpec — argv/cwd/env/input/ timeouts/output cap). Persists in the existing payload JSON blob, so there is NO migration and no schema version bump. - internal/cronexec: in-process runner with wall-clock + no-output timeouts, per-stream output capping, and process-group termination so a timed-out command's forked children are also killed. - gateway_cron handler: command jobs run in-process and deliver stdout on success (honoring the NO_REPLY sentinel). A non-zero exit / timeout returns an error so the run is recorded as error and retried per cron.max_retries; failures are NOT delivered, mirroring the agent path (only successful output is announced — no channel spam). - surfaces: cron.create RPC, the agent `cron` tool, and a new `goclaw cron create` CLI all accept command payloads. - security: gated by cron.command_enabled (default false). Commands run with the gateway process's privileges, so the feature is opt-in per gateway; when disabled the RPC and tool reject command payloads and the handler refuses to run them. - i18n (en/vi/zh), docs (08-scheduling-cron.md), and tests for the runner and the handler command path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cron): gate command payloads on the update surfaces too handleUpdate (RPC + agent tool) passed CronJobPatch.Command straight to UpdateJob, which switches the payload to command kind for any non-nil Command — without the command_enabled gate or ValidateCronCommandSpec that create enforces. A normal job could therefore be mutated into a command job (or persisted with an invalid spec, e.g. empty argv) on a gateway where command cron is disabled, breaking the disabled-gateway contract. Both update surfaces now require cron.command_enabled and validate the spec before UpdateJob, matching create. The agent tool parses the command via the same path as add and drops the raw keys so a shell-string command can't break the generic patch unmarshal. Regression tests added for RPC and tool update (command disabled + invalid argv), plus a positive enabled-valid case. Addresses review feedback from @mrgoonie on #1279. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
317 lines
8.7 KiB
Go
317 lines
8.7 KiB
Go
package cmd
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
func cronCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "cron",
|
|
Short: "Manage scheduled cron jobs",
|
|
}
|
|
cmd.AddCommand(cronListCmd())
|
|
cmd.AddCommand(cronCreateCmd())
|
|
cmd.AddCommand(cronDeleteCmd())
|
|
cmd.AddCommand(cronToggleCmd())
|
|
return cmd
|
|
}
|
|
|
|
func cronCreateCmd() *cobra.Command {
|
|
var (
|
|
name string
|
|
cronExpr string
|
|
every string
|
|
at string
|
|
tz string
|
|
command string
|
|
argvJSON string
|
|
cwd string
|
|
timeout string
|
|
envPairs []string
|
|
deliver bool
|
|
channel string
|
|
to string
|
|
)
|
|
cmd := &cobra.Command{
|
|
Use: "create",
|
|
Short: "Create a deterministic command cron job (runs a shell command, no LLM)",
|
|
Long: "Create a cron job whose payload is a shell command executed in the gateway\n" +
|
|
"process WITHOUT an LLM turn (zero model tokens). Requires the gateway to have\n" +
|
|
"cron.command_enabled=true.\n\n" +
|
|
"Examples:\n" +
|
|
" goclaw cron create --name disk-probe --cron '*/15 * * * *' --command 'df -h /'\n" +
|
|
" goclaw cron create --name backup --at 2026-07-01T09:00:00Z --argv '[\"/opt/backup.sh\"]' --timeout 5m",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
if name == "" {
|
|
fmt.Fprintln(os.Stderr, "Error: --name is required")
|
|
os.Exit(1)
|
|
}
|
|
|
|
schedule := map[string]any{}
|
|
switch {
|
|
case cronExpr != "":
|
|
schedule["kind"] = "cron"
|
|
schedule["expr"] = cronExpr
|
|
if tz != "" {
|
|
schedule["tz"] = tz
|
|
}
|
|
case every != "":
|
|
d, err := time.ParseDuration(every)
|
|
if err != nil || d <= 0 {
|
|
fmt.Fprintf(os.Stderr, "Error: invalid --every duration %q\n", every)
|
|
os.Exit(1)
|
|
}
|
|
schedule["kind"] = "every"
|
|
schedule["everyMs"] = d.Milliseconds()
|
|
case at != "":
|
|
ts, err := time.Parse(time.RFC3339, at)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: invalid --at time %q (use RFC3339, e.g. 2026-07-01T09:00:00Z)\n", at)
|
|
os.Exit(1)
|
|
}
|
|
schedule["kind"] = "at"
|
|
schedule["atMs"] = ts.UnixMilli()
|
|
default:
|
|
fmt.Fprintln(os.Stderr, "Error: one of --cron, --every, or --at is required")
|
|
os.Exit(1)
|
|
}
|
|
|
|
var argv []string
|
|
switch {
|
|
case argvJSON != "":
|
|
if err := json.Unmarshal([]byte(argvJSON), &argv); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: --argv must be a JSON array of strings: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
case command != "":
|
|
argv = []string{"sh", "-c", command}
|
|
default:
|
|
fmt.Fprintln(os.Stderr, "Error: one of --command or --argv is required")
|
|
os.Exit(1)
|
|
}
|
|
|
|
commandSpec := map[string]any{"argv": argv}
|
|
if cwd != "" {
|
|
commandSpec["cwd"] = cwd
|
|
}
|
|
if timeout != "" {
|
|
d, err := time.ParseDuration(timeout)
|
|
if err != nil || d <= 0 {
|
|
fmt.Fprintf(os.Stderr, "Error: invalid --timeout duration %q\n", timeout)
|
|
os.Exit(1)
|
|
}
|
|
commandSpec["timeoutSeconds"] = int(d.Seconds())
|
|
}
|
|
if len(envPairs) > 0 {
|
|
env := map[string]string{}
|
|
for _, kv := range envPairs {
|
|
k, v, ok := strings.Cut(kv, "=")
|
|
if !ok {
|
|
fmt.Fprintf(os.Stderr, "Error: --env must be KEY=VALUE, got %q\n", kv)
|
|
os.Exit(1)
|
|
}
|
|
env[k] = v
|
|
}
|
|
commandSpec["env"] = env
|
|
}
|
|
|
|
cronCreateCommandRPC(name, schedule, commandSpec, deliver, channel, to)
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&name, "name", "", "job name (lowercase slug, required)")
|
|
cmd.Flags().StringVar(&cronExpr, "cron", "", "cron expression (5-field), e.g. '*/15 * * * *'")
|
|
cmd.Flags().StringVar(&every, "every", "", "fixed interval as a Go duration, e.g. 15m")
|
|
cmd.Flags().StringVar(&at, "at", "", "one-shot time (RFC3339), e.g. 2026-07-01T09:00:00Z")
|
|
cmd.Flags().StringVar(&tz, "tz", "", "IANA timezone for --cron (e.g. Asia/Seoul)")
|
|
cmd.Flags().StringVar(&command, "command", "", "shell command (run as sh -c)")
|
|
cmd.Flags().StringVar(&argvJSON, "argv", "", `explicit argv as a JSON array, e.g. '["node","x.js"]'`)
|
|
cmd.Flags().StringVar(&cwd, "cwd", "", "working directory")
|
|
cmd.Flags().StringVar(&timeout, "timeout", "", "per-command timeout as a Go duration, e.g. 30s")
|
|
cmd.Flags().StringArrayVar(&envPairs, "env", nil, "environment override KEY=VALUE (repeatable)")
|
|
cmd.Flags().BoolVar(&deliver, "deliver", false, "deliver command output to a channel")
|
|
cmd.Flags().StringVar(&channel, "channel", "", "delivery channel")
|
|
cmd.Flags().StringVar(&to, "to", "", "delivery chat/target ID")
|
|
return cmd
|
|
}
|
|
|
|
func cronListCmd() *cobra.Command {
|
|
var jsonOutput bool
|
|
var showDisabled bool
|
|
cmd := &cobra.Command{
|
|
Use: "list",
|
|
Short: "List all cron jobs",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
cronListRPC(showDisabled, jsonOutput)
|
|
},
|
|
}
|
|
cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON")
|
|
cmd.Flags().BoolVar(&showDisabled, "all", false, "include disabled jobs")
|
|
return cmd
|
|
}
|
|
|
|
func cronDeleteCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "delete [jobId]",
|
|
Short: "Delete a cron job",
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
cronDeleteRPC(args[0])
|
|
},
|
|
}
|
|
}
|
|
|
|
func cronToggleCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "toggle [jobId] [true|false]",
|
|
Short: "Enable or disable a cron job",
|
|
Args: cobra.ExactArgs(2),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
enabled := args[1] == "true" || args[1] == "1" || args[1] == "on"
|
|
cronToggleRPC(args[0], enabled)
|
|
},
|
|
}
|
|
}
|
|
|
|
// --- RPC implementations ---
|
|
|
|
func cronListRPC(showDisabled, jsonOutput bool) {
|
|
requireGateway()
|
|
|
|
params, _ := json.Marshal(map[string]any{"includeDisabled": showDisabled})
|
|
resp, err := gatewayRPC(protocol.MethodCronList, params)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if !resp.OK {
|
|
fmt.Fprintf(os.Stderr, "Failed: %s\n", resp.Error.Message)
|
|
os.Exit(1)
|
|
}
|
|
|
|
raw, _ := json.Marshal(resp.Payload)
|
|
var result struct {
|
|
Jobs []store.CronJob `json:"jobs"`
|
|
}
|
|
if err := json.Unmarshal(raw, &result); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
printCronJobs(result.Jobs, jsonOutput)
|
|
}
|
|
|
|
func cronCreateCommandRPC(name string, schedule, command map[string]any, deliver bool, channel, to string) {
|
|
requireGateway()
|
|
|
|
params, _ := json.Marshal(map[string]any{
|
|
"name": name,
|
|
"schedule": schedule,
|
|
"command": command,
|
|
"deliver": deliver,
|
|
"deliverChannel": channel,
|
|
"deliverTo": to,
|
|
})
|
|
resp, err := gatewayRPC(protocol.MethodCronCreate, params)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if !resp.OK {
|
|
fmt.Fprintf(os.Stderr, "Failed: %s\n", resp.Error.Message)
|
|
os.Exit(1)
|
|
}
|
|
|
|
raw, _ := json.Marshal(resp.Payload)
|
|
var result struct {
|
|
Job store.CronJob `json:"job"`
|
|
}
|
|
if err := json.Unmarshal(raw, &result); err == nil && result.Job.ID != "" {
|
|
fmt.Printf("Created command cron job %s (%s)\n", result.Job.ID, result.Job.Name)
|
|
return
|
|
}
|
|
fmt.Println("Created command cron job.")
|
|
}
|
|
|
|
func cronDeleteRPC(jobID string) {
|
|
requireGateway()
|
|
|
|
params, _ := json.Marshal(map[string]string{"jobId": jobID})
|
|
resp, err := gatewayRPC(protocol.MethodCronDelete, params)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if !resp.OK {
|
|
fmt.Fprintf(os.Stderr, "Failed: %s\n", resp.Error.Message)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("Deleted job %s\n", jobID)
|
|
}
|
|
|
|
func cronToggleRPC(jobID string, enabled bool) {
|
|
requireGateway()
|
|
|
|
params, _ := json.Marshal(map[string]any{"jobId": jobID, "enabled": enabled})
|
|
resp, err := gatewayRPC(protocol.MethodCronToggle, params)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if !resp.OK {
|
|
fmt.Fprintf(os.Stderr, "Failed: %s\n", resp.Error.Message)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("Job %s enabled=%v\n", jobID, enabled)
|
|
}
|
|
|
|
// --- Shared display ---
|
|
|
|
func printCronJobs(jobs []store.CronJob, jsonOutput bool) {
|
|
if jsonOutput {
|
|
data, _ := json.MarshalIndent(jobs, "", " ")
|
|
fmt.Println(string(data))
|
|
return
|
|
}
|
|
|
|
if len(jobs) == 0 {
|
|
fmt.Println("No cron jobs configured.")
|
|
return
|
|
}
|
|
|
|
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintf(tw, "ID\tNAME\tENABLED\tSCHEDULE\tLAST RUN\n")
|
|
for _, j := range jobs {
|
|
schedule := j.Schedule.Kind
|
|
if j.Schedule.Expr != "" {
|
|
schedule = j.Schedule.Expr
|
|
} else if j.Schedule.EveryMS != nil {
|
|
d := time.Duration(*j.Schedule.EveryMS) * time.Millisecond
|
|
schedule = "every " + d.String()
|
|
}
|
|
|
|
lastRun := "never"
|
|
if j.State.LastRunAtMS != nil {
|
|
lastRun = time.UnixMilli(*j.State.LastRunAtMS).Format(time.DateTime)
|
|
}
|
|
|
|
idShort := j.ID
|
|
if len(idShort) > 8 {
|
|
idShort = idShort[:8]
|
|
}
|
|
|
|
fmt.Fprintf(tw, "%s\t%s\t%v\t%s\t%s\n",
|
|
idShort, j.Name, j.Enabled, schedule, lastRun)
|
|
}
|
|
tw.Flush()
|
|
}
|