Files
Zezae OhandClaude Opus 4.8 4a79c8a208 feat(cron): deterministic command payloads (run a shell command, no LLM) (#1279)
* 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>
2026-06-25 10:23:55 +07:00

233 lines
5.5 KiB
Go

package pg
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func (s *PGCronStore) UpdateJob(ctx context.Context, jobID string, patch store.CronJobPatch) (*store.CronJob, error) {
id, err := uuid.Parse(jobID)
if err != nil {
return nil, fmt.Errorf("invalid job ID: %s", jobID)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
current, err := s.lockCronJobForMutation(ctx, tx, id, true)
if err != nil {
return nil, err
}
now := time.Now()
updates := make(map[string]any)
effectiveEnabled := current.Enabled
if patch.Enabled != nil {
effectiveEnabled = *patch.Enabled
updates["enabled"] = effectiveEnabled
}
if patch.Name != "" {
updates["name"] = patch.Name
}
if patch.DeleteAfterRun != nil {
updates["delete_after_run"] = *patch.DeleteAfterRun
}
if patch.AgentID != nil {
if *patch.AgentID == "" {
updates["agent_id"] = nil
} else if aid, parseErr := uuid.Parse(*patch.AgentID); parseErr == nil {
updates["agent_id"] = aid
}
}
if patch.Schedule != nil {
merged := store.MergeCronSchedule(current.Schedule, patch.Schedule)
if err := store.ValidateCronSchedule(&merged); err != nil {
return nil, err
}
store.ApplyCronScheduleUpdates(updates, merged)
nextRun, err := store.NextRunForSchedule(&merged, effectiveEnabled, now, s.defaultTZ)
if err != nil {
return nil, err
}
updates["next_run_at"] = nextRun
} else if patch.Enabled != nil {
nextRun, err := store.NextRunForToggle(&current.Schedule, effectiveEnabled, current.Enabled, current.NextRunAt, now, s.defaultTZ)
if err != nil {
return nil, err
}
updates["next_run_at"] = nextRun
}
if patch.Stateless != nil {
updates["stateless"] = *patch.Stateless
}
if patch.Deliver != nil {
updates["deliver"] = *patch.Deliver
}
if patch.DeliverChannel != nil {
updates["deliver_channel"] = *patch.DeliverChannel
}
if patch.DeliverTo != nil {
updates["deliver_to"] = *patch.DeliverTo
}
if patch.WakeHeartbeat != nil {
updates["wake_heartbeat"] = *patch.WakeHeartbeat
}
if patch.ProviderID != nil {
updates["provider_id"] = *patch.ProviderID
}
if patch.Model != nil {
updates["model"] = *patch.Model
}
if patch.Message != "" || patch.Command != nil {
payload := current.Payload
if patch.Message != "" {
payload.Message = patch.Message
}
if patch.Command != nil {
payload.Kind = store.CronPayloadKindCommand
payload.Command = patch.Command
}
mergedPayload, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal payload for job %s: %w", jobID, err)
}
updates["payload"] = mergedPayload
}
updates["updated_at"] = now
if err := execCronJobUpdateTx(ctx, tx, id, updates); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
s.InvalidateCache()
job, _ := s.scanJob(ctx, id)
return job, nil
}
func (s *PGCronStore) lockCronJobForMutation(ctx context.Context, tx *sql.Tx, id uuid.UUID, loadPayload bool) (*store.CronJobMutableState, error) {
q := `SELECT enabled, schedule_kind, cron_expression, run_at, timezone, interval_ms, next_run_at, payload
FROM cron_jobs WHERE id = $1`
args := []any{id}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return nil, fmt.Errorf("tenant_id required")
}
q += fmt.Sprintf(" AND tenant_id = $%d", len(args)+1)
args = append(args, tid)
}
q += " FOR UPDATE"
var (
state store.CronJobMutableState
scheduleKind string
cronExpr *string
runAt *time.Time
tz *string
intervalMS *int64
nextRunAt *time.Time
payloadJSON []byte
)
if err := tx.QueryRowContext(ctx, q, args...).Scan(
&state.Enabled,
&scheduleKind,
&cronExpr,
&runAt,
&tz,
&intervalMS,
&nextRunAt,
&payloadJSON,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, store.ErrCronJobNotFound
}
return nil, err
}
state.Schedule = store.CronSchedule{Kind: scheduleKind}
if cronExpr != nil {
state.Schedule.Expr = *cronExpr
}
if runAt != nil {
ms := runAt.UnixMilli()
state.Schedule.AtMS = &ms
}
if tz != nil {
state.Schedule.TZ = *tz
}
if intervalMS != nil {
state.Schedule.EveryMS = intervalMS
}
state.NextRunAt = nextRunAt
if loadPayload && len(payloadJSON) > 0 {
if err := json.Unmarshal(payloadJSON, &state.Payload); err != nil {
return nil, fmt.Errorf("failed to parse existing payload for job %s: %w", id, err)
}
}
return &state, nil
}
func execCronJobUpdateTx(ctx context.Context, tx *sql.Tx, id uuid.UUID, updates map[string]any) error {
if len(updates) == 0 {
return nil
}
var (
setClauses []string
args []any
)
for idx, col := range store.SortedUpdateColumns(updates) {
if !validColumnName.MatchString(col) {
return fmt.Errorf("invalid column name: %q", col)
}
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, idx+1))
args = append(args, updates[col])
}
args = append(args, id)
q := fmt.Sprintf("UPDATE cron_jobs SET %s WHERE id = $%d", strings.Join(setClauses, ", "), len(args))
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return fmt.Errorf("tenant_id required")
}
args = append(args, tid)
q += fmt.Sprintf(" AND tenant_id = $%d", len(args))
}
res, err := tx.ExecContext(ctx, q, args...)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return store.ErrCronJobNotFound
}
return nil
}