mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
feat(cron): script payloads behind the trigger gate (#111112)
Run script payloads through the shared headless code-mode executor with payload-grade budgets and success-only trigger.state persistence. Reuse cron delivery, wake, pacing, and dangerous trigger-gate contracts for notify, wake, and nextCheck results.
This commit is contained in:
@@ -43,7 +43,7 @@ Cron is the Gateway's built-in scheduler. It persists jobs, wakes the agent at t
|
||||
- Job definitions, runtime state, and run history persist in OpenClaw's shared SQLite state database, so restarts do not lose schedules.
|
||||
- Every cron execution creates a [background task](/automation/tasks) record.
|
||||
- One-shot jobs (`--at`) auto-delete after success by default; pass `--keep-after-run` to keep them.
|
||||
- Per-run wall-clock budget: `--timeout-seconds` when set. Otherwise, isolated/detached agent-turn jobs are bounded by cron's own 60-minute watchdog before the underlying agent-turn timeout (`agents.defaults.timeoutSeconds`, default 48 hours) would ever apply; command jobs default to 10 minutes.
|
||||
- Per-run wall-clock budget: `--timeout-seconds` when set. Otherwise, isolated/detached agent-turn jobs are bounded by cron's own 60-minute watchdog before the underlying agent-turn timeout (`agents.defaults.timeoutSeconds`, default 48 hours) would ever apply; command jobs default to 10 minutes, and script payloads default to 5 minutes.
|
||||
- On Gateway startup, overdue isolated agent-turn jobs are rescheduled instead of replayed immediately, keeping model/tool bootstrap work out of the channel-connect window.
|
||||
- If you drive `openclaw agent` from system cron or another external scheduler, wrap it with a hard-kill escalation even though the CLI already handles `SIGTERM`/`SIGINT`. Gateway-backed runs ask the Gateway to abort accepted runs; local and embedded fallback runs get the same abort signal. For GNU `timeout`, prefer `timeout -k 60 600 openclaw agent ...` over plain `timeout 600 ...` — the `-k` value is the backstop if the process cannot drain in time. For systemd units, use a `SIGTERM` stop signal with a grace window (`TimeoutStopSec`) before the final kill. Reusing a `--run-id` while the original Gateway run is still active reports the duplicate as in-flight instead of starting a second run.
|
||||
|
||||
@@ -119,7 +119,7 @@ The script must return `{ fire, message?, state? }`. The previous JSON state is
|
||||
Author watchers around **actionable state**, not only success: a watcher that goes quiet when its check fails or times out looks healthy while broken. Compare the observation with `trigger.state` and return fresh state to deduplicate; do not rely on model or process memory. When firing, make `message` self-contained because it becomes the fired run's complete event context.
|
||||
|
||||
<Warning>
|
||||
Enabling `cron.triggers.enabled` lets agent-authored scripts run headlessly with the owning agent's **full tool policy, including `exec`**. Treat this as unattended code execution with that agent's permissions; leave it disabled unless every agent allowed to create cron jobs is trusted accordingly.
|
||||
Enabling `cron.triggers.enabled` permits both condition-trigger scripts and `script` payloads to run headlessly with the owning agent's **full tool policy, including `exec`**. Treat this as unattended code execution with that agent's permissions; leave it disabled unless every agent allowed to create cron jobs is trusted accordingly.
|
||||
</Warning>
|
||||
|
||||
Create a watcher from a local script file (`-` reads the script from stdin):
|
||||
@@ -137,11 +137,12 @@ openclaw cron add \
|
||||
|
||||
Every job carries exactly one payload kind, chosen by flag:
|
||||
|
||||
| Payload | Flag | Runs |
|
||||
| ------------- | ---------------------------------------------- | ------------------------------------------------------- |
|
||||
| System event | `--system-event <text>` | Enqueued into the main session, no model call by itself |
|
||||
| Agent message | `--message <text>` | A model-backed agent turn |
|
||||
| Command | `--command <shell>` or `--command-argv <json>` | A shell/process on the Gateway host, no model call |
|
||||
| Payload | Flag | Runs |
|
||||
| ------------- | ---------------------------------------------- | ---------------------------------------------------------- |
|
||||
| System event | `--system-event <text>` | Enqueued into the main session, no model call by itself |
|
||||
| Agent message | `--message <text>` | A model-backed agent turn |
|
||||
| Command | `--command <shell>` or `--command-argv <json>` | A shell/process on the Gateway host, no model call |
|
||||
| Script | `--script <file\|->` | A headless code-mode script using the owning agent's tools |
|
||||
|
||||
### Agent-turn options
|
||||
|
||||
@@ -210,6 +211,31 @@ openclaw cron create "*/15 * * * *" \
|
||||
|
||||
Delivered text is derived from process output: non-empty stdout wins; if stdout is empty and stderr is non-empty, stderr is delivered; if both are present, cron sends a small `stdout:` / `stderr:` block. Exit code `0` records the run `ok`; non-zero exit, signal, timeout, or no-output timeout records `error` and can trigger failure alerts. A command that prints only `NO_REPLY` uses the normal cron silent-token suppression and posts nothing back to chat.
|
||||
|
||||
### Script payloads
|
||||
|
||||
Script payloads run headlessly in the same code-mode executor as trigger scripts, without starting a conversational agent turn. Enable `cron.triggers.enabled` before creating or running them; this dangerous-automation gate covers both trigger scripts and script payloads. Script jobs support only `main` and `isolated` session targets.
|
||||
|
||||
```bash
|
||||
openclaw cron create "0 * * * *" \
|
||||
--name "Hourly queue check" \
|
||||
--script ./automation/check-queue.js \
|
||||
--script-timeout-seconds 300 \
|
||||
--script-tool-budget 50 \
|
||||
--session isolated \
|
||||
--announce
|
||||
```
|
||||
|
||||
Use `--script <file|->` to read JavaScript from a file or stdin. The timeout defaults to 300 seconds and is capped at 900; the tool budget defaults to 50 calls and is capped at 200. These payload budgets are separate from the smaller trigger-gate evaluation budgets.
|
||||
|
||||
The script may return an object with these optional fields:
|
||||
|
||||
- `notify`: Text delivered through the job's `announce`, `webhook`, or `none` delivery mode. If omitted, nothing is delivered. For a `main` job, the text becomes a system event.
|
||||
- `wake`: `"now"` requests an immediate heartbeat after enqueueing `notify` (or a compact completion event); `"next-heartbeat"` enqueues the event for the next heartbeat.
|
||||
- `state`: JSON state, capped at 16 KB and persisted only after a successful run. The next run receives a frozen copy as `trigger.state`, matching trigger scripts. Because that namespace has one persisted owner, a script payload cannot be combined with a condition trigger on the same job.
|
||||
- `nextCheck`: A duration such as `"15m"`. It is valid only for jobs with pacing enabled and uses the same pacing clamp as agent-turn proposals.
|
||||
|
||||
Throws, timeouts, exhausted tool budgets, invalid results, and `nextCheck` without pacing are normal cron run errors: they enter run history, backoff, and failure-alert handling without persisting returned state.
|
||||
|
||||
## Execution styles
|
||||
|
||||
| Style | `--session` value | Runs in | Best for |
|
||||
|
||||
@@ -71,6 +71,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H2: Payloads
|
||||
- H3: Agent-turn options
|
||||
- H3: Command payloads
|
||||
- H3: Script payloads
|
||||
- H2: Execution styles
|
||||
- H2: Delivery and output
|
||||
- H3: Failure notifications
|
||||
|
||||
@@ -50,6 +50,17 @@ function cronCommandPayloadSchema(params: { argv: TSchema; toolsAllow: TSchema }
|
||||
});
|
||||
}
|
||||
|
||||
function cronScriptPayloadSchema(params: { script: TSchema; toolsAllow: TSchema }) {
|
||||
return closedObject({
|
||||
kind: Type.Literal("script"),
|
||||
script: params.script,
|
||||
timeoutSeconds: Type.Optional(Type.Number({ minimum: 1 })),
|
||||
toolBudget: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
toolsAllow: Type.Optional(params.toolsAllow),
|
||||
toolsAllowIsDefault: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
}
|
||||
|
||||
/** Session target accepted by cron jobs. */
|
||||
const CronSessionTargetSchema = Type.Union([
|
||||
Type.Literal("main"),
|
||||
@@ -255,6 +266,10 @@ export const CronPayloadSchema = Type.Union([
|
||||
argv: Type.Array(NonEmptyString, { minItems: 1 }),
|
||||
toolsAllow: Type.Array(Type.String()),
|
||||
}),
|
||||
cronScriptPayloadSchema({
|
||||
script: Type.String({ minLength: 1, maxLength: 65_536 }),
|
||||
toolsAllow: Type.Array(Type.String()),
|
||||
}),
|
||||
]);
|
||||
|
||||
/** Partial cron payload for job updates. */
|
||||
@@ -276,6 +291,10 @@ export const CronPayloadPatchSchema = Type.Union([
|
||||
argv: Type.Optional(Type.Array(NonEmptyString, { minItems: 1 })),
|
||||
toolsAllow: Type.Union([Type.Array(Type.String()), Type.Null()]),
|
||||
}),
|
||||
cronScriptPayloadSchema({
|
||||
script: Type.Optional(Type.String({ minLength: 1, maxLength: 65_536 })),
|
||||
toolsAllow: Type.Union([Type.Array(Type.String()), Type.Null()]),
|
||||
}),
|
||||
]);
|
||||
|
||||
/** Failure alert policy for repeated cron run failures. */
|
||||
|
||||
@@ -8,7 +8,7 @@ import { isRecord } from "../../utils.js";
|
||||
import { isStringOption } from "../../utils/string-readers.js";
|
||||
|
||||
const CRON_SCHEDULE_KINDS = ["at", "every", "cron", "on-exit"] as const;
|
||||
const CRON_PAYLOAD_KINDS = ["systemEvent", "agentTurn"] as const;
|
||||
const CRON_PAYLOAD_KINDS = ["systemEvent", "agentTurn", "script"] as const;
|
||||
const CRON_FLAT_PAYLOAD_KEYS = [
|
||||
"message",
|
||||
"text",
|
||||
@@ -66,7 +66,7 @@ function isCronScheduleKind(value: unknown): value is (typeof CRON_SCHEDULE_KIND
|
||||
}
|
||||
|
||||
function isCronPayloadKind(value: unknown): value is (typeof CRON_PAYLOAD_KINDS)[number] {
|
||||
return value === "systemEvent" || value === "agentTurn";
|
||||
return value === "systemEvent" || value === "agentTurn" || value === "script";
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
@@ -247,6 +247,8 @@ function canonicalizeCronToolPayload(value: Record<string, unknown>): void {
|
||||
(payload.fallbacks !== undefined && isStringArrayOrNull(payload.fallbacks));
|
||||
if (hasAgentTurnSignal) {
|
||||
payload.kind = "agentTurn";
|
||||
} else if (isNonEmptyString(payload.script)) {
|
||||
payload.kind = "script";
|
||||
} else if (isNonEmptyString(payload.text)) {
|
||||
payload.kind = "systemEvent";
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ function capCronJobToolsAllow(params: {
|
||||
const writesToolsAllow = Object.hasOwn(params.payload, "toolsAllow");
|
||||
if (
|
||||
params.payload.kind !== "agentTurn" &&
|
||||
params.payload.kind !== "script" &&
|
||||
!hasCronTriggerScript(params.trigger) &&
|
||||
!writesToolsAllow
|
||||
) {
|
||||
@@ -170,7 +171,12 @@ export function planCronJobUpdatePatch(params: {
|
||||
|
||||
const trigger = Object.hasOwn(patch, "trigger") ? patch.trigger : params.currentJob.trigger;
|
||||
const writesToolsAllow = payload !== undefined && Object.hasOwn(payload, "toolsAllow");
|
||||
if (payloadKind !== "agentTurn" && !hasCronTriggerScript(trigger) && !writesToolsAllow) {
|
||||
if (
|
||||
payloadKind !== "agentTurn" &&
|
||||
payloadKind !== "script" &&
|
||||
!hasCronTriggerScript(trigger) &&
|
||||
!writesToolsAllow
|
||||
) {
|
||||
return { kind: "ready", patch };
|
||||
}
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ describe("createCronToolSchema", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("job.payload exposes kind, text, message, model, thinking and extras", () => {
|
||||
it("job.payload exposes conversational and script payload fields", () => {
|
||||
expect(keysAt(schemaRecord, "job.payload")).toEqual(
|
||||
[
|
||||
"allowUnsafeExternalContent",
|
||||
@@ -191,8 +191,10 @@ describe("createCronToolSchema", () => {
|
||||
"lightContext",
|
||||
"message",
|
||||
"model",
|
||||
"script",
|
||||
"text",
|
||||
"thinking",
|
||||
"toolBudget",
|
||||
"toolsAllow",
|
||||
"timeoutSeconds",
|
||||
].toSorted(),
|
||||
@@ -212,14 +214,43 @@ describe("createCronToolSchema", () => {
|
||||
"lightContext",
|
||||
"message",
|
||||
"model",
|
||||
"script",
|
||||
"text",
|
||||
"thinking",
|
||||
"toolBudget",
|
||||
"toolsAllow",
|
||||
"timeoutSeconds",
|
||||
].toSorted(),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts script payloads in create and patch schemas", () => {
|
||||
expect(
|
||||
Value.Check(schema, {
|
||||
action: "add",
|
||||
job: {
|
||||
name: "script job",
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "script",
|
||||
script: "return { notify: 'done' }",
|
||||
timeoutSeconds: 300,
|
||||
toolBudget: 50,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
Value.Check(schema, {
|
||||
action: "update",
|
||||
id: "job-1",
|
||||
patch: { payload: { kind: "script", toolBudget: 75 } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("job.failureAlert exposes after, channel, to, cooldownMs, includeSkipped, mode, accountId", () => {
|
||||
expect(keysAt(schemaRecord, "job.failureAlert")).toEqual(
|
||||
["accountId", "after", "channel", "cooldownMs", "includeSkipped", "mode", "to"].toSorted(),
|
||||
|
||||
@@ -1123,6 +1123,34 @@ describe("cron tool", () => {
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows script payloads without treating them as shell commands", async () => {
|
||||
const tool = createTestCronTool();
|
||||
|
||||
await tool.execute("call-script-add", {
|
||||
action: "add",
|
||||
job: {
|
||||
name: "script",
|
||||
schedule: { at: new Date(123).toISOString() },
|
||||
sessionTarget: "isolated",
|
||||
payload: {
|
||||
kind: "script",
|
||||
script: "return { notify: 'done' }",
|
||||
timeoutSeconds: 300,
|
||||
toolBudget: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(expectSingleGatewayCallMethod("cron.add")).toMatchObject({
|
||||
payload: {
|
||||
kind: "script",
|
||||
script: "return { notify: 'done' }",
|
||||
timeoutSeconds: 300,
|
||||
toolBudget: 50,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects on-exit schedules from the agent cron tool on add", async () => {
|
||||
const tool = createTestCronTool();
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ const CRON_ACTIONS = [
|
||||
|
||||
const CRON_SCHEDULE_KINDS = ["at", "every", "cron"] as const;
|
||||
const CRON_WAKE_MODES = ["now", "next-heartbeat"] as const;
|
||||
const CRON_PAYLOAD_KINDS = ["systemEvent", "agentTurn"] as const;
|
||||
const CRON_PAYLOAD_KINDS = ["systemEvent", "agentTurn", "script"] as const;
|
||||
const CRON_DELIVERY_MODES = ["none", "announce", "webhook"] as const;
|
||||
const CRON_RUN_MODES = ["due", "force"] as const;
|
||||
|
||||
@@ -128,9 +128,11 @@ function cronPayloadObjectSchema(params: {
|
||||
kind: optionalStringEnum(CRON_PAYLOAD_KINDS, { description: "Payload kind" }),
|
||||
text: Type.Optional(Type.String({ description: "systemEvent text" })),
|
||||
message: Type.Optional(Type.String({ description: "agentTurn prompt" })),
|
||||
script: Type.Optional(Type.String({ description: "Headless code-mode script" })),
|
||||
model: params.model,
|
||||
thinking: Type.Optional(Type.String({ description: "Thinking override" })),
|
||||
timeoutSeconds: optionalFiniteNumberSchema({ minimum: 0 }),
|
||||
toolBudget: optionalPositiveIntegerSchema({ description: "Maximum script tool calls" }),
|
||||
lightContext: Type.Optional(Type.Boolean()),
|
||||
allowUnsafeExternalContent: Type.Optional(Type.Boolean()),
|
||||
fallbacks: params.fallbacks,
|
||||
@@ -737,8 +739,9 @@ ADD JOB:
|
||||
Required: schedule,payload. enabled default true. trigger only every/cron.
|
||||
|
||||
TARGET/PAYLOAD:
|
||||
- main => systemEvent {kind:"systemEvent",text:"..."}; systemEvent defaults main.
|
||||
- main => systemEvent {kind:"systemEvent",text:"..."} or script; systemEvent defaults main.
|
||||
- isolated/current/session:<id> => agentTurn {kind:"agentTurn",message:"...",model?,thinking?,timeoutSeconds?}; agentTurn defaults isolated. timeoutSeconds=0 means none.
|
||||
- script {kind:"script",script:"...",timeoutSeconds?,toolBudget?} supports main or isolated only and requires cron.triggers.enabled.
|
||||
- current binds caller session at creation. session:<id> is persistent. Prefer isolated unless user explicitly wants current binding.
|
||||
|
||||
SCHEDULE:
|
||||
|
||||
@@ -794,7 +794,7 @@ describe("cron cli", () => {
|
||||
value,
|
||||
]);
|
||||
expectRuntimeErrorContaining(
|
||||
"--channel, --to, --account, and --thread-id require a non-main agentTurn or command job with delivery",
|
||||
"--channel, --to, --account, and --thread-id require a non-main agentTurn, command, or script job with delivery",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
warnIfCronSchedulerDisabled,
|
||||
} from "./shared.js";
|
||||
import { normalizeCronSessionTargetOption, parseCronThreadIdOption } from "./thread-id-shared.js";
|
||||
import { readCronTriggerScript } from "./trigger-options.js";
|
||||
import { readCronPayloadScript, readCronTriggerScript } from "./trigger-options.js";
|
||||
|
||||
export function registerCronStatusCommand(cron: Command) {
|
||||
addGatewayClientOptions(
|
||||
@@ -121,6 +121,9 @@ export function registerCronAddCommand(cron: Command) {
|
||||
.option("--trigger-once", "Disable after the first successful triggered run", false)
|
||||
.option("--system-event <text>", "System event payload (main session)")
|
||||
.option("--message <text>", "Agent message payload")
|
||||
.option("--script <file|->", "Headless script payload file, or - for stdin")
|
||||
.option("--script-timeout-seconds <n>", "Script wall-clock timeout seconds")
|
||||
.option("--script-tool-budget <n>", "Maximum script tool calls")
|
||||
.option("--command <shell>", "Command payload run as sh -lc <shell> on the Gateway")
|
||||
.option("--command-argv <json>", "Command payload argv as JSON array of strings")
|
||||
.option("--command-cwd <path>", "Working directory for command payloads")
|
||||
@@ -203,6 +206,7 @@ export function registerCronAddCommand(cron: Command) {
|
||||
const positionalMessage = normalizeOptionalString(messageArg);
|
||||
const commandShell = normalizeOptionalString(opts.command);
|
||||
const commandArgv = parseCronCommandArgv(opts.commandArgv);
|
||||
const scriptPath = normalizeOptionalString(opts.script);
|
||||
if (optionMessage && positionalMessage && optionMessage !== positionalMessage) {
|
||||
throw new Error(
|
||||
"Pass the cron job message either positionally or with --message, not both.",
|
||||
@@ -218,15 +222,35 @@ export function registerCronAddCommand(cron: Command) {
|
||||
Boolean(systemEvent),
|
||||
Boolean(message),
|
||||
Boolean(commandShell) || Boolean(commandArgv),
|
||||
Boolean(scriptPath),
|
||||
].filter(Boolean).length;
|
||||
if (chosen !== 1) {
|
||||
throw new Error(
|
||||
"Choose exactly one payload: --system-event, --message, or --command",
|
||||
"Choose exactly one payload: --system-event, --message, --command, or --script",
|
||||
);
|
||||
}
|
||||
if (systemEvent) {
|
||||
return { kind: "systemEvent" as const, text: systemEvent };
|
||||
}
|
||||
if (scriptPath) {
|
||||
const scriptTimeoutSeconds = parseStrictPositiveIntOrUndefined(
|
||||
opts.scriptTimeoutSeconds,
|
||||
);
|
||||
if (opts.scriptTimeoutSeconds !== undefined && scriptTimeoutSeconds === undefined) {
|
||||
throw new Error("Invalid --script-timeout-seconds (must be a positive integer).");
|
||||
}
|
||||
const scriptToolBudget = parseStrictPositiveIntOrUndefined(opts.scriptToolBudget);
|
||||
if (opts.scriptToolBudget !== undefined && scriptToolBudget === undefined) {
|
||||
throw new Error("Invalid --script-tool-budget (must be a positive integer).");
|
||||
}
|
||||
return {
|
||||
kind: "script" as const,
|
||||
scriptPath,
|
||||
timeoutSeconds: scriptTimeoutSeconds,
|
||||
toolBudget: scriptToolBudget,
|
||||
toolsAllow: parseCronToolsAllow(opts.tools),
|
||||
};
|
||||
}
|
||||
const timeoutSeconds = parseStrictPositiveIntOrUndefined(opts.timeoutSeconds);
|
||||
if (opts.timeoutSeconds !== undefined && timeoutSeconds === undefined) {
|
||||
throw new Error("Invalid --timeout-seconds (must be a positive integer).");
|
||||
@@ -280,11 +304,25 @@ export function registerCronAddCommand(cron: Command) {
|
||||
toolsAllow: parseCronToolsAllow(opts.tools),
|
||||
};
|
||||
})();
|
||||
const resolvedPayload = await (async () => {
|
||||
if (payload.kind !== "script") {
|
||||
return payload;
|
||||
}
|
||||
const { scriptPath, ...scriptPayload } = payload;
|
||||
return {
|
||||
...scriptPayload,
|
||||
script: await readCronPayloadScript(scriptPath),
|
||||
};
|
||||
})();
|
||||
|
||||
const sessionSource = cmd.getOptionValueSource("session");
|
||||
const sessionTargetRaw = normalizeOptionalString(opts.session) ?? "";
|
||||
const inferredSessionTarget =
|
||||
payload.kind === "agentTurn" || payload.kind === "command" ? "isolated" : "main";
|
||||
resolvedPayload.kind === "agentTurn" ||
|
||||
resolvedPayload.kind === "command" ||
|
||||
resolvedPayload.kind === "script"
|
||||
? "isolated"
|
||||
: "main";
|
||||
const sessionTarget =
|
||||
sessionSource === "cli"
|
||||
? normalizeCronSessionTargetOption(sessionTargetRaw) || ""
|
||||
@@ -302,25 +340,37 @@ export function registerCronAddCommand(cron: Command) {
|
||||
throw new Error("Choose --delete-after-run or --keep-after-run, not both");
|
||||
}
|
||||
|
||||
if (sessionTarget === "main" && payload.kind !== "systemEvent") {
|
||||
throw new Error("Main jobs require --system-event (systemEvent).");
|
||||
if (
|
||||
sessionTarget === "main" &&
|
||||
resolvedPayload.kind !== "systemEvent" &&
|
||||
resolvedPayload.kind !== "script"
|
||||
) {
|
||||
throw new Error("Main jobs require --system-event or --script.");
|
||||
}
|
||||
if (
|
||||
resolvedPayload.kind === "script" &&
|
||||
sessionTarget !== "main" &&
|
||||
sessionTarget !== "isolated"
|
||||
) {
|
||||
throw new Error("Script jobs require --session main or --session isolated.");
|
||||
}
|
||||
if (
|
||||
isIsolatedLikeSessionTarget &&
|
||||
payload.kind !== "agentTurn" &&
|
||||
payload.kind !== "command"
|
||||
resolvedPayload.kind !== "agentTurn" &&
|
||||
resolvedPayload.kind !== "command" &&
|
||||
resolvedPayload.kind !== "script"
|
||||
) {
|
||||
throw new Error(
|
||||
"Isolated/current/custom-session jobs require --message (agentTurn) or --command.",
|
||||
);
|
||||
throw new Error("Isolated jobs require --message, --command, or --script.");
|
||||
}
|
||||
if (
|
||||
(opts.announce || typeof opts.deliver === "boolean") &&
|
||||
(!isIsolatedLikeSessionTarget ||
|
||||
(payload.kind !== "agentTurn" && payload.kind !== "command"))
|
||||
(resolvedPayload.kind !== "agentTurn" &&
|
||||
resolvedPayload.kind !== "command" &&
|
||||
resolvedPayload.kind !== "script"))
|
||||
) {
|
||||
throw new Error(
|
||||
"--announce/--no-deliver require a non-main agentTurn or command session target.",
|
||||
"--announce/--no-deliver require a non-main agentTurn, command, or script session target.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -336,10 +386,12 @@ export function registerCronAddCommand(cron: Command) {
|
||||
if (
|
||||
hasChatDeliveryTarget &&
|
||||
(!isIsolatedLikeSessionTarget ||
|
||||
(payload.kind !== "agentTurn" && payload.kind !== "command"))
|
||||
(resolvedPayload.kind !== "agentTurn" &&
|
||||
resolvedPayload.kind !== "command" &&
|
||||
resolvedPayload.kind !== "script"))
|
||||
) {
|
||||
throw new Error(
|
||||
"--channel, --to, --account, and --thread-id require a non-main agentTurn or command job with delivery.",
|
||||
"--channel, --to, --account, and --thread-id require a non-main agentTurn, command, or script job with delivery.",
|
||||
);
|
||||
}
|
||||
if (hasWebhook && hasChatDeliveryTarget) {
|
||||
@@ -349,7 +401,9 @@ export function registerCronAddCommand(cron: Command) {
|
||||
const deliveryMode = hasWebhook
|
||||
? "webhook"
|
||||
: isIsolatedLikeSessionTarget &&
|
||||
(payload.kind === "agentTurn" || payload.kind === "command")
|
||||
(resolvedPayload.kind === "agentTurn" ||
|
||||
resolvedPayload.kind === "command" ||
|
||||
resolvedPayload.kind === "script")
|
||||
? hasAnnounce
|
||||
? "announce"
|
||||
: hasNoDeliver
|
||||
@@ -399,7 +453,12 @@ export function registerCronAddCommand(cron: Command) {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
if ((payload.kind === "agentTurn" || payload.kind === "command") && !agentId) {
|
||||
if (
|
||||
(resolvedPayload.kind === "agentTurn" ||
|
||||
resolvedPayload.kind === "command" ||
|
||||
resolvedPayload.kind === "script") &&
|
||||
!agentId
|
||||
) {
|
||||
defaultRuntime.error(
|
||||
theme.warn(
|
||||
"No --agent specified; the job will run with the configured default agent. " +
|
||||
@@ -431,7 +490,7 @@ export function registerCronAddCommand(cron: Command) {
|
||||
trigger,
|
||||
sessionTarget,
|
||||
wakeMode,
|
||||
payload,
|
||||
payload: resolvedPayload,
|
||||
delivery: deliveryMode
|
||||
? {
|
||||
mode: deliveryMode,
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import type { CronJob } from "../../cron/types.js";
|
||||
import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js";
|
||||
import {
|
||||
parseCronCommandArgv,
|
||||
parseCronCommandEnv,
|
||||
parseCronFallbacks,
|
||||
parseCronToolsAllow,
|
||||
} from "./shared.js";
|
||||
import { parseCronThreadIdOption } from "./thread-id-shared.js";
|
||||
import { readCronPayloadScript } from "./trigger-options.js";
|
||||
|
||||
const assignIf = (
|
||||
target: Record<string, unknown>,
|
||||
key: string,
|
||||
value: unknown,
|
||||
shouldAssign: boolean,
|
||||
) => {
|
||||
if (shouldAssign) {
|
||||
target[key] = value;
|
||||
}
|
||||
};
|
||||
|
||||
export async function resolveCronEditPayloadDeliveryPatch(
|
||||
opts: Record<string, unknown>,
|
||||
loadExistingJob: () => Promise<CronJob>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const patch: Record<string, unknown> = {};
|
||||
const hasSystemEventPatch = typeof opts.systemEvent === "string";
|
||||
const scriptPath = normalizeOptionalString(opts.script);
|
||||
const commandShell = normalizeOptionalString(opts.command);
|
||||
const commandArgv = parseCronCommandArgv(opts.commandArgv);
|
||||
if (commandShell && commandArgv) {
|
||||
throw new Error("Pass command payload either with --command or --command-argv, not both.");
|
||||
}
|
||||
const model = normalizeOptionalString(opts.model);
|
||||
if (model && opts.clearModel) {
|
||||
throw new Error("Use --model or --clear-model, not both");
|
||||
}
|
||||
const thinking = normalizeOptionalString(opts.thinking);
|
||||
if (thinking && opts.clearThinking) {
|
||||
throw new Error("Use --thinking or --clear-thinking, not both");
|
||||
}
|
||||
const fallbacks = parseCronFallbacks(opts.fallbacks);
|
||||
if (typeof opts.fallbacks === "string" && opts.clearFallbacks) {
|
||||
throw new Error("Use --fallbacks or --clear-fallbacks, not both");
|
||||
}
|
||||
const toolsAllow = parseCronToolsAllow(opts.tools);
|
||||
const timeoutSecondsValue = opts.timeoutSeconds;
|
||||
const rawTimeoutSeconds =
|
||||
timeoutSecondsValue === undefined
|
||||
? undefined
|
||||
: typeof timeoutSecondsValue === "string" || typeof timeoutSecondsValue === "number"
|
||||
? String(timeoutSecondsValue).trim()
|
||||
: "";
|
||||
if (rawTimeoutSeconds !== undefined && !/^\d+$/u.test(rawTimeoutSeconds)) {
|
||||
throw new Error("Invalid --timeout-seconds (must be a positive integer).");
|
||||
}
|
||||
const timeoutSeconds = rawTimeoutSeconds === undefined ? undefined : Number(rawTimeoutSeconds);
|
||||
const hasTimeoutSeconds =
|
||||
typeof timeoutSeconds === "number" &&
|
||||
Number.isSafeInteger(timeoutSeconds) &&
|
||||
timeoutSeconds > 0;
|
||||
if (rawTimeoutSeconds !== undefined && !hasTimeoutSeconds) {
|
||||
throw new Error("Invalid --timeout-seconds (must be a positive integer).");
|
||||
}
|
||||
const rawNoOutputTimeoutSeconds =
|
||||
opts.noOutputTimeoutSeconds ??
|
||||
(typeof opts.outputTimeoutSeconds === "string" || typeof opts.outputTimeoutSeconds === "number"
|
||||
? opts.outputTimeoutSeconds
|
||||
: undefined);
|
||||
const noOutputTimeoutSeconds = parseStrictPositiveInteger(rawNoOutputTimeoutSeconds);
|
||||
if (rawNoOutputTimeoutSeconds !== undefined && noOutputTimeoutSeconds === undefined) {
|
||||
throw new Error("Invalid --no-output-timeout-seconds (must be a positive integer).");
|
||||
}
|
||||
const outputMaxBytes = parseStrictPositiveInteger(opts.outputMaxBytes);
|
||||
if (opts.outputMaxBytes !== undefined && outputMaxBytes === undefined) {
|
||||
throw new Error("Invalid --output-max-bytes (must be a positive integer).");
|
||||
}
|
||||
const scriptTimeoutSeconds = parseStrictPositiveInteger(opts.scriptTimeoutSeconds);
|
||||
if (opts.scriptTimeoutSeconds !== undefined && scriptTimeoutSeconds === undefined) {
|
||||
throw new Error("Invalid --script-timeout-seconds (must be a positive integer).");
|
||||
}
|
||||
const scriptToolBudget = parseStrictPositiveInteger(opts.scriptToolBudget);
|
||||
if (opts.scriptToolBudget !== undefined && scriptToolBudget === undefined) {
|
||||
throw new Error("Invalid --script-tool-budget (must be a positive integer).");
|
||||
}
|
||||
|
||||
const hasWebhookDelivery = typeof opts.webhook === "string";
|
||||
const hasDeliveryModeFlag =
|
||||
opts.announce || typeof opts.deliver === "boolean" || hasWebhookDelivery;
|
||||
const threadId = parseCronThreadIdOption(opts.threadId);
|
||||
const hasDeliveryThreadId = typeof threadId === "number";
|
||||
const hasDeliveryTarget =
|
||||
typeof opts.channel === "string" ||
|
||||
typeof opts.to === "string" ||
|
||||
hasDeliveryThreadId ||
|
||||
Boolean(opts.clearChannel) ||
|
||||
Boolean(opts.clearTo) ||
|
||||
Boolean(opts.clearThreadId);
|
||||
const hasDeliveryAccount = typeof opts.account === "string" || Boolean(opts.clearAccount);
|
||||
const hasBestEffort = typeof opts.bestEffortDeliver === "boolean";
|
||||
if (hasWebhookDelivery && (hasDeliveryTarget || hasDeliveryAccount)) {
|
||||
throw new Error("--webhook cannot be combined with chat delivery options.");
|
||||
}
|
||||
if (typeof opts.channel === "string" && opts.clearChannel) {
|
||||
throw new Error("Use --channel or --clear-channel, not both");
|
||||
}
|
||||
if (typeof opts.to === "string" && opts.clearTo) {
|
||||
throw new Error("Use --to or --clear-to, not both");
|
||||
}
|
||||
if (hasDeliveryThreadId && opts.clearThreadId) {
|
||||
throw new Error("Use --thread-id or --clear-thread-id, not both");
|
||||
}
|
||||
if (typeof opts.account === "string" && opts.clearAccount) {
|
||||
throw new Error("Use --account or --clear-account, not both");
|
||||
}
|
||||
|
||||
const hasCommandSpecificPayloadField =
|
||||
Boolean(commandShell) ||
|
||||
Boolean(commandArgv) ||
|
||||
typeof opts.commandCwd === "string" ||
|
||||
typeof opts.commandInput === "string" ||
|
||||
opts.commandEnv !== undefined ||
|
||||
noOutputTimeoutSeconds !== undefined ||
|
||||
outputMaxBytes !== undefined;
|
||||
let timeoutOnlyPayloadKind: "agentTurn" | "command" | undefined;
|
||||
if (
|
||||
hasTimeoutSeconds &&
|
||||
!hasCommandSpecificPayloadField &&
|
||||
typeof opts.message !== "string" &&
|
||||
!model &&
|
||||
typeof opts.fallbacks !== "string" &&
|
||||
!opts.clearFallbacks &&
|
||||
!thinking &&
|
||||
!opts.clearThinking &&
|
||||
typeof opts.lightContext !== "boolean" &&
|
||||
typeof opts.tools !== "string" &&
|
||||
!Array.isArray(opts.tools) &&
|
||||
!opts.clearTools
|
||||
) {
|
||||
const existing = await loadExistingJob();
|
||||
timeoutOnlyPayloadKind = existing.payload.kind === "command" ? "command" : "agentTurn";
|
||||
}
|
||||
const hasAgentTurnPayloadField =
|
||||
typeof opts.message === "string" ||
|
||||
Boolean(model) ||
|
||||
Boolean(opts.clearModel) ||
|
||||
typeof opts.fallbacks === "string" ||
|
||||
Boolean(opts.clearFallbacks) ||
|
||||
Boolean(thinking) ||
|
||||
Boolean(opts.clearThinking) ||
|
||||
(hasTimeoutSeconds &&
|
||||
!hasCommandSpecificPayloadField &&
|
||||
timeoutOnlyPayloadKind !== "command") ||
|
||||
typeof opts.lightContext === "boolean" ||
|
||||
typeof opts.tools === "string" ||
|
||||
Array.isArray(opts.tools) ||
|
||||
opts.clearTools;
|
||||
const hasCommandPayloadField =
|
||||
hasCommandSpecificPayloadField ||
|
||||
(hasTimeoutSeconds && (hasCommandSpecificPayloadField || timeoutOnlyPayloadKind === "command"));
|
||||
const hasAgentTurnPatch = hasAgentTurnPayloadField;
|
||||
const hasCommandPatch = hasCommandPayloadField;
|
||||
const hasScriptPatch =
|
||||
Boolean(scriptPath) || scriptTimeoutSeconds !== undefined || scriptToolBudget !== undefined;
|
||||
if (
|
||||
[hasSystemEventPatch, hasAgentTurnPatch, hasCommandPatch, hasScriptPatch].filter(Boolean)
|
||||
.length > 1
|
||||
) {
|
||||
throw new Error("Choose at most one payload change");
|
||||
}
|
||||
|
||||
if (hasSystemEventPatch) {
|
||||
patch.payload = {
|
||||
kind: "systemEvent",
|
||||
text: String(opts.systemEvent),
|
||||
};
|
||||
} else if (hasAgentTurnPatch) {
|
||||
const payload: Record<string, unknown> = { kind: "agentTurn" };
|
||||
assignIf(payload, "message", String(opts.message), typeof opts.message === "string");
|
||||
if (opts.clearModel) {
|
||||
payload.model = null;
|
||||
} else {
|
||||
assignIf(payload, "model", model, Boolean(model));
|
||||
}
|
||||
assignIf(payload, "fallbacks", fallbacks, typeof opts.fallbacks === "string");
|
||||
assignIf(payload, "fallbacks", null, Boolean(opts.clearFallbacks));
|
||||
if (opts.clearThinking) {
|
||||
payload.thinking = null;
|
||||
} else {
|
||||
assignIf(payload, "thinking", thinking, Boolean(thinking));
|
||||
}
|
||||
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds);
|
||||
assignIf(payload, "lightContext", opts.lightContext, typeof opts.lightContext === "boolean");
|
||||
if (opts.clearTools) {
|
||||
payload.toolsAllow = null;
|
||||
} else if (toolsAllow) {
|
||||
payload.toolsAllow = toolsAllow;
|
||||
}
|
||||
patch.payload = payload;
|
||||
} else if (hasCommandPatch) {
|
||||
const payload: Record<string, unknown> = { kind: "command" };
|
||||
assignIf(payload, "argv", commandArgv, Boolean(commandArgv));
|
||||
assignIf(payload, "argv", ["sh", "-lc", commandShell], Boolean(commandShell));
|
||||
assignIf(
|
||||
payload,
|
||||
"cwd",
|
||||
normalizeOptionalString(opts.commandCwd),
|
||||
typeof opts.commandCwd === "string",
|
||||
);
|
||||
assignIf(payload, "env", parseCronCommandEnv(opts.commandEnv), opts.commandEnv !== undefined);
|
||||
assignIf(payload, "input", opts.commandInput, typeof opts.commandInput === "string");
|
||||
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds);
|
||||
assignIf(
|
||||
payload,
|
||||
"noOutputTimeoutSeconds",
|
||||
noOutputTimeoutSeconds,
|
||||
noOutputTimeoutSeconds !== undefined,
|
||||
);
|
||||
assignIf(payload, "outputMaxBytes", outputMaxBytes, outputMaxBytes !== undefined);
|
||||
patch.payload = payload;
|
||||
} else if (hasScriptPatch) {
|
||||
const payload: Record<string, unknown> = { kind: "script" };
|
||||
if (scriptPath) {
|
||||
payload.script = await readCronPayloadScript(scriptPath);
|
||||
}
|
||||
assignIf(payload, "timeoutSeconds", scriptTimeoutSeconds, scriptTimeoutSeconds !== undefined);
|
||||
assignIf(payload, "toolBudget", scriptToolBudget, scriptToolBudget !== undefined);
|
||||
patch.payload = payload;
|
||||
}
|
||||
|
||||
if (hasDeliveryModeFlag || hasDeliveryTarget || hasDeliveryAccount || hasBestEffort) {
|
||||
const delivery: Record<string, unknown> = {};
|
||||
if (hasDeliveryModeFlag) {
|
||||
delivery.mode = hasWebhookDelivery
|
||||
? "webhook"
|
||||
: opts.announce || opts.deliver === true
|
||||
? "announce"
|
||||
: "none";
|
||||
} else if (opts.bestEffortDeliver === true) {
|
||||
// Back-compat: enabling best-effort historically implied announce mode.
|
||||
delivery.mode = "announce";
|
||||
}
|
||||
if (opts.clearChannel) {
|
||||
delivery.channel = null;
|
||||
} else if (typeof opts.channel === "string") {
|
||||
const channel = opts.channel.trim();
|
||||
delivery.channel = channel ? channel : undefined;
|
||||
}
|
||||
if (hasWebhookDelivery) {
|
||||
const webhook = normalizeOptionalString(opts.webhook) ?? "";
|
||||
delivery.to = webhook ? webhook : undefined;
|
||||
} else if (opts.clearTo) {
|
||||
delivery.to = null;
|
||||
} else if (typeof opts.to === "string") {
|
||||
const to = opts.to.trim();
|
||||
delivery.to = to ? to : undefined;
|
||||
}
|
||||
if (opts.clearThreadId) {
|
||||
delivery.threadId = null;
|
||||
} else if (hasDeliveryThreadId) {
|
||||
delivery.threadId = threadId;
|
||||
}
|
||||
if (opts.clearAccount) {
|
||||
delivery.accountId = null;
|
||||
} else if (typeof opts.account === "string") {
|
||||
const account = opts.account.trim();
|
||||
delivery.accountId = account ? account : undefined;
|
||||
}
|
||||
if (typeof opts.bestEffortDeliver === "boolean") {
|
||||
delivery.bestEffort = opts.bestEffortDeliver;
|
||||
}
|
||||
patch.delivery = delivery;
|
||||
}
|
||||
|
||||
return patch;
|
||||
}
|
||||
@@ -11,36 +11,22 @@ import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js";
|
||||
import { sanitizeAgentId } from "../../routing/session-key.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||
import { resolveCronEditPayloadDeliveryPatch } from "./register.cron-edit-options.js";
|
||||
import {
|
||||
applyExistingCronSchedulePatch,
|
||||
resolveCronEditScheduleRequest,
|
||||
} from "./schedule-options.js";
|
||||
import {
|
||||
getCronChannelOptions,
|
||||
parseCronCommandArgv,
|
||||
parseCronCommandEnv,
|
||||
parseCronFallbacks,
|
||||
parseCronToolsAllow,
|
||||
parsePositiveCronDurationMs,
|
||||
warnIfCronSchedulerDisabled,
|
||||
} from "./shared.js";
|
||||
import { normalizeCronSessionTargetOption, parseCronThreadIdOption } from "./thread-id-shared.js";
|
||||
import { normalizeCronSessionTargetOption } from "./thread-id-shared.js";
|
||||
import { readCronTriggerScript } from "./trigger-options.js";
|
||||
|
||||
const CRON_EDIT_LOOKUP_PAGE_SIZE = 200;
|
||||
const CRON_EDIT_LOOKUP_MAX_PAGES = 50;
|
||||
|
||||
const assignIf = (
|
||||
target: Record<string, unknown>,
|
||||
key: string,
|
||||
value: unknown,
|
||||
shouldAssign: boolean,
|
||||
) => {
|
||||
if (shouldAssign) {
|
||||
target[key] = value;
|
||||
}
|
||||
};
|
||||
|
||||
function isUnknownCronGetMethodError(error: unknown): error is Error {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
@@ -128,6 +114,9 @@ export function registerCronEditCommand(cron: Command) {
|
||||
.option("--clear-trigger", "Remove the condition trigger", false)
|
||||
.option("--system-event <text>", "Set systemEvent payload")
|
||||
.option("--message <text>", "Set agentTurn payload message")
|
||||
.option("--script <file|->", "Set headless script payload from file, or - for stdin")
|
||||
.option("--script-timeout-seconds <n>", "Set script wall-clock timeout seconds")
|
||||
.option("--script-tool-budget <n>", "Set maximum script tool calls")
|
||||
.option("--command <shell>", "Set command payload run as sh -lc <shell> on the Gateway")
|
||||
.option("--command-argv <json>", "Set command payload argv as JSON array of strings")
|
||||
.option("--command-cwd <path>", "Set command payload working directory")
|
||||
@@ -208,6 +197,12 @@ export function registerCronEditCommand(cron: Command) {
|
||||
"Main jobs cannot use --message or --command; use --system-event or --session isolated.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
(sessionTarget === "current" || sessionTarget?.startsWith("session:")) &&
|
||||
typeof opts.script === "string"
|
||||
) {
|
||||
throw new Error("Script jobs require --session main or --session isolated.");
|
||||
}
|
||||
if (
|
||||
(sessionTarget === "isolated" ||
|
||||
sessionTarget === "current" ||
|
||||
@@ -364,239 +359,12 @@ export function registerCronEditCommand(cron: Command) {
|
||||
patch.schedule = applyExistingCronSchedulePatch(existing.schedule, scheduleRequest);
|
||||
}
|
||||
|
||||
const hasSystemEventPatch = typeof opts.systemEvent === "string";
|
||||
const commandShell = normalizeOptionalString(opts.command);
|
||||
const commandArgv = parseCronCommandArgv(opts.commandArgv);
|
||||
if (commandShell && commandArgv) {
|
||||
throw new Error(
|
||||
"Pass command payload either with --command or --command-argv, not both.",
|
||||
);
|
||||
}
|
||||
const model = normalizeOptionalString(opts.model);
|
||||
if (model && opts.clearModel) {
|
||||
throw new Error("Use --model or --clear-model, not both");
|
||||
}
|
||||
const thinking = normalizeOptionalString(opts.thinking);
|
||||
if (thinking && opts.clearThinking) {
|
||||
throw new Error("Use --thinking or --clear-thinking, not both");
|
||||
}
|
||||
const fallbacks = parseCronFallbacks(opts.fallbacks);
|
||||
if (typeof opts.fallbacks === "string" && opts.clearFallbacks) {
|
||||
throw new Error("Use --fallbacks or --clear-fallbacks, not both");
|
||||
}
|
||||
const toolsAllow = parseCronToolsAllow(opts.tools);
|
||||
const rawTimeoutSeconds =
|
||||
opts.timeoutSeconds === undefined ? undefined : String(opts.timeoutSeconds).trim();
|
||||
if (rawTimeoutSeconds !== undefined && !/^\d+$/u.test(rawTimeoutSeconds)) {
|
||||
throw new Error("Invalid --timeout-seconds (must be a positive integer).");
|
||||
}
|
||||
const timeoutSeconds =
|
||||
rawTimeoutSeconds === undefined ? undefined : Number(rawTimeoutSeconds);
|
||||
const hasTimeoutSeconds =
|
||||
typeof timeoutSeconds === "number" &&
|
||||
Number.isSafeInteger(timeoutSeconds) &&
|
||||
timeoutSeconds > 0;
|
||||
if (rawTimeoutSeconds !== undefined && !hasTimeoutSeconds) {
|
||||
throw new Error("Invalid --timeout-seconds (must be a positive integer).");
|
||||
}
|
||||
const rawNoOutputTimeoutSeconds =
|
||||
opts.noOutputTimeoutSeconds ??
|
||||
(typeof opts.outputTimeoutSeconds === "string" ||
|
||||
typeof opts.outputTimeoutSeconds === "number"
|
||||
? opts.outputTimeoutSeconds
|
||||
: undefined);
|
||||
const noOutputTimeoutSeconds = parseStrictPositiveInteger(rawNoOutputTimeoutSeconds);
|
||||
if (rawNoOutputTimeoutSeconds !== undefined && noOutputTimeoutSeconds === undefined) {
|
||||
throw new Error("Invalid --no-output-timeout-seconds (must be a positive integer).");
|
||||
}
|
||||
const outputMaxBytes = parseStrictPositiveInteger(opts.outputMaxBytes);
|
||||
if (opts.outputMaxBytes !== undefined && outputMaxBytes === undefined) {
|
||||
throw new Error("Invalid --output-max-bytes (must be a positive integer).");
|
||||
}
|
||||
const hasDeliveryModeFlag =
|
||||
opts.announce || typeof opts.deliver === "boolean" || hasWebhookDelivery;
|
||||
const threadId = parseCronThreadIdOption(opts.threadId);
|
||||
const hasDeliveryThreadId = typeof threadId === "number";
|
||||
const hasDeliveryTarget =
|
||||
typeof opts.channel === "string" ||
|
||||
typeof opts.to === "string" ||
|
||||
hasDeliveryThreadId ||
|
||||
Boolean(opts.clearChannel) ||
|
||||
Boolean(opts.clearTo) ||
|
||||
Boolean(opts.clearThreadId);
|
||||
const hasDeliveryAccount = typeof opts.account === "string" || Boolean(opts.clearAccount);
|
||||
const hasBestEffort = typeof opts.bestEffortDeliver === "boolean";
|
||||
if (hasWebhookDelivery && (hasDeliveryTarget || hasDeliveryAccount)) {
|
||||
throw new Error("--webhook cannot be combined with chat delivery options.");
|
||||
}
|
||||
if (typeof opts.channel === "string" && opts.clearChannel) {
|
||||
throw new Error("Use --channel or --clear-channel, not both");
|
||||
}
|
||||
if (typeof opts.to === "string" && opts.clearTo) {
|
||||
throw new Error("Use --to or --clear-to, not both");
|
||||
}
|
||||
if (hasDeliveryThreadId && opts.clearThreadId) {
|
||||
throw new Error("Use --thread-id or --clear-thread-id, not both");
|
||||
}
|
||||
if (typeof opts.account === "string" && opts.clearAccount) {
|
||||
throw new Error("Use --account or --clear-account, not both");
|
||||
}
|
||||
const hasCommandSpecificPayloadField =
|
||||
Boolean(commandShell) ||
|
||||
Boolean(commandArgv) ||
|
||||
typeof opts.commandCwd === "string" ||
|
||||
typeof opts.commandInput === "string" ||
|
||||
opts.commandEnv !== undefined ||
|
||||
noOutputTimeoutSeconds !== undefined ||
|
||||
outputMaxBytes !== undefined;
|
||||
let timeoutOnlyPayloadKind: "agentTurn" | "command" | undefined;
|
||||
if (
|
||||
hasTimeoutSeconds &&
|
||||
!hasCommandSpecificPayloadField &&
|
||||
typeof opts.message !== "string" &&
|
||||
!model &&
|
||||
typeof opts.fallbacks !== "string" &&
|
||||
!opts.clearFallbacks &&
|
||||
!thinking &&
|
||||
!opts.clearThinking &&
|
||||
typeof opts.lightContext !== "boolean" &&
|
||||
typeof opts.tools !== "string" &&
|
||||
!Array.isArray(opts.tools) &&
|
||||
!opts.clearTools
|
||||
) {
|
||||
const existing = await readCronJobForEdit(opts, String(id));
|
||||
timeoutOnlyPayloadKind = existing.payload.kind === "command" ? "command" : "agentTurn";
|
||||
}
|
||||
const hasAgentTurnPayloadField =
|
||||
typeof opts.message === "string" ||
|
||||
Boolean(model) ||
|
||||
Boolean(opts.clearModel) ||
|
||||
typeof opts.fallbacks === "string" ||
|
||||
Boolean(opts.clearFallbacks) ||
|
||||
Boolean(thinking) ||
|
||||
Boolean(opts.clearThinking) ||
|
||||
(hasTimeoutSeconds &&
|
||||
!hasCommandSpecificPayloadField &&
|
||||
timeoutOnlyPayloadKind !== "command") ||
|
||||
typeof opts.lightContext === "boolean" ||
|
||||
typeof opts.tools === "string" ||
|
||||
Array.isArray(opts.tools) ||
|
||||
opts.clearTools;
|
||||
const hasCommandPayloadField =
|
||||
hasCommandSpecificPayloadField ||
|
||||
(hasTimeoutSeconds &&
|
||||
(hasCommandSpecificPayloadField || timeoutOnlyPayloadKind === "command"));
|
||||
const hasAgentTurnPatch = hasAgentTurnPayloadField;
|
||||
const hasCommandPatch = hasCommandPayloadField;
|
||||
if (
|
||||
[hasSystemEventPatch, hasAgentTurnPatch, hasCommandPatch].filter(Boolean).length > 1
|
||||
) {
|
||||
throw new Error("Choose at most one payload change");
|
||||
}
|
||||
if (hasSystemEventPatch) {
|
||||
patch.payload = {
|
||||
kind: "systemEvent",
|
||||
text: String(opts.systemEvent),
|
||||
};
|
||||
} else if (hasAgentTurnPatch) {
|
||||
const payload: Record<string, unknown> = { kind: "agentTurn" };
|
||||
assignIf(payload, "message", String(opts.message), typeof opts.message === "string");
|
||||
if (opts.clearModel) {
|
||||
payload.model = null;
|
||||
} else {
|
||||
assignIf(payload, "model", model, Boolean(model));
|
||||
}
|
||||
assignIf(payload, "fallbacks", fallbacks, typeof opts.fallbacks === "string");
|
||||
assignIf(payload, "fallbacks", null, Boolean(opts.clearFallbacks));
|
||||
if (opts.clearThinking) {
|
||||
payload.thinking = null;
|
||||
} else {
|
||||
assignIf(payload, "thinking", thinking, Boolean(thinking));
|
||||
}
|
||||
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds);
|
||||
assignIf(
|
||||
payload,
|
||||
"lightContext",
|
||||
opts.lightContext,
|
||||
typeof opts.lightContext === "boolean",
|
||||
);
|
||||
if (opts.clearTools) {
|
||||
payload.toolsAllow = null;
|
||||
} else if (toolsAllow) {
|
||||
payload.toolsAllow = toolsAllow;
|
||||
}
|
||||
patch.payload = payload;
|
||||
} else if (hasCommandPatch) {
|
||||
const payload: Record<string, unknown> = { kind: "command" };
|
||||
assignIf(payload, "argv", commandArgv, Boolean(commandArgv));
|
||||
assignIf(payload, "argv", ["sh", "-lc", commandShell], Boolean(commandShell));
|
||||
assignIf(
|
||||
payload,
|
||||
"cwd",
|
||||
normalizeOptionalString(opts.commandCwd),
|
||||
typeof opts.commandCwd === "string",
|
||||
);
|
||||
assignIf(
|
||||
payload,
|
||||
"env",
|
||||
parseCronCommandEnv(opts.commandEnv),
|
||||
opts.commandEnv !== undefined,
|
||||
);
|
||||
assignIf(payload, "input", opts.commandInput, typeof opts.commandInput === "string");
|
||||
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds);
|
||||
assignIf(
|
||||
payload,
|
||||
"noOutputTimeoutSeconds",
|
||||
noOutputTimeoutSeconds,
|
||||
noOutputTimeoutSeconds !== undefined,
|
||||
);
|
||||
assignIf(payload, "outputMaxBytes", outputMaxBytes, outputMaxBytes !== undefined);
|
||||
patch.payload = payload;
|
||||
}
|
||||
|
||||
if (hasDeliveryModeFlag || hasDeliveryTarget || hasDeliveryAccount || hasBestEffort) {
|
||||
const delivery: Record<string, unknown> = {};
|
||||
if (hasDeliveryModeFlag) {
|
||||
delivery.mode = hasWebhookDelivery
|
||||
? "webhook"
|
||||
: opts.announce || opts.deliver === true
|
||||
? "announce"
|
||||
: "none";
|
||||
} else if (opts.bestEffortDeliver === true) {
|
||||
// Back-compat: enabling best-effort historically implied announce mode.
|
||||
delivery.mode = "announce";
|
||||
}
|
||||
if (opts.clearChannel) {
|
||||
delivery.channel = null;
|
||||
} else if (typeof opts.channel === "string") {
|
||||
const channel = opts.channel.trim();
|
||||
delivery.channel = channel ? channel : undefined;
|
||||
}
|
||||
if (hasWebhookDelivery) {
|
||||
const webhook = normalizeOptionalString(opts.webhook) ?? "";
|
||||
delivery.to = webhook ? webhook : undefined;
|
||||
} else if (opts.clearTo) {
|
||||
delivery.to = null;
|
||||
} else if (typeof opts.to === "string") {
|
||||
const to = opts.to.trim();
|
||||
delivery.to = to ? to : undefined;
|
||||
}
|
||||
if (opts.clearThreadId) {
|
||||
delivery.threadId = null;
|
||||
} else if (hasDeliveryThreadId) {
|
||||
delivery.threadId = threadId;
|
||||
}
|
||||
if (opts.clearAccount) {
|
||||
delivery.accountId = null;
|
||||
} else if (typeof opts.account === "string") {
|
||||
const account = opts.account.trim();
|
||||
delivery.accountId = account ? account : undefined;
|
||||
}
|
||||
if (typeof opts.bestEffortDeliver === "boolean") {
|
||||
delivery.bestEffort = opts.bestEffortDeliver;
|
||||
}
|
||||
patch.delivery = delivery;
|
||||
}
|
||||
Object.assign(
|
||||
patch,
|
||||
await resolveCronEditPayloadDeliveryPatch(opts, () =>
|
||||
readCronJobForEdit(opts, String(id)),
|
||||
),
|
||||
);
|
||||
|
||||
const hasFailureAlertAfter = typeof opts.failureAlertAfter === "string";
|
||||
const hasFailureAlertChannel = typeof opts.failureAlertChannel === "string";
|
||||
|
||||
@@ -18,7 +18,7 @@ vi.mock("../gateway-rpc.js", async () => {
|
||||
|
||||
const { registerCronAddCommand } = await import("./register.cron-add.js");
|
||||
const { registerCronEditCommand } = await import("./register.cron-edit.js");
|
||||
const { readCronTriggerScript } = await import("./trigger-options.js");
|
||||
const { readCronPayloadScript, readCronTriggerScript } = await import("./trigger-options.js");
|
||||
|
||||
describe("cron trigger CLI options", () => {
|
||||
let fixtureRoot = "";
|
||||
@@ -79,6 +79,87 @@ describe("cron trigger CLI options", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reads --script client-side and sends payload budgets on add", async () => {
|
||||
const scriptPath = path.join(fixtureRoot, "job.js");
|
||||
await fs.writeFile(scriptPath, " return { notify: 'done' } \n", "utf8");
|
||||
const program = new Command().exitOverride();
|
||||
registerCronAddCommand(program);
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
"add",
|
||||
"--name",
|
||||
"script job",
|
||||
"--every",
|
||||
"30s",
|
||||
"--script",
|
||||
scriptPath,
|
||||
"--script-timeout-seconds",
|
||||
"450",
|
||||
"--script-tool-budget",
|
||||
"75",
|
||||
"--session",
|
||||
"isolated",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(callGatewayFromCli).toHaveBeenCalledWith(
|
||||
"cron.add",
|
||||
expect.objectContaining({
|
||||
script: scriptPath,
|
||||
scriptTimeoutSeconds: "450",
|
||||
scriptToolBudget: "75",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
sessionTarget: "isolated",
|
||||
payload: {
|
||||
kind: "script",
|
||||
script: "return { notify: 'done' }",
|
||||
timeoutSeconds: 450,
|
||||
toolBudget: 75,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reads script payload updates client-side", async () => {
|
||||
const scriptPath = path.join(fixtureRoot, "edit-job.js");
|
||||
await fs.writeFile(scriptPath, "return { state: { ok: true } }\n", "utf8");
|
||||
const program = new Command().exitOverride();
|
||||
registerCronEditCommand(program);
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
"edit",
|
||||
"job-1",
|
||||
"--script",
|
||||
scriptPath,
|
||||
"--script-timeout-seconds",
|
||||
"600",
|
||||
"--script-tool-budget",
|
||||
"100",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(callGatewayFromCli).toHaveBeenCalledWith(
|
||||
"cron.update",
|
||||
expect.objectContaining({ script: scriptPath }),
|
||||
{
|
||||
id: "job-1",
|
||||
patch: {
|
||||
payload: {
|
||||
kind: "script",
|
||||
script: "return { state: { ok: true } }",
|
||||
timeoutSeconds: 600,
|
||||
toolBudget: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("sends pacing bounds on add", async () => {
|
||||
const program = new Command().exitOverride();
|
||||
registerCronAddCommand(program);
|
||||
@@ -116,6 +197,18 @@ describe("cron trigger CLI options", () => {
|
||||
await expect(readCronTriggerScript(scriptPath)).resolves.toHaveLength(65_536);
|
||||
});
|
||||
|
||||
it("uses the same size and empty-input validation for payload scripts", async () => {
|
||||
const atLimitPath = path.join(fixtureRoot, "payload-at-limit.js");
|
||||
const emptyPath = path.join(fixtureRoot, "payload-empty.js");
|
||||
await fs.writeFile(atLimitPath, "x".repeat(65_536), "utf8");
|
||||
await fs.writeFile(emptyPath, " \n", "utf8");
|
||||
|
||||
await expect(readCronPayloadScript(atLimitPath)).resolves.toHaveLength(65_536);
|
||||
await expect(readCronPayloadScript(emptyPath)).rejects.toThrow(
|
||||
"Script payload must not be empty",
|
||||
);
|
||||
});
|
||||
|
||||
it("stops oversized trigger script files before the gateway call", async () => {
|
||||
const scriptPath = path.join(fixtureRoot, "oversized.js");
|
||||
await fs.writeFile(scriptPath, "x".repeat(65_537), "utf8");
|
||||
|
||||
@@ -4,10 +4,10 @@ import { readByteStreamWithLimit } from "@openclaw/media-core/read-byte-stream-w
|
||||
|
||||
const MAX_CRON_TRIGGER_SCRIPT_BYTES = 65_536;
|
||||
|
||||
async function readTriggerScriptStream(stream: AsyncIterable<unknown>): Promise<string> {
|
||||
async function readScriptStream(stream: AsyncIterable<unknown>, label: string): Promise<string> {
|
||||
const bytes = await readByteStreamWithLimit(stream, {
|
||||
maxBytes: MAX_CRON_TRIGGER_SCRIPT_BYTES,
|
||||
onOverflow: () => new Error(`Trigger script exceeds ${MAX_CRON_TRIGGER_SCRIPT_BYTES} bytes`),
|
||||
onOverflow: () => new Error(`${label} exceeds ${MAX_CRON_TRIGGER_SCRIPT_BYTES} bytes`),
|
||||
});
|
||||
return bytes.toString("utf8");
|
||||
}
|
||||
@@ -20,10 +20,24 @@ export async function readCronTriggerScript(
|
||||
},
|
||||
): Promise<string> {
|
||||
const stream = source === "-" ? (deps?.stdin ?? process.stdin) : createReadStream(source);
|
||||
const raw = await readTriggerScriptStream(stream);
|
||||
const raw = await readScriptStream(stream, "Trigger script");
|
||||
const script = raw.trim();
|
||||
if (!script) {
|
||||
throw new Error("Trigger script must not be empty");
|
||||
}
|
||||
return script;
|
||||
}
|
||||
|
||||
/** Reads a script payload locally before sending the cron RPC. */
|
||||
export async function readCronPayloadScript(
|
||||
source: string,
|
||||
deps?: { stdin?: AsyncIterable<unknown> },
|
||||
): Promise<string> {
|
||||
const stream = source === "-" ? (deps?.stdin ?? process.stdin) : createReadStream(source);
|
||||
const raw = await readScriptStream(stream, "Script payload");
|
||||
const script = raw.trim();
|
||||
if (!script) {
|
||||
throw new Error("Script payload must not be empty");
|
||||
}
|
||||
return script;
|
||||
}
|
||||
|
||||
@@ -657,7 +657,9 @@ export function normalizeStoredCronJobs(
|
||||
}
|
||||
} else {
|
||||
const inferredSessionTarget =
|
||||
payloadKind === "agentTurn" || payloadKind === "command" ? "isolated" : "main";
|
||||
payloadKind === "agentTurn" || payloadKind === "command" || payloadKind === "script"
|
||||
? "isolated"
|
||||
: "main";
|
||||
if (raw.sessionTarget !== inferredSessionTarget) {
|
||||
raw.sessionTarget = inferredSessionTarget;
|
||||
mutated = true;
|
||||
@@ -669,14 +671,18 @@ export function normalizeStoredCronJobs(
|
||||
sessionTarget === "isolated" ||
|
||||
sessionTarget === "current" ||
|
||||
sessionTarget.startsWith("session:") ||
|
||||
(sessionTarget === "" && (payloadKind === "agentTurn" || payloadKind === "command"));
|
||||
(sessionTarget === "" &&
|
||||
(payloadKind === "agentTurn" || payloadKind === "command" || payloadKind === "script"));
|
||||
const hasDelivery = delivery && typeof delivery === "object" && !Array.isArray(delivery);
|
||||
const normalizedLegacy = normalizeLegacyDeliveryInput({
|
||||
delivery: hasDelivery ? (delivery as Record<string, unknown>) : null,
|
||||
payload: payloadRecord,
|
||||
});
|
||||
|
||||
if (isIsolatedRunnablePayload && (payloadKind === "agentTurn" || payloadKind === "command")) {
|
||||
if (
|
||||
isIsolatedRunnablePayload &&
|
||||
(payloadKind === "agentTurn" || payloadKind === "command" || payloadKind === "script")
|
||||
) {
|
||||
if (!hasDelivery && normalizedLegacy.delivery) {
|
||||
raw.delivery = normalizedLegacy.delivery;
|
||||
mutated = true;
|
||||
|
||||
@@ -8,7 +8,11 @@ export function shouldDefaultCronDeliveryToAnnounce(params: {
|
||||
payloadKind: unknown;
|
||||
sessionTarget: unknown;
|
||||
}): boolean {
|
||||
if (params.payloadKind !== "agentTurn" && params.payloadKind !== "command") {
|
||||
if (
|
||||
params.payloadKind !== "agentTurn" &&
|
||||
params.payloadKind !== "command" &&
|
||||
params.payloadKind !== "script"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -24,6 +24,7 @@ const cronDeliveryLogger = getChildLogger({ subsystem: "cron-delivery" });
|
||||
type CronAnnounceTarget = {
|
||||
channel?: string;
|
||||
to?: string;
|
||||
threadId?: string | number;
|
||||
accountId?: string;
|
||||
sessionKey?: string;
|
||||
inheritSessionThread?: boolean;
|
||||
@@ -55,6 +56,7 @@ async function resolveCronAnnounceDelivery(params: {
|
||||
{
|
||||
channel: params.target.channel as CronMessageChannel | undefined,
|
||||
to: params.target.to,
|
||||
threadId: params.target.threadId,
|
||||
accountId: params.target.accountId,
|
||||
sessionKey: params.target.sessionKey,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-normalization";
|
||||
import { isRecord } from "../utils.js";
|
||||
import {
|
||||
TimeoutSecondsFieldSchema,
|
||||
TrimmedNonEmptyStringFieldSchema,
|
||||
parseOptionalField,
|
||||
} from "./delivery-field-schemas.js";
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
function normalizeTrimmedStringArray(
|
||||
value: unknown,
|
||||
options?: { allowNull?: boolean },
|
||||
): string[] | null | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
const normalized = normalizeTrimmedStringList(value);
|
||||
if (normalized.length === 0 && value.length > 0) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
if (options?.allowNull && value === null) {
|
||||
return null;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeCommandEnv(value: unknown): Record<string, string> {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("command env must be an object with non-blank keys and string values");
|
||||
}
|
||||
const entries: Array<[string, string]> = [];
|
||||
for (const [rawKey, rawValue] of Object.entries(value)) {
|
||||
const key = normalizeOptionalString(rawKey);
|
||||
if (!key || typeof rawValue !== "string") {
|
||||
throw new Error("command env must be an object with non-blank keys and string values");
|
||||
}
|
||||
entries.push([key, rawValue]);
|
||||
}
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
function normalizeCommandArgv(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (value.some((entry) => typeof entry !== "string" || entry.length === 0)) {
|
||||
return undefined;
|
||||
}
|
||||
return [...value];
|
||||
}
|
||||
|
||||
function hasAgentTurnOnlyPayloadHint(payload: UnknownRecord): boolean {
|
||||
return (
|
||||
"model" in payload ||
|
||||
"fallbacks" in payload ||
|
||||
"thinking" in payload ||
|
||||
"timeoutSeconds" in payload ||
|
||||
typeof payload.lightContext === "boolean" ||
|
||||
typeof payload.allowUnsafeExternalContent === "boolean"
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeCronPayload(payload: UnknownRecord): UnknownRecord {
|
||||
const next: UnknownRecord = { ...payload };
|
||||
const kindRaw = normalizeLowercaseStringOrEmpty(next.kind);
|
||||
if (kindRaw === "agentturn") {
|
||||
next.kind = "agentTurn";
|
||||
} else if (kindRaw === "systemevent") {
|
||||
next.kind = "systemEvent";
|
||||
} else if (kindRaw === "command") {
|
||||
next.kind = "command";
|
||||
} else if (kindRaw === "script") {
|
||||
next.kind = "script";
|
||||
} else if (kindRaw) {
|
||||
next.kind = kindRaw;
|
||||
}
|
||||
if (typeof next.message === "string") {
|
||||
const trimmed = normalizeOptionalString(next.message) ?? "";
|
||||
if (trimmed) {
|
||||
next.message = trimmed;
|
||||
} else {
|
||||
next.message = "";
|
||||
}
|
||||
}
|
||||
if (typeof next.text === "string") {
|
||||
const trimmed = normalizeOptionalString(next.text) ?? "";
|
||||
if (trimmed) {
|
||||
next.text = trimmed;
|
||||
} else {
|
||||
next.text = "";
|
||||
}
|
||||
}
|
||||
if (typeof next.script === "string") {
|
||||
next.script = next.script.trim();
|
||||
}
|
||||
if ("model" in next) {
|
||||
if (next.model === null) {
|
||||
next.model = null;
|
||||
} else {
|
||||
const model = parseOptionalField(TrimmedNonEmptyStringFieldSchema, next.model);
|
||||
if (model !== undefined) {
|
||||
next.model = model;
|
||||
} else {
|
||||
delete next.model;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ("thinking" in next) {
|
||||
// Preserve an explicit null so patches can clear a stored thinking override,
|
||||
// matching the model/fallbacks/toolsAllow clear paths.
|
||||
if (next.thinking === null) {
|
||||
next.thinking = null;
|
||||
} else {
|
||||
const thinking = parseOptionalField(TrimmedNonEmptyStringFieldSchema, next.thinking);
|
||||
if (thinking !== undefined) {
|
||||
next.thinking = thinking;
|
||||
} else {
|
||||
delete next.thinking;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ("timeoutSeconds" in next) {
|
||||
const timeoutSeconds = parseOptionalField(TimeoutSecondsFieldSchema, next.timeoutSeconds);
|
||||
if (timeoutSeconds !== undefined) {
|
||||
next.timeoutSeconds = timeoutSeconds;
|
||||
} else {
|
||||
delete next.timeoutSeconds;
|
||||
}
|
||||
}
|
||||
if ("fallbacks" in next) {
|
||||
const fallbacks = normalizeTrimmedStringArray(next.fallbacks, { allowNull: true });
|
||||
if (fallbacks !== undefined) {
|
||||
next.fallbacks = fallbacks;
|
||||
} else {
|
||||
delete next.fallbacks;
|
||||
}
|
||||
}
|
||||
if ("toolsAllow" in next) {
|
||||
const toolsAllow = normalizeTrimmedStringArray(next.toolsAllow, { allowNull: true });
|
||||
if (toolsAllow !== undefined) {
|
||||
next.toolsAllow = toolsAllow;
|
||||
} else {
|
||||
delete next.toolsAllow;
|
||||
}
|
||||
}
|
||||
if ("argv" in next) {
|
||||
const argv = normalizeCommandArgv(next.argv);
|
||||
if (Array.isArray(argv) && argv.length > 0) {
|
||||
next.argv = argv;
|
||||
} else {
|
||||
delete next.argv;
|
||||
}
|
||||
}
|
||||
if ("cwd" in next) {
|
||||
const cwd = parseOptionalField(TrimmedNonEmptyStringFieldSchema, next.cwd);
|
||||
if (cwd !== undefined) {
|
||||
next.cwd = cwd;
|
||||
} else {
|
||||
delete next.cwd;
|
||||
}
|
||||
}
|
||||
if ("env" in next) {
|
||||
next.env = normalizeCommandEnv(next.env);
|
||||
}
|
||||
if ("input" in next && typeof next.input !== "string") {
|
||||
delete next.input;
|
||||
}
|
||||
if ("noOutputTimeoutSeconds" in next) {
|
||||
const noOutputTimeoutSeconds = parseOptionalField(
|
||||
TimeoutSecondsFieldSchema,
|
||||
next.noOutputTimeoutSeconds,
|
||||
);
|
||||
if (noOutputTimeoutSeconds !== undefined) {
|
||||
next.noOutputTimeoutSeconds = noOutputTimeoutSeconds;
|
||||
} else {
|
||||
delete next.noOutputTimeoutSeconds;
|
||||
}
|
||||
}
|
||||
if ("outputMaxBytes" in next) {
|
||||
const outputMaxBytes = parseOptionalField(TimeoutSecondsFieldSchema, next.outputMaxBytes);
|
||||
if (outputMaxBytes !== undefined && outputMaxBytes > 0) {
|
||||
next.outputMaxBytes = Math.floor(outputMaxBytes);
|
||||
} else {
|
||||
delete next.outputMaxBytes;
|
||||
}
|
||||
}
|
||||
if ("toolBudget" in next) {
|
||||
const toolBudget = parseOptionalField(TimeoutSecondsFieldSchema, next.toolBudget);
|
||||
if (toolBudget !== undefined && toolBudget > 0) {
|
||||
next.toolBudget = Math.floor(toolBudget);
|
||||
} else {
|
||||
delete next.toolBudget;
|
||||
}
|
||||
}
|
||||
if (
|
||||
"allowUnsafeExternalContent" in next &&
|
||||
typeof next.allowUnsafeExternalContent !== "boolean"
|
||||
) {
|
||||
delete next.allowUnsafeExternalContent;
|
||||
}
|
||||
if (!("kind" in next) && typeof next.text === "string" && hasAgentTurnOnlyPayloadHint(next)) {
|
||||
next.kind = "agentTurn";
|
||||
next.message = next.text;
|
||||
}
|
||||
if (next.kind === "systemEvent") {
|
||||
delete next.message;
|
||||
delete next.model;
|
||||
delete next.fallbacks;
|
||||
delete next.thinking;
|
||||
delete next.timeoutSeconds;
|
||||
delete next.lightContext;
|
||||
delete next.allowUnsafeExternalContent;
|
||||
delete next.argv;
|
||||
delete next.cwd;
|
||||
delete next.env;
|
||||
delete next.input;
|
||||
delete next.noOutputTimeoutSeconds;
|
||||
delete next.outputMaxBytes;
|
||||
delete next.script;
|
||||
delete next.toolBudget;
|
||||
} else if (next.kind === "agentTurn") {
|
||||
delete next.text;
|
||||
delete next.argv;
|
||||
delete next.cwd;
|
||||
delete next.env;
|
||||
delete next.input;
|
||||
delete next.noOutputTimeoutSeconds;
|
||||
delete next.outputMaxBytes;
|
||||
delete next.script;
|
||||
delete next.toolBudget;
|
||||
} else if (next.kind === "command") {
|
||||
delete next.text;
|
||||
delete next.message;
|
||||
delete next.model;
|
||||
delete next.fallbacks;
|
||||
delete next.thinking;
|
||||
delete next.lightContext;
|
||||
delete next.allowUnsafeExternalContent;
|
||||
delete next.script;
|
||||
delete next.toolBudget;
|
||||
} else if (next.kind === "script") {
|
||||
delete next.text;
|
||||
delete next.message;
|
||||
delete next.model;
|
||||
delete next.fallbacks;
|
||||
delete next.thinking;
|
||||
delete next.lightContext;
|
||||
delete next.allowUnsafeExternalContent;
|
||||
delete next.argv;
|
||||
delete next.cwd;
|
||||
delete next.env;
|
||||
delete next.input;
|
||||
delete next.noOutputTimeoutSeconds;
|
||||
delete next.outputMaxBytes;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
+4
-225
@@ -6,16 +6,11 @@ import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-normalization";
|
||||
import { sanitizeAgentId } from "../routing/session-key.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
import { shouldDefaultCronDeliveryToAnnounce } from "./delivery-defaults.js";
|
||||
import {
|
||||
TimeoutSecondsFieldSchema,
|
||||
TrimmedNonEmptyStringFieldSchema,
|
||||
parseDeliveryInput,
|
||||
parseOptionalField,
|
||||
} from "./delivery-field-schemas.js";
|
||||
import { parseDeliveryInput } from "./delivery-field-schemas.js";
|
||||
import { normalizeCronPayload } from "./normalize-payload.js";
|
||||
import { parseAbsoluteTimeMs } from "./parse.js";
|
||||
import { coerceFiniteScheduleNumber } from "./schedule-number.js";
|
||||
import { inferCronJobName } from "./service/normalize.js";
|
||||
@@ -38,59 +33,6 @@ const DEFAULT_OPTIONS: NormalizeOptions = {
|
||||
applyDefaults: false,
|
||||
};
|
||||
|
||||
function normalizeTrimmedStringArray(
|
||||
value: unknown,
|
||||
options?: { allowNull?: boolean },
|
||||
): string[] | null | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
const normalized = normalizeTrimmedStringList(value);
|
||||
if (normalized.length === 0 && value.length > 0) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
if (options?.allowNull && value === null) {
|
||||
return null;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeCommandEnv(value: unknown): Record<string, string> {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("command env must be an object with non-blank keys and string values");
|
||||
}
|
||||
const entries: Array<[string, string]> = [];
|
||||
for (const [rawKey, rawValue] of Object.entries(value)) {
|
||||
const key = normalizeOptionalString(rawKey);
|
||||
if (!key || typeof rawValue !== "string") {
|
||||
throw new Error("command env must be an object with non-blank keys and string values");
|
||||
}
|
||||
entries.push([key, rawValue]);
|
||||
}
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
function normalizeCommandArgv(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (value.some((entry) => typeof entry !== "string" || entry.length === 0)) {
|
||||
return undefined;
|
||||
}
|
||||
return [...value];
|
||||
}
|
||||
|
||||
function hasAgentTurnOnlyPayloadHint(payload: UnknownRecord): boolean {
|
||||
return (
|
||||
"model" in payload ||
|
||||
"fallbacks" in payload ||
|
||||
"thinking" in payload ||
|
||||
"timeoutSeconds" in payload ||
|
||||
typeof payload.lightContext === "boolean" ||
|
||||
typeof payload.allowUnsafeExternalContent === "boolean"
|
||||
);
|
||||
}
|
||||
|
||||
function coerceSchedule(schedule: UnknownRecord) {
|
||||
const next: UnknownRecord = { ...schedule };
|
||||
const rawKind = normalizeLowercaseStringOrEmpty(schedule.kind);
|
||||
@@ -188,169 +130,6 @@ function coerceSchedule(schedule: UnknownRecord) {
|
||||
return next;
|
||||
}
|
||||
|
||||
function coercePayload(payload: UnknownRecord) {
|
||||
const next: UnknownRecord = { ...payload };
|
||||
const kindRaw = normalizeLowercaseStringOrEmpty(next.kind);
|
||||
if (kindRaw === "agentturn") {
|
||||
next.kind = "agentTurn";
|
||||
} else if (kindRaw === "systemevent") {
|
||||
next.kind = "systemEvent";
|
||||
} else if (kindRaw === "command") {
|
||||
next.kind = "command";
|
||||
} else if (kindRaw) {
|
||||
next.kind = kindRaw;
|
||||
}
|
||||
if (typeof next.message === "string") {
|
||||
const trimmed = normalizeOptionalString(next.message) ?? "";
|
||||
if (trimmed) {
|
||||
next.message = trimmed;
|
||||
} else {
|
||||
next.message = "";
|
||||
}
|
||||
}
|
||||
if (typeof next.text === "string") {
|
||||
const trimmed = normalizeOptionalString(next.text) ?? "";
|
||||
if (trimmed) {
|
||||
next.text = trimmed;
|
||||
} else {
|
||||
next.text = "";
|
||||
}
|
||||
}
|
||||
if ("model" in next) {
|
||||
if (next.model === null) {
|
||||
next.model = null;
|
||||
} else {
|
||||
const model = parseOptionalField(TrimmedNonEmptyStringFieldSchema, next.model);
|
||||
if (model !== undefined) {
|
||||
next.model = model;
|
||||
} else {
|
||||
delete next.model;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ("thinking" in next) {
|
||||
// Preserve an explicit null so patches can clear a stored thinking override,
|
||||
// matching the model/fallbacks/toolsAllow clear paths.
|
||||
if (next.thinking === null) {
|
||||
next.thinking = null;
|
||||
} else {
|
||||
const thinking = parseOptionalField(TrimmedNonEmptyStringFieldSchema, next.thinking);
|
||||
if (thinking !== undefined) {
|
||||
next.thinking = thinking;
|
||||
} else {
|
||||
delete next.thinking;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ("timeoutSeconds" in next) {
|
||||
const timeoutSeconds = parseOptionalField(TimeoutSecondsFieldSchema, next.timeoutSeconds);
|
||||
if (timeoutSeconds !== undefined) {
|
||||
next.timeoutSeconds = timeoutSeconds;
|
||||
} else {
|
||||
delete next.timeoutSeconds;
|
||||
}
|
||||
}
|
||||
if ("fallbacks" in next) {
|
||||
const fallbacks = normalizeTrimmedStringArray(next.fallbacks, { allowNull: true });
|
||||
if (fallbacks !== undefined) {
|
||||
next.fallbacks = fallbacks;
|
||||
} else {
|
||||
delete next.fallbacks;
|
||||
}
|
||||
}
|
||||
if ("toolsAllow" in next) {
|
||||
const toolsAllow = normalizeTrimmedStringArray(next.toolsAllow, { allowNull: true });
|
||||
if (toolsAllow !== undefined) {
|
||||
next.toolsAllow = toolsAllow;
|
||||
} else {
|
||||
delete next.toolsAllow;
|
||||
}
|
||||
}
|
||||
if ("argv" in next) {
|
||||
const argv = normalizeCommandArgv(next.argv);
|
||||
if (Array.isArray(argv) && argv.length > 0) {
|
||||
next.argv = argv;
|
||||
} else {
|
||||
delete next.argv;
|
||||
}
|
||||
}
|
||||
if ("cwd" in next) {
|
||||
const cwd = parseOptionalField(TrimmedNonEmptyStringFieldSchema, next.cwd);
|
||||
if (cwd !== undefined) {
|
||||
next.cwd = cwd;
|
||||
} else {
|
||||
delete next.cwd;
|
||||
}
|
||||
}
|
||||
if ("env" in next) {
|
||||
next.env = normalizeCommandEnv(next.env);
|
||||
}
|
||||
if ("input" in next && typeof next.input !== "string") {
|
||||
delete next.input;
|
||||
}
|
||||
if ("noOutputTimeoutSeconds" in next) {
|
||||
const noOutputTimeoutSeconds = parseOptionalField(
|
||||
TimeoutSecondsFieldSchema,
|
||||
next.noOutputTimeoutSeconds,
|
||||
);
|
||||
if (noOutputTimeoutSeconds !== undefined) {
|
||||
next.noOutputTimeoutSeconds = noOutputTimeoutSeconds;
|
||||
} else {
|
||||
delete next.noOutputTimeoutSeconds;
|
||||
}
|
||||
}
|
||||
if ("outputMaxBytes" in next) {
|
||||
const outputMaxBytes = parseOptionalField(TimeoutSecondsFieldSchema, next.outputMaxBytes);
|
||||
if (outputMaxBytes !== undefined && outputMaxBytes > 0) {
|
||||
next.outputMaxBytes = Math.floor(outputMaxBytes);
|
||||
} else {
|
||||
delete next.outputMaxBytes;
|
||||
}
|
||||
}
|
||||
if (
|
||||
"allowUnsafeExternalContent" in next &&
|
||||
typeof next.allowUnsafeExternalContent !== "boolean"
|
||||
) {
|
||||
delete next.allowUnsafeExternalContent;
|
||||
}
|
||||
if (!("kind" in next) && typeof next.text === "string" && hasAgentTurnOnlyPayloadHint(next)) {
|
||||
next.kind = "agentTurn";
|
||||
next.message = next.text;
|
||||
}
|
||||
if (next.kind === "systemEvent") {
|
||||
delete next.message;
|
||||
delete next.model;
|
||||
delete next.fallbacks;
|
||||
delete next.thinking;
|
||||
delete next.timeoutSeconds;
|
||||
delete next.lightContext;
|
||||
delete next.allowUnsafeExternalContent;
|
||||
delete next.argv;
|
||||
delete next.cwd;
|
||||
delete next.env;
|
||||
delete next.input;
|
||||
delete next.noOutputTimeoutSeconds;
|
||||
delete next.outputMaxBytes;
|
||||
} else if (next.kind === "agentTurn") {
|
||||
delete next.text;
|
||||
delete next.argv;
|
||||
delete next.cwd;
|
||||
delete next.env;
|
||||
delete next.input;
|
||||
delete next.noOutputTimeoutSeconds;
|
||||
delete next.outputMaxBytes;
|
||||
} else if (next.kind === "command") {
|
||||
delete next.text;
|
||||
delete next.message;
|
||||
delete next.model;
|
||||
delete next.fallbacks;
|
||||
delete next.thinking;
|
||||
delete next.lightContext;
|
||||
delete next.allowUnsafeExternalContent;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function coerceTrigger(trigger: UnknownRecord): UnknownRecord {
|
||||
const script = typeof trigger.script === "string" ? trigger.script.trim() : "";
|
||||
const once = parseBoolean(trigger.once);
|
||||
@@ -619,7 +398,7 @@ export function normalizeCronJobInput(
|
||||
}
|
||||
|
||||
if (isRecord(base.payload)) {
|
||||
next.payload = coercePayload(base.payload);
|
||||
next.payload = normalizeCronPayload(base.payload);
|
||||
}
|
||||
|
||||
if ("trigger" in base) {
|
||||
@@ -671,7 +450,7 @@ export function normalizeCronJobInput(
|
||||
// turns isolate by default to avoid unbounded token accumulation.
|
||||
if (kind === "systemEvent") {
|
||||
next.sessionTarget = "main";
|
||||
} else if (kind === "agentTurn" || kind === "command") {
|
||||
} else if (kind === "agentTurn" || kind === "command" || kind === "script") {
|
||||
next.sessionTarget = "isolated";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,12 @@ export function getInvalidPersistedCronJobReason(
|
||||
}
|
||||
const payloadRecord = payload as Record<string, unknown>;
|
||||
const payloadKind = payloadRecord.kind;
|
||||
if (payloadKind !== "systemEvent" && payloadKind !== "agentTurn" && payloadKind !== "command") {
|
||||
if (
|
||||
payloadKind !== "systemEvent" &&
|
||||
payloadKind !== "agentTurn" &&
|
||||
payloadKind !== "command" &&
|
||||
payloadKind !== "script"
|
||||
) {
|
||||
return "invalid-payload";
|
||||
}
|
||||
if (payloadKind === "systemEvent") {
|
||||
@@ -110,5 +115,11 @@ export function getInvalidPersistedCronJobReason(
|
||||
return "invalid-payload";
|
||||
}
|
||||
}
|
||||
if (payloadKind === "script") {
|
||||
const script = payloadRecord.script;
|
||||
if (typeof script !== "string" || script.trim().length === 0) {
|
||||
return "invalid-payload";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -16,4 +16,27 @@ describe("toPublicCronJob", () => {
|
||||
expect(publicJob.state.pacedNextRunAtMs).toBeUndefined();
|
||||
expect(job.state.pacedNextRunAtMs).toBe(2_000);
|
||||
});
|
||||
|
||||
it("projects script payload fields without exposing scheduler-only state", () => {
|
||||
const job = makeCronJob({
|
||||
sessionTarget: "isolated",
|
||||
payload: {
|
||||
kind: "script",
|
||||
script: "return { notify: 'done' }",
|
||||
timeoutSeconds: 300,
|
||||
toolBudget: 50,
|
||||
},
|
||||
state: { triggerState: { revision: 1 }, pacedNextRunAtMs: 2_000 },
|
||||
});
|
||||
|
||||
expect(toPublicCronJob(job)).toMatchObject({
|
||||
payload: {
|
||||
kind: "script",
|
||||
script: "return { notify: 'done' }",
|
||||
timeoutSeconds: 300,
|
||||
toolBudget: 50,
|
||||
},
|
||||
state: { triggerState: { revision: 1 } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { CronPayload } from "./types.js";
|
||||
|
||||
export const DEFAULT_CRON_SCRIPT_TIMEOUT_SECONDS = 300;
|
||||
export const MAX_CRON_SCRIPT_TIMEOUT_SECONDS = 900;
|
||||
export const DEFAULT_CRON_SCRIPT_TOOL_BUDGET = 50;
|
||||
export const MAX_CRON_SCRIPT_TOOL_BUDGET = 200;
|
||||
|
||||
function clampPositiveInteger(value: unknown, fallback: number, maximum: number): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.min(maximum, Math.max(1, Math.floor(value)));
|
||||
}
|
||||
|
||||
/** Applies the persisted defaults and hard caps for unattended script payloads. */
|
||||
export function normalizeCronScriptPayload(
|
||||
payload: Extract<CronPayload, { kind: "script" }>,
|
||||
): Extract<CronPayload, { kind: "script" }> {
|
||||
return {
|
||||
...payload,
|
||||
script: payload.script.trim(),
|
||||
timeoutSeconds: clampPositiveInteger(
|
||||
payload.timeoutSeconds,
|
||||
DEFAULT_CRON_SCRIPT_TIMEOUT_SECONDS,
|
||||
MAX_CRON_SCRIPT_TIMEOUT_SECONDS,
|
||||
),
|
||||
toolBudget: clampPositiveInteger(
|
||||
payload.toolBudget,
|
||||
DEFAULT_CRON_SCRIPT_TOOL_BUDGET,
|
||||
MAX_CRON_SCRIPT_TOOL_BUDGET,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -817,15 +817,131 @@ describe("applyJobPatch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function createMockState(now: number, opts?: { defaultAgentId?: string }): CronServiceState {
|
||||
function createMockState(
|
||||
now: number,
|
||||
opts?: { defaultAgentId?: string; scriptPayloadsEnabled?: boolean },
|
||||
): CronServiceState {
|
||||
return {
|
||||
deps: {
|
||||
nowMs: () => now,
|
||||
defaultAgentId: opts?.defaultAgentId,
|
||||
cronConfig:
|
||||
opts?.scriptPayloadsEnabled === undefined
|
||||
? undefined
|
||||
: { triggers: { enabled: opts.scriptPayloadsEnabled } },
|
||||
},
|
||||
} as unknown as CronServiceState;
|
||||
}
|
||||
|
||||
describe("script payload validation", () => {
|
||||
const now = Date.parse("2026-07-18T12:00:00.000Z");
|
||||
const input = (sessionTarget: CronJob["sessionTarget"] = "isolated") => ({
|
||||
name: "script-job",
|
||||
enabled: true,
|
||||
schedule: { kind: "every" as const, everyMs: 60_000 },
|
||||
sessionTarget,
|
||||
wakeMode: "now" as const,
|
||||
payload: {
|
||||
kind: "script" as const,
|
||||
script: "return { state: { count: 1 } }",
|
||||
timeoutSeconds: 4_000,
|
||||
toolBudget: 4_000,
|
||||
},
|
||||
});
|
||||
|
||||
it("rejects creation while the trigger gate is disabled", () => {
|
||||
expect(() =>
|
||||
createJob(createMockState(now, { scriptPayloadsEnabled: false }), input()),
|
||||
).toThrow("cron.triggers.enabled=true");
|
||||
});
|
||||
|
||||
it.each(["current", "session:reporting"] as const)(
|
||||
"rejects the %s session target",
|
||||
(sessionTarget) => {
|
||||
expect(() =>
|
||||
createJob(createMockState(now, { scriptPayloadsEnabled: true }), input(sessionTarget)),
|
||||
).toThrow('sessionTarget="main" or "isolated"');
|
||||
},
|
||||
);
|
||||
|
||||
it("persists defaults and hard caps when creation is enabled", () => {
|
||||
const job = createJob(createMockState(now, { scriptPayloadsEnabled: true }), input());
|
||||
|
||||
expect(job.payload).toMatchObject({
|
||||
kind: "script",
|
||||
timeoutSeconds: 900,
|
||||
toolBudget: 200,
|
||||
});
|
||||
expect(job.delivery).toEqual({ mode: "announce" });
|
||||
});
|
||||
|
||||
it("allows a main-session script for a named agent", () => {
|
||||
const job = createJob(
|
||||
createMockState(now, { defaultAgentId: "main", scriptPayloadsEnabled: true }),
|
||||
{ ...input("main"), agentId: "reporter" },
|
||||
);
|
||||
|
||||
expect(job.sessionTarget).toBe("main");
|
||||
expect(job.agentId).toBe("reporter");
|
||||
});
|
||||
|
||||
it("rejects condition triggers because both script kinds own trigger.state", () => {
|
||||
const state = createMockState(now, { scriptPayloadsEnabled: true });
|
||||
expect(() =>
|
||||
createJob(state, {
|
||||
...input(),
|
||||
trigger: { script: "return { fire: true }" },
|
||||
}),
|
||||
).toThrow("cannot be combined with a condition trigger");
|
||||
|
||||
const job = createJob(state, input());
|
||||
expect(() =>
|
||||
applyJobPatch(
|
||||
job,
|
||||
{ trigger: { script: "return { fire: true }" } },
|
||||
{ cronConfig: { triggers: { enabled: true } } },
|
||||
),
|
||||
).toThrow("cannot be combined with a condition trigger");
|
||||
});
|
||||
|
||||
it("rejects converting a job to script while disabled and caps enabled patches", () => {
|
||||
const base = createJob(createMockState(now), {
|
||||
name: "agent-job",
|
||||
enabled: true,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "run" },
|
||||
});
|
||||
expect(() =>
|
||||
applyJobPatch(
|
||||
structuredClone(base),
|
||||
{ payload: { kind: "script", script: "return {}" } },
|
||||
{ cronConfig: { triggers: { enabled: false } } },
|
||||
),
|
||||
).toThrow("cron.triggers.enabled=true");
|
||||
|
||||
const patched = structuredClone(base);
|
||||
applyJobPatch(
|
||||
patched,
|
||||
{
|
||||
payload: {
|
||||
kind: "script",
|
||||
script: "return {}",
|
||||
timeoutSeconds: 9_000,
|
||||
toolBudget: 9_000,
|
||||
},
|
||||
},
|
||||
{ cronConfig: { triggers: { enabled: true } } },
|
||||
);
|
||||
expect(patched.payload).toMatchObject({
|
||||
kind: "script",
|
||||
timeoutSeconds: 900,
|
||||
toolBudget: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createJob rejects sessionTarget main for non-default agents", () => {
|
||||
const now = Date.parse("2026-02-28T12:00:00.000Z");
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ describe("resolveInitialCronDelivery", () => {
|
||||
const payloads: CronJobCreate["payload"][] = [
|
||||
{ kind: "agentTurn", message: "hello" },
|
||||
{ kind: "command", argv: ["echo", "hello"] },
|
||||
{ kind: "script", script: "return { notify: 'hello' }" },
|
||||
];
|
||||
for (const payload of payloads) {
|
||||
expect(resolveInitialCronDelivery(createInput({ sessionTarget, payload }))).toEqual({
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
computeNextRunAtMs,
|
||||
computePreviousRunAtMs,
|
||||
} from "../schedule.js";
|
||||
import { normalizeCronScriptPayload } from "../script-payload.js";
|
||||
import { assertSafeCronSessionTargetId } from "../session-target.js";
|
||||
import {
|
||||
normalizeCronStaggerMs,
|
||||
@@ -315,12 +316,50 @@ export function assertSupportedJobSpec(job: Pick<CronJob, "sessionTarget" | "pay
|
||||
if (job.sessionTarget.startsWith("session:")) {
|
||||
assertSafeCronSessionTargetId(job.sessionTarget.slice(8));
|
||||
}
|
||||
if (job.sessionTarget === "main" && job.payload.kind !== "systemEvent") {
|
||||
throw new Error('main cron jobs require payload.kind="systemEvent"');
|
||||
if (
|
||||
job.sessionTarget === "main" &&
|
||||
job.payload.kind !== "systemEvent" &&
|
||||
job.payload.kind !== "script"
|
||||
) {
|
||||
throw new Error('main cron jobs require payload.kind="systemEvent" or "script"');
|
||||
}
|
||||
if (isIsolatedLike && job.payload.kind !== "agentTurn" && job.payload.kind !== "command") {
|
||||
if (
|
||||
job.payload.kind === "script" &&
|
||||
job.sessionTarget !== "main" &&
|
||||
job.sessionTarget !== "isolated"
|
||||
) {
|
||||
throw new Error('script cron jobs require sessionTarget="main" or "isolated"');
|
||||
}
|
||||
if (
|
||||
isIsolatedLike &&
|
||||
job.payload.kind !== "agentTurn" &&
|
||||
job.payload.kind !== "command" &&
|
||||
!(job.sessionTarget === "isolated" && job.payload.kind === "script")
|
||||
) {
|
||||
throw new Error(
|
||||
'isolated/current/session cron jobs require payload.kind="agentTurn" or "command"',
|
||||
'isolated cron jobs require payload.kind="agentTurn", "command", or "script"; script payloads do not support current/session targets',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertScriptPayloadSupport(
|
||||
job: Pick<CronJob, "payload" | "trigger">,
|
||||
opts?: { cronConfig?: CronConfig; requireEnabled?: boolean },
|
||||
) {
|
||||
if (job.payload.kind !== "script") {
|
||||
return;
|
||||
}
|
||||
if (!job.payload.script.trim()) {
|
||||
throw new Error("cron script payload must not be empty");
|
||||
}
|
||||
if (job.trigger) {
|
||||
// Both script kinds expose trigger.state, so composing them would give one
|
||||
// persisted state slot two owners and make the next trigger run ambiguous.
|
||||
throw new Error("cron script payloads cannot be combined with a condition trigger");
|
||||
}
|
||||
if (opts?.requireEnabled && opts.cronConfig?.triggers?.enabled !== true) {
|
||||
throw new Error(
|
||||
"cron script payloads are disabled; set cron.triggers.enabled=true to allow unattended scripts",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -357,7 +396,7 @@ function assertCronExpressionSatisfiable(job: CronJob, nowMs: number) {
|
||||
}
|
||||
|
||||
function assertMainSessionAgentId(
|
||||
job: Pick<CronJob, "sessionTarget" | "agentId">,
|
||||
job: Pick<CronJob, "sessionTarget" | "agentId" | "payload">,
|
||||
defaultAgentId: string | undefined,
|
||||
) {
|
||||
if (job.sessionTarget !== "main") {
|
||||
@@ -366,6 +405,9 @@ function assertMainSessionAgentId(
|
||||
if (!job.agentId) {
|
||||
return;
|
||||
}
|
||||
if (job.payload.kind === "script") {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeAgentId(job.agentId);
|
||||
const normalizedDefault = normalizeAgentId(defaultAgentId);
|
||||
if (normalized !== normalizedDefault) {
|
||||
@@ -971,7 +1013,10 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
|
||||
...(input.pacing !== undefined ? { pacing: structuredClone(input.pacing) } : {}),
|
||||
sessionTarget: input.sessionTarget,
|
||||
wakeMode: input.wakeMode,
|
||||
payload: input.payload,
|
||||
payload:
|
||||
input.payload.kind === "script"
|
||||
? normalizeCronScriptPayload(structuredClone(input.payload))
|
||||
: input.payload,
|
||||
delivery: resolveInitialCronDelivery(input),
|
||||
failureAlert: input.failureAlert,
|
||||
...(input.trigger ? { trigger: structuredClone(input.trigger) } : {}),
|
||||
@@ -987,6 +1032,10 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
|
||||
cronConfig: state.deps.cronConfig,
|
||||
requireEnabled: job.trigger !== undefined,
|
||||
});
|
||||
assertScriptPayloadSupport(job, {
|
||||
cronConfig: state.deps.cronConfig,
|
||||
requireEnabled: job.payload.kind === "script",
|
||||
});
|
||||
assertMainSessionAgentId(job, state.deps.defaultAgentId);
|
||||
assertDeliverySupport(job);
|
||||
assertFailureDestinationSupport(job);
|
||||
@@ -1089,6 +1138,9 @@ export function applyJobPatch(
|
||||
}
|
||||
if (patch.payload) {
|
||||
job.payload = mergeCronPayload(job.payload, patch.payload);
|
||||
if (job.payload.kind === "script") {
|
||||
job.payload = normalizeCronScriptPayload(job.payload);
|
||||
}
|
||||
}
|
||||
if (patch.delivery) {
|
||||
const implicitMode = resolveCronDeliveryPlan(job).mode;
|
||||
@@ -1132,6 +1184,10 @@ export function applyJobPatch(
|
||||
cronConfig: opts?.cronConfig,
|
||||
requireEnabled: patch.trigger !== null && patch.trigger !== undefined,
|
||||
});
|
||||
assertScriptPayloadSupport(job, {
|
||||
cronConfig: opts?.cronConfig,
|
||||
requireEnabled: patch.payload?.kind === "script",
|
||||
});
|
||||
assertMainSessionAgentId(job, opts?.defaultAgentId);
|
||||
assertDeliverySupport(job);
|
||||
assertFailureDestinationSupport(job);
|
||||
@@ -1199,7 +1255,10 @@ export function applyDeclarativeJobSpec(
|
||||
} else {
|
||||
delete job.pacing;
|
||||
}
|
||||
job.payload = structuredClone(input.payload);
|
||||
job.payload =
|
||||
input.payload.kind === "script"
|
||||
? normalizeCronScriptPayload(structuredClone(input.payload))
|
||||
: structuredClone(input.payload);
|
||||
if (input.trigger) {
|
||||
job.trigger = structuredClone(input.trigger);
|
||||
} else {
|
||||
@@ -1218,6 +1277,10 @@ export function applyDeclarativeJobSpec(
|
||||
cronConfig: opts.cronConfig,
|
||||
requireEnabled: input.trigger !== undefined,
|
||||
});
|
||||
assertScriptPayloadSupport(job, {
|
||||
cronConfig: opts.cronConfig,
|
||||
requireEnabled: input.payload.kind === "script",
|
||||
});
|
||||
|
||||
assertSupportedJobSpec(job);
|
||||
if (job.pacing !== undefined) {
|
||||
|
||||
@@ -101,6 +101,23 @@ export function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch)
|
||||
applyToolsAllowPatch(next, patch, existing);
|
||||
return next;
|
||||
}
|
||||
if (patch.kind === "script") {
|
||||
if (existing.kind !== "script") {
|
||||
return buildPayloadFromPatch(patch);
|
||||
}
|
||||
const next: Extract<CronPayload, { kind: "script" }> = { ...existing };
|
||||
if (typeof patch.script === "string") {
|
||||
next.script = patch.script;
|
||||
}
|
||||
if (typeof patch.timeoutSeconds === "number") {
|
||||
next.timeoutSeconds = patch.timeoutSeconds;
|
||||
}
|
||||
if (typeof patch.toolBudget === "number") {
|
||||
next.toolBudget = patch.toolBudget;
|
||||
}
|
||||
applyToolsAllowPatch(next, patch, existing);
|
||||
return next;
|
||||
}
|
||||
|
||||
if (existing.kind !== "agentTurn") {
|
||||
return buildPayloadFromPatch(patch);
|
||||
@@ -169,6 +186,20 @@ function buildPayloadFromPatch(patch: CronPayloadPatch): CronPayload {
|
||||
return next;
|
||||
}
|
||||
|
||||
if (patch.kind === "script") {
|
||||
if (typeof patch.script !== "string" || patch.script.trim().length === 0) {
|
||||
throw new Error('cron.update payload.kind="script" requires script');
|
||||
}
|
||||
const next: Extract<CronPayload, { kind: "script" }> = {
|
||||
kind: "script",
|
||||
script: patch.script,
|
||||
timeoutSeconds: patch.timeoutSeconds,
|
||||
toolBudget: patch.toolBudget,
|
||||
};
|
||||
applyToolsAllowPatch(next, patch);
|
||||
return next;
|
||||
}
|
||||
|
||||
if (typeof patch.message !== "string" || patch.message.length === 0) {
|
||||
throw new Error('cron.update payload.kind="agentTurn" requires message');
|
||||
}
|
||||
|
||||
@@ -180,6 +180,19 @@ export type CronServiceDeps = {
|
||||
delivery?: CronDeliveryTrace;
|
||||
} & CronRunOutcome
|
||||
>;
|
||||
runScriptJob?: (params: { job: CronJob; abortSignal?: AbortSignal }) => Promise<
|
||||
{
|
||||
delivered?: boolean;
|
||||
deliveryAttempted?: boolean;
|
||||
deliveryError?: string;
|
||||
delivery?: CronDeliveryTrace;
|
||||
notify?: string;
|
||||
wake?: "now" | "next-heartbeat";
|
||||
stateChanged?: boolean;
|
||||
state?: unknown;
|
||||
nextCheck?: CronNextCheckProposal;
|
||||
} & CronRunOutcome
|
||||
>;
|
||||
cleanupTimedOutAgentRun?: (params: {
|
||||
job: CronJob;
|
||||
timeoutMs: number;
|
||||
|
||||
@@ -48,6 +48,13 @@ describe("timeout-policy", () => {
|
||||
expect(timeout).toBe(1_900);
|
||||
});
|
||||
|
||||
it("uses a script payload timeout as the outer cron watchdog", () => {
|
||||
const timeout = resolveCronJobTimeoutMs(
|
||||
makeJob({ kind: "script", script: "return {}", timeoutSeconds: 300 }),
|
||||
);
|
||||
expect(timeout).toBe(300_000);
|
||||
});
|
||||
|
||||
it("caps oversized explicit timeoutSeconds at the timer-safe ceiling", () => {
|
||||
const timeout = resolveCronJobTimeoutMs(
|
||||
makeJob({ kind: "agentTurn", message: "hi", timeoutSeconds: Number.MAX_SAFE_INTEGER }),
|
||||
|
||||
@@ -18,7 +18,9 @@ const AGENT_TURN_SAFETY_TIMEOUT_MS = 60 * 60_000; // 60 minutes
|
||||
/** Resolves the wall-clock timeout for a cron job, including explicit detached-run overrides. */
|
||||
export function resolveCronJobTimeoutMs(job: CronJob): number | undefined {
|
||||
const configuredTimeoutMs =
|
||||
(job.payload.kind === "agentTurn" || job.payload.kind === "command") &&
|
||||
(job.payload.kind === "agentTurn" ||
|
||||
job.payload.kind === "command" ||
|
||||
job.payload.kind === "script") &&
|
||||
typeof job.payload.timeoutSeconds === "number"
|
||||
? (finiteSecondsToTimerSafeMilliseconds(job.payload.timeoutSeconds) ?? 0)
|
||||
: undefined;
|
||||
|
||||
@@ -66,6 +66,32 @@ function createDueCommandJob(params: { now: number }): CronJob {
|
||||
};
|
||||
}
|
||||
|
||||
function createDueScriptJob(params: {
|
||||
now: number;
|
||||
sessionTarget?: "main" | "isolated";
|
||||
pacing?: CronJob["pacing"];
|
||||
}): CronJob {
|
||||
return {
|
||||
id: "script-job",
|
||||
agentId: "finn",
|
||||
name: "script job",
|
||||
enabled: true,
|
||||
createdAtMs: params.now - 60_000,
|
||||
updatedAtMs: params.now - 60_000,
|
||||
schedule: { kind: "every", everyMs: 60_000, anchorMs: params.now - 60_000 },
|
||||
pacing: params.pacing,
|
||||
sessionTarget: params.sessionTarget ?? "isolated",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "script",
|
||||
script: "return { notify: 'done' }",
|
||||
timeoutSeconds: 300,
|
||||
toolBudget: 50,
|
||||
},
|
||||
state: { nextRunAtMs: params.now - 1, triggerState: { revision: 1 } },
|
||||
};
|
||||
}
|
||||
|
||||
function findCronTaskByBaseRunId(baseRunId: string) {
|
||||
return (
|
||||
findTaskByRunId(baseRunId) ??
|
||||
@@ -356,6 +382,193 @@ describe("cron service timer seam coverage", () => {
|
||||
expect(runIsolatedAgentJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records an execution error when script payloads are disabled", async () => {
|
||||
const { storePath } = await makeStorePath();
|
||||
const now = Date.parse("2026-07-18T12:00:00.000Z");
|
||||
const runScriptJob = vi.fn(async () => ({ status: "ok" as const }));
|
||||
const state = createCronServiceState({
|
||||
storePath,
|
||||
cronEnabled: true,
|
||||
cronConfig: { triggers: { enabled: false } },
|
||||
log: logger,
|
||||
nowMs: () => now,
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
requestHeartbeat: vi.fn(),
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
|
||||
runScriptJob,
|
||||
});
|
||||
|
||||
await expect(executeJobCore(state, createDueScriptJob({ now }))).resolves.toMatchObject({
|
||||
status: "error",
|
||||
error: expect.stringContaining("cron.triggers.enabled=true"),
|
||||
});
|
||||
expect(runScriptJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["now", "immediate"],
|
||||
["next-heartbeat", "event"],
|
||||
] as const)("turns a main script notify and %s wake into one event", async (wake, intent) => {
|
||||
const { storePath } = await makeStorePath();
|
||||
const now = Date.parse("2026-07-18T12:00:00.000Z");
|
||||
const enqueueSystemEvent = vi.fn();
|
||||
const requestHeartbeat = vi.fn();
|
||||
const job = createDueScriptJob({ now, sessionTarget: "main" });
|
||||
const state = createCronServiceState({
|
||||
storePath,
|
||||
cronEnabled: true,
|
||||
cronConfig: { triggers: { enabled: true } },
|
||||
log: logger,
|
||||
nowMs: () => now,
|
||||
enqueueSystemEvent,
|
||||
requestHeartbeat,
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
|
||||
runScriptJob: vi.fn(async () => ({
|
||||
status: "ok" as const,
|
||||
notify: "queue changed",
|
||||
wake,
|
||||
})),
|
||||
});
|
||||
|
||||
await expect(executeJobCore(state, job)).resolves.toMatchObject({
|
||||
status: "ok",
|
||||
summary: "queue changed",
|
||||
});
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledExactlyOnceWith("queue changed", {
|
||||
agentId: "finn",
|
||||
contextKey: "cron:script-job:script",
|
||||
});
|
||||
expect(requestHeartbeat).toHaveBeenCalledExactlyOnceWith({
|
||||
source: "cron",
|
||||
intent,
|
||||
reason: "cron:script-job:script",
|
||||
agentId: "finn",
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers nothing and enqueues nothing when notify and wake are absent", async () => {
|
||||
const { storePath } = await makeStorePath();
|
||||
const now = Date.parse("2026-07-18T12:00:00.000Z");
|
||||
const enqueueSystemEvent = vi.fn();
|
||||
const requestHeartbeat = vi.fn();
|
||||
const state = createCronServiceState({
|
||||
storePath,
|
||||
cronEnabled: true,
|
||||
cronConfig: { triggers: { enabled: true } },
|
||||
log: logger,
|
||||
nowMs: () => now,
|
||||
enqueueSystemEvent,
|
||||
requestHeartbeat,
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
|
||||
runScriptJob: vi.fn(async () => ({
|
||||
status: "ok" as const,
|
||||
stateChanged: true,
|
||||
state: { revision: 2 },
|
||||
delivered: false,
|
||||
deliveryAttempted: false,
|
||||
})),
|
||||
});
|
||||
|
||||
await expect(
|
||||
executeJobCore(state, createDueScriptJob({ now, sessionTarget: "main" })),
|
||||
).resolves.toMatchObject({ status: "ok", scriptStateChanged: true });
|
||||
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
expect(requestHeartbeat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects nextCheck without pacing before applying state", async () => {
|
||||
const { storePath } = await makeStorePath();
|
||||
const now = Date.parse("2026-07-18T12:00:00.000Z");
|
||||
const state = createCronServiceState({
|
||||
storePath,
|
||||
cronEnabled: true,
|
||||
cronConfig: { triggers: { enabled: true } },
|
||||
log: logger,
|
||||
nowMs: () => now,
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
requestHeartbeat: vi.fn(),
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
|
||||
runScriptJob: vi.fn(async () => ({
|
||||
status: "ok" as const,
|
||||
stateChanged: true,
|
||||
state: { revision: 2 },
|
||||
nextCheck: { delayMs: 5_000 },
|
||||
})),
|
||||
});
|
||||
|
||||
await expect(executeJobCore(state, createDueScriptJob({ now }))).resolves.toEqual({
|
||||
status: "error",
|
||||
error: "cron script payload returned nextCheck, but this job has no pacing bounds",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["ok", { status: "ok" as const, stateChanged: true, state: { revision: 2 } }, 2, 0],
|
||||
[
|
||||
"error",
|
||||
{
|
||||
status: "error" as const,
|
||||
error: "script threw",
|
||||
stateChanged: true,
|
||||
state: { revision: 2 },
|
||||
},
|
||||
1,
|
||||
1,
|
||||
],
|
||||
] as const)(
|
||||
"persists script state on %s runs only",
|
||||
async (_label, outcome, revision, errors) => {
|
||||
const { storePath } = await makeStorePath();
|
||||
const now = Date.parse("2026-07-18T12:00:00.000Z");
|
||||
const job = createDueScriptJob({ now });
|
||||
await writeCronStoreSnapshot({ storePath, jobs: [job] });
|
||||
const state = createCronServiceState({
|
||||
storePath,
|
||||
cronEnabled: true,
|
||||
cronConfig: { triggers: { enabled: true } },
|
||||
log: logger,
|
||||
nowMs: () => now,
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
requestHeartbeat: vi.fn(),
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
|
||||
runScriptJob: vi.fn(async () => outcome),
|
||||
});
|
||||
|
||||
await onTimer(state);
|
||||
|
||||
const stored = await loadCronStore(storePath);
|
||||
expect(stored.jobs[0]?.state.triggerState).toEqual({ revision });
|
||||
expect(stored.jobs[0]?.state.consecutiveErrors ?? 0).toBe(errors);
|
||||
},
|
||||
);
|
||||
|
||||
it("clamps a script nextCheck through the shared pacing path", async () => {
|
||||
const { storePath } = await makeStorePath();
|
||||
const now = Date.parse("2026-07-18T12:00:00.000Z");
|
||||
const job = createDueScriptJob({ now, pacing: { min: "15m", max: "4h" } });
|
||||
await writeCronStoreSnapshot({ storePath, jobs: [job] });
|
||||
const state = createCronServiceState({
|
||||
storePath,
|
||||
cronEnabled: true,
|
||||
cronConfig: { triggers: { enabled: true } },
|
||||
log: logger,
|
||||
nowMs: () => now,
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
requestHeartbeat: vi.fn(),
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
|
||||
runScriptJob: vi.fn(async () => ({
|
||||
status: "ok" as const,
|
||||
nextCheck: { delayMs: 5 * 60_000 },
|
||||
})),
|
||||
});
|
||||
|
||||
await onTimer(state);
|
||||
|
||||
const stored = await loadCronStore(storePath);
|
||||
expect(stored.jobs[0]?.state.nextRunAtMs).toBe(now + 15 * 60_000);
|
||||
expect(stored.jobs[0]?.state.pacedNextRunAtMs).toBe(now + 15 * 60_000);
|
||||
});
|
||||
|
||||
it("records isolated cron task runs against the backing cron session", async () => {
|
||||
const { storePath } = await makeStorePath();
|
||||
const now = Date.parse("2026-03-23T12:00:00.000Z");
|
||||
|
||||
@@ -114,6 +114,7 @@ import {
|
||||
finishRetiredCronTaskRuns,
|
||||
clearUnstartedStartupCatchupReservationMarkers,
|
||||
} from "./timer-outcome-finalization.js";
|
||||
import { enqueueCronSystemEvent, requestCronHeartbeat } from "./wake.js";
|
||||
|
||||
const MAX_TIMER_DELAY_MS = 60_000;
|
||||
const HEARTBEAT_SKIP_DISABLED = "disabled";
|
||||
@@ -146,6 +147,8 @@ type TimedCronRunOutcome = CronRunOutcome &
|
||||
startedAt: number;
|
||||
endedAt: number;
|
||||
triggerEval?: CronTriggerEvalOutcome;
|
||||
scriptStateChanged?: boolean;
|
||||
scriptState?: unknown;
|
||||
nextCheck?: CronNextCheckProposal;
|
||||
};
|
||||
|
||||
@@ -1217,6 +1220,11 @@ function applyOutcomeToStoredJob(
|
||||
|
||||
const shouldDelete = applyJobResult(state, job, result);
|
||||
applyTriggerRunResult(job, result);
|
||||
if (result.status === "ok" && result.scriptStateChanged === true) {
|
||||
// Both trigger and payload scripts expose frozen trigger.state. Advance it
|
||||
// only after the complete cron run has succeeded.
|
||||
job.state.triggerState = result.scriptState;
|
||||
}
|
||||
job.state.startupCatchupAtMs = undefined;
|
||||
|
||||
emitJobFinished(state, job, result, result.startedAt);
|
||||
@@ -2457,6 +2465,8 @@ async function executeJobCore(
|
||||
deliveryError?: string;
|
||||
delivery?: CronDeliveryTrace;
|
||||
nextCheck?: CronNextCheckProposal;
|
||||
scriptStateChanged?: boolean;
|
||||
scriptState?: unknown;
|
||||
triggerEval?: CronTriggerEvalOutcome;
|
||||
}
|
||||
> {
|
||||
@@ -2537,6 +2547,10 @@ async function executeJobCore(
|
||||
effectiveJob = { ...job, payload };
|
||||
}
|
||||
}
|
||||
if (effectiveJob.payload.kind === "script") {
|
||||
const result = await executeScriptCronJob(state, effectiveJob, abortSignal);
|
||||
return triggerEval ? { ...result, triggerEval } : result;
|
||||
}
|
||||
if (effectiveJob.sessionTarget === "main") {
|
||||
const result = await executeMainSessionCronJob(
|
||||
state,
|
||||
@@ -2593,7 +2607,7 @@ async function executeMainSessionCronJob(
|
||||
// Main-session jobs enqueue text into a per-run child session so each cron
|
||||
// execution has its own transcript and task drill-down target.
|
||||
const queuedSystemEvent = normalizeQueuedSystemEventHandle(
|
||||
state.deps.enqueueSystemEvent(text, {
|
||||
enqueueCronSystemEvent(state, text, {
|
||||
agentId: job.agentId,
|
||||
sessionKey: cronRunSessionKey,
|
||||
contextKey: `cron:${job.id}`,
|
||||
@@ -2692,8 +2706,7 @@ async function executeMainSessionCronJob(
|
||||
removeQueuedSystemEventHandle(state, job, queuedSystemEvent);
|
||||
return { status: "error", error: timeoutErrorMessage() };
|
||||
}
|
||||
state.deps.requestHeartbeat({
|
||||
source: "cron",
|
||||
requestCronHeartbeat(state, {
|
||||
intent: job.wakeMode === "now" ? "immediate" : "event",
|
||||
reason: `cron:${job.id}`,
|
||||
agentId: job.agentId,
|
||||
@@ -2820,6 +2833,66 @@ async function executeDetachedCronJob(
|
||||
};
|
||||
}
|
||||
|
||||
async function executeScriptCronJob(
|
||||
state: CronServiceState,
|
||||
job: CronJob,
|
||||
abortSignal: AbortSignal | undefined,
|
||||
) {
|
||||
if (state.deps.cronConfig?.triggers?.enabled !== true) {
|
||||
return {
|
||||
status: "error" as const,
|
||||
error:
|
||||
"cron script payload execution is disabled; set cron.triggers.enabled=true to allow unattended scripts",
|
||||
};
|
||||
}
|
||||
if (!state.deps.runScriptJob) {
|
||||
return { status: "error" as const, error: "cron script payload executor is unavailable" };
|
||||
}
|
||||
const result = await state.deps.runScriptJob({ job, abortSignal });
|
||||
if (result.status !== "ok") {
|
||||
return result;
|
||||
}
|
||||
if (result.nextCheck && !job.pacing) {
|
||||
return {
|
||||
status: "error" as const,
|
||||
error: "cron script payload returned nextCheck, but this job has no pacing bounds",
|
||||
};
|
||||
}
|
||||
|
||||
const notify = result.notify?.trim() ? result.notify : undefined;
|
||||
if (job.sessionTarget === "main" && notify) {
|
||||
enqueueCronSystemEvent(state, notify, {
|
||||
agentId: job.agentId,
|
||||
contextKey: `cron:${job.id}:script`,
|
||||
});
|
||||
}
|
||||
if (result.wake) {
|
||||
const eventText = notify ?? `script job ${job.name} completed`;
|
||||
if (job.sessionTarget !== "main" || !notify) {
|
||||
enqueueCronSystemEvent(state, eventText, {
|
||||
agentId: job.agentId,
|
||||
contextKey: `cron:${job.id}:script-wake`,
|
||||
});
|
||||
}
|
||||
requestCronHeartbeat(state, {
|
||||
intent: result.wake === "now" ? "immediate" : "event",
|
||||
reason: `cron:${job.id}:script`,
|
||||
agentId: job.agentId,
|
||||
});
|
||||
}
|
||||
return {
|
||||
status: "ok" as const,
|
||||
...(notify ? { summary: notify } : {}),
|
||||
delivered: result.delivered,
|
||||
deliveryAttempted: result.deliveryAttempted,
|
||||
deliveryError: result.deliveryError,
|
||||
delivery: result.delivery,
|
||||
nextCheck: result.nextCheck,
|
||||
scriptStateChanged: result.stateChanged === true,
|
||||
...(result.stateChanged === true ? { scriptState: result.state } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function emitJobFinished(
|
||||
state: CronServiceState,
|
||||
job: CronJob,
|
||||
|
||||
@@ -2,6 +2,27 @@
|
||||
import { isSubagentSessionKey } from "../../routing/session-key.js";
|
||||
import type { CronServiceState } from "./state.js";
|
||||
|
||||
export function enqueueCronSystemEvent(
|
||||
state: CronServiceState,
|
||||
text: string,
|
||||
opts?: Parameters<CronServiceState["deps"]["enqueueSystemEvent"]>[1],
|
||||
) {
|
||||
return state.deps.enqueueSystemEvent(text, opts);
|
||||
}
|
||||
|
||||
export function requestCronHeartbeat(
|
||||
state: CronServiceState,
|
||||
opts: {
|
||||
intent: "immediate" | "event";
|
||||
reason: string;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
heartbeat?: { target?: "last" };
|
||||
},
|
||||
) {
|
||||
state.deps.requestHeartbeat({ source: "cron", ...opts });
|
||||
}
|
||||
|
||||
/** Enqueues a manual cron wake event and optionally pokes the targeted heartbeat loop. */
|
||||
export function wake(
|
||||
state: CronServiceState,
|
||||
|
||||
@@ -89,6 +89,28 @@ function parseCommandPayloadMessage(
|
||||
};
|
||||
}
|
||||
|
||||
function parseScriptPayloadMessage(
|
||||
raw: string | null,
|
||||
): Omit<Extract<CronPayload, { kind: "script" }>, "kind" | "timeoutSeconds"> | null {
|
||||
const parsed = raw ? parseJsonValue<unknown>(raw, undefined) : undefined;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const record = parsed as Record<string, unknown>;
|
||||
if (typeof record.script !== "string" || !record.script.trim()) {
|
||||
return null;
|
||||
}
|
||||
const toolBudget = normalizeNumber(
|
||||
typeof record.toolBudget === "number" || typeof record.toolBudget === "bigint"
|
||||
? record.toolBudget
|
||||
: null,
|
||||
);
|
||||
return {
|
||||
script: record.script,
|
||||
...(toolBudget != null ? { toolBudget } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Maps cron payload variants into normalized SQLite columns. */
|
||||
export function bindPayloadColumns(
|
||||
payload: CronPayload,
|
||||
@@ -140,6 +162,26 @@ export function bindPayloadColumns(
|
||||
...bindPayloadToolAllowColumns(payload),
|
||||
};
|
||||
}
|
||||
if (payload.kind === "script") {
|
||||
const {
|
||||
timeoutSeconds: _timeoutSeconds,
|
||||
toolsAllow: _toolsAllow,
|
||||
toolsAllowIsDefault: _toolsAllowIsDefault,
|
||||
...payloadMessage
|
||||
} = payload;
|
||||
return {
|
||||
payload_kind: "script",
|
||||
payload_message: serializeJson(payloadMessage),
|
||||
payload_model: null,
|
||||
payload_fallbacks_json: null,
|
||||
payload_thinking: null,
|
||||
payload_timeout_seconds: payload.timeoutSeconds ?? null,
|
||||
payload_allow_unsafe_external_content: null,
|
||||
payload_external_content_source_json: null,
|
||||
payload_light_context: null,
|
||||
...bindPayloadToolAllowColumns(payload),
|
||||
};
|
||||
}
|
||||
return {
|
||||
payload_kind: "agentTurn",
|
||||
payload_message: payload.message,
|
||||
@@ -209,5 +251,18 @@ export function payloadFromRow(row: CronJobRow): CronPayload | null {
|
||||
...payloadToolAllowFromRow(row),
|
||||
};
|
||||
}
|
||||
if (row.payload_kind === "script") {
|
||||
const script = parseScriptPayloadMessage(row.payload_message);
|
||||
if (!script) {
|
||||
return null;
|
||||
}
|
||||
const timeoutSeconds = normalizeNumber(row.payload_timeout_seconds);
|
||||
return {
|
||||
kind: "script",
|
||||
...script,
|
||||
...(timeoutSeconds != null ? { timeoutSeconds } : {}),
|
||||
...payloadToolAllowFromRow(row),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import { BEFORE_TOOL_CALL_HOOK_CONTEXT } from "../agents/before-tool-call-metada
|
||||
import type { CodeModeHeadlessResult } from "../agents/code-mode.js";
|
||||
import type { AnyAgentTool } from "../agents/tools/common.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { createCronTriggerEvaluator } from "./trigger-script.js";
|
||||
import { createCronScriptRuntime } from "./trigger-script.js";
|
||||
|
||||
type EvaluatorDeps = Parameters<typeof createCronTriggerEvaluator>[0];
|
||||
type EvaluatorDeps = Parameters<typeof createCronScriptRuntime>[0];
|
||||
type HeadlessParams = Parameters<NonNullable<EvaluatorDeps["runHeadless"]>>[0];
|
||||
type PrepareParams = Parameters<NonNullable<EvaluatorDeps["prepareRuntime"]>>[0];
|
||||
|
||||
@@ -52,18 +52,22 @@ function createPreparedRuntime(config: OpenClawConfig) {
|
||||
function createEvaluator(
|
||||
runHeadless: (
|
||||
params: Parameters<
|
||||
NonNullable<Parameters<typeof createCronTriggerEvaluator>[0]["runHeadless"]>
|
||||
NonNullable<Parameters<typeof createCronScriptRuntime>[0]["runHeadless"]>
|
||||
>[0],
|
||||
) => Promise<CodeModeHeadlessResult>,
|
||||
) {
|
||||
const config = {} as OpenClawConfig;
|
||||
const prepareRuntime = vi.fn(async () => createPreparedRuntime(config));
|
||||
return {
|
||||
evaluate: createCronTriggerEvaluator({ config, runHeadless, prepareRuntime }),
|
||||
evaluate: createCronScriptRuntime({ config, runHeadless, prepareRuntime }).evaluateTrigger,
|
||||
prepareRuntime,
|
||||
};
|
||||
}
|
||||
|
||||
function createCronTriggerEvaluator(deps: EvaluatorDeps) {
|
||||
return createCronScriptRuntime(deps).evaluateTrigger;
|
||||
}
|
||||
|
||||
describe("cron trigger script evaluator", () => {
|
||||
it("prefers a valid returned value and injects trigger state", async () => {
|
||||
const runHeadless = vi.fn(async (_params: HeadlessParams) =>
|
||||
@@ -103,11 +107,11 @@ describe("cron trigger script evaluator", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the last json output entry", async () => {
|
||||
it("falls back to the last json output entry when the returned object is not a trigger result", async () => {
|
||||
const { evaluate } = createEvaluator(
|
||||
vi.fn(async () =>
|
||||
completed({
|
||||
value: null,
|
||||
value: { ignored: true },
|
||||
output: [
|
||||
{ type: "json", value: { fire: false, state: { old: true } } },
|
||||
{ type: "text", text: "ignored" },
|
||||
@@ -395,3 +399,139 @@ describe("cron trigger script evaluator", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("cron script payload evaluator", () => {
|
||||
it("uses payload-grade capped budgets and exposes frozen trigger state", async () => {
|
||||
const config = {} as OpenClawConfig;
|
||||
const runHeadless = vi.fn(async (_params: HeadlessParams) =>
|
||||
completed({
|
||||
value: {
|
||||
notify: "queue changed",
|
||||
wake: "now",
|
||||
state: { revision: 2 },
|
||||
nextCheck: "5m",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const runtime = createCronScriptRuntime({
|
||||
config,
|
||||
runHeadless,
|
||||
prepareRuntime: vi.fn(async () => createPreparedRuntime(config)),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runtime.executePayload({
|
||||
jobId: "payload-job",
|
||||
script: "return result",
|
||||
state: { revision: 1 },
|
||||
timeoutSeconds: 10_000,
|
||||
toolBudget: 10_000,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "completed",
|
||||
notify: "queue changed",
|
||||
wake: "now",
|
||||
stateChanged: true,
|
||||
state: { revision: 2 },
|
||||
nextCheck: { delayMs: 300_000 },
|
||||
});
|
||||
expect(runHeadless).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
maxToolCalls: 200,
|
||||
extraNamespaces: [
|
||||
{
|
||||
id: "cron:trigger",
|
||||
globalName: "trigger",
|
||||
scope: {
|
||||
kind: "object",
|
||||
entries: [["state", { kind: "value", value: { revision: 1 } }]],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const headlessParams = runHeadless.mock.calls[0]?.[0];
|
||||
expect(headlessParams?.wallClockMs).toBeGreaterThanOrEqual(899_000);
|
||||
expect(headlessParams?.wallClockMs).toBeLessThanOrEqual(900_000);
|
||||
});
|
||||
|
||||
it("uses payload defaults and accepts an omitted result state", async () => {
|
||||
const config = {} as OpenClawConfig;
|
||||
const runHeadless = vi.fn(async (_params: HeadlessParams) => completed({ value: {} }));
|
||||
const runtime = createCronScriptRuntime({
|
||||
config,
|
||||
runHeadless,
|
||||
prepareRuntime: vi.fn(async () => createPreparedRuntime(config)),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runtime.executePayload({ jobId: "payload-defaults", script: "return {}", state: null }),
|
||||
).resolves.toEqual({ kind: "completed", stateChanged: false });
|
||||
expect(runHeadless).toHaveBeenCalledWith(expect.objectContaining({ maxToolCalls: 50 }));
|
||||
const headlessParams = runHeadless.mock.calls[0]?.[0];
|
||||
expect(headlessParams?.wallClockMs).toBeGreaterThanOrEqual(299_000);
|
||||
expect(headlessParams?.wallClockMs).toBeLessThanOrEqual(300_000);
|
||||
});
|
||||
|
||||
it("canonicalizes returned state to the JSON value that will be persisted", async () => {
|
||||
const config = {} as OpenClawConfig;
|
||||
const runtime = createCronScriptRuntime({
|
||||
config,
|
||||
runHeadless: vi.fn(async () =>
|
||||
completed({
|
||||
value: { state: { keep: 1, dropped: undefined, nonFinite: Number.NaN } },
|
||||
}),
|
||||
),
|
||||
prepareRuntime: vi.fn(async () => createPreparedRuntime(config)),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runtime.executePayload({ jobId: "payload-json-state", script: "return state", state: null }),
|
||||
).resolves.toEqual({
|
||||
kind: "completed",
|
||||
stateChanged: true,
|
||||
state: { keep: 1, nonFinite: null },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ notify: 42 }, "notify must be a string"],
|
||||
[{ wake: "later" }, 'wake must be "now" or "next-heartbeat"'],
|
||||
[{ nextCheck: "tomorrowish" }, "nextCheck must be a positive duration"],
|
||||
[{ state: "x".repeat(17 * 1024) }, "state exceeds the 16KB limit"],
|
||||
] as const)("rejects an invalid result %#", async (value, error) => {
|
||||
const config = {} as OpenClawConfig;
|
||||
const runtime = createCronScriptRuntime({
|
||||
config,
|
||||
runHeadless: vi.fn(async () => completed({ value })),
|
||||
prepareRuntime: vi.fn(async () => createPreparedRuntime(config)),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runtime.executePayload({ jobId: "payload-invalid", script: "return result", state: null }),
|
||||
).resolves.toMatchObject({ kind: "error", error: expect.stringContaining(error) });
|
||||
});
|
||||
|
||||
it("surfaces executor failures through the cron error contract", async () => {
|
||||
const config = {} as OpenClawConfig;
|
||||
const runtime = createCronScriptRuntime({
|
||||
config,
|
||||
runHeadless: vi.fn(async () => ({
|
||||
status: "failed" as const,
|
||||
code: "tool_budget_exceeded" as const,
|
||||
error: "tool budget exceeded",
|
||||
output: [],
|
||||
toolCallCount: 51,
|
||||
})),
|
||||
prepareRuntime: vi.fn(async () => createPreparedRuntime(config)),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runtime.executePayload({ jobId: "payload-failed", script: "return result", state: null }),
|
||||
).resolves.toEqual({
|
||||
kind: "error",
|
||||
code: "tool_budget_exceeded",
|
||||
error: "tool budget exceeded",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+227
-49
@@ -32,6 +32,7 @@ import {
|
||||
} from "../agents/tool-search.js";
|
||||
import type { AnyAgentTool } from "../agents/tools/common.js";
|
||||
import { ensureAgentWorkspace } from "../agents/workspace.js";
|
||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { getPluginToolMeta } from "../plugins/tools.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
@@ -40,6 +41,12 @@ import {
|
||||
resolveCronActiveRuntimeConfig,
|
||||
} from "./isolated-agent/run-config.js";
|
||||
import { resolveCronAgentSessionKey } from "./isolated-agent/session-key.js";
|
||||
import {
|
||||
DEFAULT_CRON_SCRIPT_TIMEOUT_SECONDS,
|
||||
DEFAULT_CRON_SCRIPT_TOOL_BUDGET,
|
||||
MAX_CRON_SCRIPT_TIMEOUT_SECONDS,
|
||||
MAX_CRON_SCRIPT_TOOL_BUDGET,
|
||||
} from "./script-payload.js";
|
||||
import type { CronTriggerEvaluationResult, CronTriggerFailureCode } from "./types.js";
|
||||
|
||||
const MAX_CONCURRENT_TRIGGER_EVALS = 3;
|
||||
@@ -212,6 +219,21 @@ function triggerResultCandidate(result: Extract<CodeModeHeadlessResult, { status
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function scriptPayloadResultCandidate(
|
||||
result: Extract<CodeModeHeadlessResult, { status: "completed" }>,
|
||||
) {
|
||||
if (isRecord(result.value)) {
|
||||
return result.value;
|
||||
}
|
||||
for (let index = result.output.length - 1; index >= 0; index -= 1) {
|
||||
const entry = result.output[index];
|
||||
if (isRecord(entry) && entry.type === "json") {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseTriggerResult(
|
||||
result: Extract<CodeModeHeadlessResult, { status: "completed" }>,
|
||||
): CronTriggerEvaluationResult {
|
||||
@@ -230,59 +252,40 @@ function parseTriggerResult(
|
||||
error: "cron trigger script message must be a string",
|
||||
};
|
||||
}
|
||||
const hasState = Object.hasOwn(candidate, "state");
|
||||
if (hasState) {
|
||||
let serialized: string | undefined;
|
||||
try {
|
||||
serialized = JSON.stringify(candidate.state);
|
||||
} catch (error) {
|
||||
return {
|
||||
kind: "error",
|
||||
code: "internal_error",
|
||||
error: `cron trigger state is not JSON-serializable: ${String(error)}`,
|
||||
};
|
||||
}
|
||||
if (serialized === undefined) {
|
||||
return {
|
||||
kind: "error",
|
||||
code: "internal_error",
|
||||
error: "cron trigger state is not JSON-serializable",
|
||||
};
|
||||
}
|
||||
if (Buffer.byteLength(serialized, "utf8") > MAX_TRIGGER_STATE_BYTES) {
|
||||
return {
|
||||
kind: "error",
|
||||
code: "output_limit_exceeded",
|
||||
error: "cron trigger state exceeds the 16KB limit",
|
||||
};
|
||||
}
|
||||
const state = validateCronState(candidate, "cron trigger");
|
||||
if (!state.ok) {
|
||||
return { kind: "error", code: state.code, error: state.error };
|
||||
}
|
||||
return {
|
||||
kind: "evaluated",
|
||||
fire: candidate.fire,
|
||||
...(typeof candidate.message === "string" ? { message: candidate.message } : {}),
|
||||
...(hasState ? { state: candidate.state } : {}),
|
||||
...(state.stateChanged ? { state: state.state } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createTriggerDeadlineScope(externalSignal?: AbortSignal) {
|
||||
function createHeadlessDeadlineScope(params: {
|
||||
externalSignal?: AbortSignal;
|
||||
wallClockMs: number;
|
||||
label: string;
|
||||
}) {
|
||||
const controller = new AbortController();
|
||||
const onExternalAbort = () =>
|
||||
controller.abort(new CodeModeHeadlessAbortError("cron trigger evaluation aborted"));
|
||||
externalSignal?.addEventListener("abort", onExternalAbort, { once: true });
|
||||
if (externalSignal?.aborted) {
|
||||
controller.abort(new CodeModeHeadlessAbortError(`${params.label} aborted`));
|
||||
params.externalSignal?.addEventListener("abort", onExternalAbort, { once: true });
|
||||
if (params.externalSignal?.aborted) {
|
||||
onExternalAbort();
|
||||
}
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new CodeModeHeadlessTimeoutError("cron trigger evaluation timed out")),
|
||||
HEADLESS_TRIGGER_WALL_CLOCK_MS,
|
||||
() => controller.abort(new CodeModeHeadlessTimeoutError(`${params.label} timed out`)),
|
||||
params.wallClockMs,
|
||||
);
|
||||
return {
|
||||
deadline: Date.now() + HEADLESS_TRIGGER_WALL_CLOCK_MS,
|
||||
deadline: Date.now() + params.wallClockMs,
|
||||
signal: controller.signal,
|
||||
cleanup: () => {
|
||||
clearTimeout(timer);
|
||||
externalSignal?.removeEventListener("abort", onExternalAbort);
|
||||
params.externalSignal?.removeEventListener("abort", onExternalAbort);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -306,7 +309,7 @@ async function awaitTriggerSignal<T>(promise: Promise<T>, signal: AbortSignal):
|
||||
}
|
||||
}
|
||||
|
||||
export function createCronTriggerEvaluator(deps: CronTriggerEvaluatorDeps) {
|
||||
function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) {
|
||||
const runHeadless = deps.runHeadless ?? runCodeModeScriptHeadless;
|
||||
const prepareRuntime = deps.prepareRuntime ?? prepareTriggerRuntime;
|
||||
// Config identity is the reload epoch; caching the preparation promise makes
|
||||
@@ -382,19 +385,25 @@ export function createCronTriggerEvaluator(deps: CronTriggerEvaluatorDeps) {
|
||||
return await awaitTriggerSignal(entry.promise, request.signal);
|
||||
};
|
||||
|
||||
return async function evaluateCronTrigger(params: {
|
||||
return async function runCronCodeModeScript(params: {
|
||||
jobId: string;
|
||||
agentId?: string;
|
||||
script: string;
|
||||
state: unknown;
|
||||
toolsAllow?: string[];
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<CronTriggerEvaluationResult> {
|
||||
if (activeTriggerEvaluations >= MAX_CONCURRENT_TRIGGER_EVALS) {
|
||||
return { kind: "busy" };
|
||||
}
|
||||
activeTriggerEvaluations += 1;
|
||||
const evaluationScope = createTriggerDeadlineScope(params.abortSignal);
|
||||
wallClockMs: number;
|
||||
maxToolCalls: number;
|
||||
label: string;
|
||||
namespaces: CodeModeNamespaceDescriptor[];
|
||||
}): Promise<
|
||||
| { kind: "completed"; result: Extract<CodeModeHeadlessResult, { status: "completed" }> }
|
||||
| { kind: "error"; code: CronTriggerFailureCode; error: string }
|
||||
> {
|
||||
const evaluationScope = createHeadlessDeadlineScope({
|
||||
externalSignal: params.abortSignal,
|
||||
wallClockMs: params.wallClockMs,
|
||||
label: params.label,
|
||||
});
|
||||
try {
|
||||
const runtimeConfig = resolveCronActiveRuntimeConfig(deps.config);
|
||||
const agentId = resolveTriggerAgentId(runtimeConfig, params.agentId);
|
||||
@@ -418,20 +427,20 @@ export function createCronTriggerEvaluator(deps: CronTriggerEvaluatorDeps) {
|
||||
});
|
||||
const remainingWallClockMs = evaluationScope.deadline - Date.now();
|
||||
if (remainingWallClockMs <= 0) {
|
||||
throw new CodeModeHeadlessTimeoutError("cron trigger evaluation timed out");
|
||||
throw new CodeModeHeadlessTimeoutError(`${params.label} timed out`);
|
||||
}
|
||||
const result = await runHeadless({
|
||||
ctx: { ...runtime.ctx, catalogRef, abortSignal: evaluationScope.signal },
|
||||
code: params.script,
|
||||
wallClockMs: remainingWallClockMs,
|
||||
maxToolCalls: HEADLESS_TRIGGER_TOOL_BUDGET,
|
||||
extraNamespaces: [triggerStateNamespace(params.state)],
|
||||
maxToolCalls: params.maxToolCalls,
|
||||
extraNamespaces: params.namespaces,
|
||||
signal: evaluationScope.signal,
|
||||
});
|
||||
if (result.status === "failed") {
|
||||
return { kind: "error", code: result.code, error: result.error };
|
||||
}
|
||||
return parseTriggerResult(result);
|
||||
return { kind: "completed", result };
|
||||
} catch (error) {
|
||||
return {
|
||||
kind: "error",
|
||||
@@ -445,7 +454,176 @@ export function createCronTriggerEvaluator(deps: CronTriggerEvaluatorDeps) {
|
||||
};
|
||||
} finally {
|
||||
evaluationScope.cleanup();
|
||||
activeTriggerEvaluations -= 1;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
type CronScriptPayloadExecutionResult =
|
||||
| {
|
||||
kind: "completed";
|
||||
notify?: string;
|
||||
wake?: "now" | "next-heartbeat";
|
||||
stateChanged: boolean;
|
||||
state?: unknown;
|
||||
nextCheck?: { delayMs: number };
|
||||
}
|
||||
| { kind: "error"; code: CronTriggerFailureCode; error: string };
|
||||
|
||||
function validateCronState(candidate: Record<string, unknown>, label: string) {
|
||||
if (!Object.hasOwn(candidate, "state")) {
|
||||
return { ok: true as const, stateChanged: false as const };
|
||||
}
|
||||
let serialized: string | undefined;
|
||||
try {
|
||||
serialized = JSON.stringify(candidate.state);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false as const,
|
||||
code: "internal_error" as const,
|
||||
error: `${label} state is not JSON-serializable: ${String(error)}`,
|
||||
};
|
||||
}
|
||||
if (serialized === undefined) {
|
||||
return {
|
||||
ok: false as const,
|
||||
code: "internal_error" as const,
|
||||
error: `${label} state is not JSON-serializable`,
|
||||
};
|
||||
}
|
||||
if (Buffer.byteLength(serialized, "utf8") > MAX_TRIGGER_STATE_BYTES) {
|
||||
return {
|
||||
ok: false as const,
|
||||
code: "output_limit_exceeded" as const,
|
||||
error: `${label} state exceeds the 16KB limit`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true as const,
|
||||
stateChanged: true as const,
|
||||
state: JSON.parse(serialized) as unknown,
|
||||
};
|
||||
}
|
||||
|
||||
function parseScriptPayloadResult(
|
||||
result: Extract<CodeModeHeadlessResult, { status: "completed" }>,
|
||||
): CronScriptPayloadExecutionResult {
|
||||
const candidate = scriptPayloadResultCandidate(result);
|
||||
if (!isRecord(candidate)) {
|
||||
return {
|
||||
kind: "error",
|
||||
code: "internal_error",
|
||||
error: "cron script payload must return an object",
|
||||
};
|
||||
}
|
||||
if (candidate.notify !== undefined && typeof candidate.notify !== "string") {
|
||||
return {
|
||||
kind: "error",
|
||||
code: "internal_error",
|
||||
error: "cron script payload notify must be a string",
|
||||
};
|
||||
}
|
||||
if (
|
||||
candidate.wake !== undefined &&
|
||||
candidate.wake !== "now" &&
|
||||
candidate.wake !== "next-heartbeat"
|
||||
) {
|
||||
return {
|
||||
kind: "error",
|
||||
code: "internal_error",
|
||||
error: 'cron script payload wake must be "now" or "next-heartbeat"',
|
||||
};
|
||||
}
|
||||
let nextCheck: { delayMs: number } | undefined;
|
||||
if (candidate.nextCheck !== undefined) {
|
||||
if (typeof candidate.nextCheck !== "string") {
|
||||
return {
|
||||
kind: "error",
|
||||
code: "internal_error",
|
||||
error: "cron script payload nextCheck must be a duration string",
|
||||
};
|
||||
}
|
||||
try {
|
||||
const delayMs = parseDurationMs(candidate.nextCheck);
|
||||
if (delayMs <= 0) {
|
||||
throw new Error("duration must be positive");
|
||||
}
|
||||
nextCheck = { delayMs };
|
||||
} catch {
|
||||
return {
|
||||
kind: "error",
|
||||
code: "internal_error",
|
||||
error: "cron script payload nextCheck must be a positive duration",
|
||||
};
|
||||
}
|
||||
}
|
||||
const state = validateCronState(candidate, "cron script payload");
|
||||
if (!state.ok) {
|
||||
return { kind: "error", code: state.code, error: state.error };
|
||||
}
|
||||
return {
|
||||
kind: "completed",
|
||||
...(candidate.notify !== undefined ? { notify: candidate.notify } : {}),
|
||||
...(candidate.wake !== undefined ? { wake: candidate.wake } : {}),
|
||||
stateChanged: state.stateChanged,
|
||||
...(state.stateChanged ? { state: state.state } : {}),
|
||||
...(nextCheck ? { nextCheck } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCronScriptRuntime(deps: CronTriggerEvaluatorDeps) {
|
||||
const run = createCronCodeModeRunner(deps);
|
||||
return {
|
||||
evaluateTrigger: async (params: {
|
||||
jobId: string;
|
||||
agentId?: string;
|
||||
script: string;
|
||||
state: unknown;
|
||||
toolsAllow?: string[];
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<CronTriggerEvaluationResult> => {
|
||||
if (activeTriggerEvaluations >= MAX_CONCURRENT_TRIGGER_EVALS) {
|
||||
return { kind: "busy" };
|
||||
}
|
||||
activeTriggerEvaluations += 1;
|
||||
try {
|
||||
const outcome = await run({
|
||||
...params,
|
||||
wallClockMs: HEADLESS_TRIGGER_WALL_CLOCK_MS,
|
||||
maxToolCalls: HEADLESS_TRIGGER_TOOL_BUDGET,
|
||||
label: "cron trigger evaluation",
|
||||
namespaces: [triggerStateNamespace(params.state)],
|
||||
});
|
||||
return outcome.kind === "completed" ? parseTriggerResult(outcome.result) : outcome;
|
||||
} finally {
|
||||
activeTriggerEvaluations -= 1;
|
||||
}
|
||||
},
|
||||
executePayload: async (params: {
|
||||
jobId: string;
|
||||
agentId?: string;
|
||||
script: string;
|
||||
state: unknown;
|
||||
toolsAllow?: string[];
|
||||
timeoutSeconds?: number;
|
||||
toolBudget?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<CronScriptPayloadExecutionResult> => {
|
||||
const timeoutSeconds = Math.min(
|
||||
MAX_CRON_SCRIPT_TIMEOUT_SECONDS,
|
||||
Math.max(1, Math.floor(params.timeoutSeconds ?? DEFAULT_CRON_SCRIPT_TIMEOUT_SECONDS)),
|
||||
);
|
||||
const toolBudget = Math.min(
|
||||
MAX_CRON_SCRIPT_TOOL_BUDGET,
|
||||
Math.max(1, Math.floor(params.toolBudget ?? DEFAULT_CRON_SCRIPT_TOOL_BUDGET)),
|
||||
);
|
||||
const outcome = await run({
|
||||
...params,
|
||||
wallClockMs: timeoutSeconds * 1000,
|
||||
maxToolCalls: toolBudget,
|
||||
label: "cron script payload",
|
||||
namespaces: [triggerStateNamespace(params.state)],
|
||||
});
|
||||
return outcome.kind === "completed" ? parseScriptPayloadResult(outcome.result) : outcome;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+18
-2
@@ -251,13 +251,15 @@ export type CronFailureAlertPatch = {
|
||||
export type CronPayload =
|
||||
| ({ kind: "systemEvent"; text: string } & CronPayloadToolAllow)
|
||||
| (CronAgentTurnPayload & CronPayloadToolAllow)
|
||||
| (CronCommandPayload & CronPayloadToolAllow);
|
||||
| (CronCommandPayload & CronPayloadToolAllow)
|
||||
| (CronScriptPayload & CronPayloadToolAllow);
|
||||
|
||||
/** Partial payload update shape used by cron patch/edit flows. */
|
||||
export type CronPayloadPatch =
|
||||
| ({ kind: "systemEvent"; text?: string } & CronPayloadToolAllowPatch)
|
||||
| (CronAgentTurnPayloadPatch & CronPayloadToolAllowPatch)
|
||||
| (CronCommandPayloadPatch & CronPayloadToolAllowPatch);
|
||||
| (CronCommandPayloadPatch & CronPayloadToolAllowPatch)
|
||||
| (CronScriptPayloadPatch & CronPayloadToolAllowPatch);
|
||||
|
||||
type CronPayloadToolAllow = {
|
||||
/** Restricts agentTurn execution, or the trigger runtime for other payload kinds. */
|
||||
@@ -317,6 +319,20 @@ type CronCommandPayload = {
|
||||
type CronCommandPayloadPatch = {
|
||||
kind: "command";
|
||||
} & Partial<CronCommandPayloadFields>;
|
||||
|
||||
type CronScriptPayloadFields = {
|
||||
script: string;
|
||||
timeoutSeconds?: number;
|
||||
toolBudget?: number;
|
||||
};
|
||||
|
||||
type CronScriptPayload = {
|
||||
kind: "script";
|
||||
} & CronScriptPayloadFields;
|
||||
|
||||
type CronScriptPayloadPatch = {
|
||||
kind: "script";
|
||||
} & Partial<CronScriptPayloadFields>;
|
||||
/** Mutable runtime state persisted beside the immutable cron job spec. */
|
||||
export type CronJobState = {
|
||||
nextRunAtMs?: number;
|
||||
|
||||
@@ -340,6 +340,10 @@ export function dispatchGatewayCronFinishedNotifications(params: {
|
||||
}): void {
|
||||
const webhookToken = normalizeOptionalString(params.webhookToken);
|
||||
const redactedWebhookEvent = redactCommandCronEventForExternalDelivery(params.evt, params.job);
|
||||
const completionSummary =
|
||||
params.job?.payload.kind === "script"
|
||||
? normalizeOptionalString(redactedWebhookEvent.summary)
|
||||
: params.evt.summary;
|
||||
const webhookTargets = resolveCronWebhookTargets({
|
||||
delivery:
|
||||
params.job?.delivery && typeof params.job.delivery.mode === "string"
|
||||
@@ -377,7 +381,9 @@ export function dispatchGatewayCronFinishedNotifications(params: {
|
||||
);
|
||||
}
|
||||
|
||||
if (params.evt.summary) {
|
||||
// Script notify is carried as the completion summary, so its absence uses
|
||||
// the same silent-summary suppression path as NO_REPLY output.
|
||||
if (completionSummary) {
|
||||
for (const webhookTarget of webhookTargets) {
|
||||
const payload = buildCronFinishedWebhookPayload(redactedWebhookEvent);
|
||||
// Completion notification fanout is best-effort; the cron service has
|
||||
|
||||
@@ -32,8 +32,9 @@ const {
|
||||
retireSessionMcpRuntimeMock,
|
||||
requestSafeGatewayRestartMock,
|
||||
getProcessSupervisorMock,
|
||||
createCronTriggerEvaluatorMock,
|
||||
createCronScriptRuntimeMock,
|
||||
cronTriggerEvaluatorMock,
|
||||
cronScriptExecutorMock,
|
||||
} = vi.hoisted(() => ({
|
||||
enqueueSystemEventMock: vi.fn(),
|
||||
consumeSelectedSystemEventEntriesMock: vi.fn((_sessionKey, entries) => entries ?? []),
|
||||
@@ -90,8 +91,9 @@ const {
|
||||
spawn: vi.fn(),
|
||||
cancelScope: vi.fn(),
|
||||
})),
|
||||
createCronTriggerEvaluatorMock: vi.fn(),
|
||||
createCronScriptRuntimeMock: vi.fn(),
|
||||
cronTriggerEvaluatorMock: vi.fn(),
|
||||
cronScriptExecutorMock: vi.fn(),
|
||||
}));
|
||||
|
||||
function enqueueSystemEvent(text: string, opts?: unknown) {
|
||||
@@ -204,7 +206,7 @@ vi.mock("../process/supervisor/index.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../cron/trigger-script.js", () => ({
|
||||
createCronTriggerEvaluator: createCronTriggerEvaluatorMock,
|
||||
createCronScriptRuntime: createCronScriptRuntimeMock,
|
||||
}));
|
||||
|
||||
import type { CronJob } from "../cron/types.js";
|
||||
@@ -320,8 +322,13 @@ describe("buildGatewayCronService", () => {
|
||||
});
|
||||
cronTriggerEvaluatorMock.mockReset();
|
||||
cronTriggerEvaluatorMock.mockResolvedValue({ kind: "evaluated", fire: false });
|
||||
createCronTriggerEvaluatorMock.mockReset();
|
||||
createCronTriggerEvaluatorMock.mockReturnValue(cronTriggerEvaluatorMock);
|
||||
cronScriptExecutorMock.mockReset();
|
||||
cronScriptExecutorMock.mockResolvedValue({ kind: "completed", stateChanged: false });
|
||||
createCronScriptRuntimeMock.mockReset();
|
||||
createCronScriptRuntimeMock.mockReturnValue({
|
||||
evaluateTrigger: cronTriggerEvaluatorMock,
|
||||
executePayload: cronScriptExecutorMock,
|
||||
});
|
||||
getGlobalHookRunnerMock.mockReturnValue({
|
||||
hasHooks: (hookName: string) => hookName === "cron_changed",
|
||||
runCronChanged: runCronChangedMock,
|
||||
@@ -761,6 +768,164 @@ describe("buildGatewayCronService", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("delivers isolated script notify through the cron announce path", async () => {
|
||||
const cfg = createCronConfig("server-cron-script-announce");
|
||||
cfg.cron = { ...cfg.cron, triggers: { enabled: true } };
|
||||
loadConfigMock.mockReturnValue(cfg);
|
||||
cronScriptExecutorMock.mockResolvedValueOnce({
|
||||
kind: "completed",
|
||||
notify: "queue changed",
|
||||
stateChanged: false,
|
||||
});
|
||||
|
||||
const state = buildGatewayCronService({
|
||||
cfg,
|
||||
deps: {} as CliDeps,
|
||||
broadcast: () => {},
|
||||
});
|
||||
try {
|
||||
const job = await state.cron.add({
|
||||
name: "script-announce",
|
||||
enabled: true,
|
||||
deleteAfterRun: false,
|
||||
schedule: { kind: "at", at: new Date(1).toISOString() },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "script", script: "return { notify: 'queue changed' }" },
|
||||
delivery: { mode: "announce", channel: "telegram", to: "123", threadId: 456 },
|
||||
});
|
||||
|
||||
await state.cron.run(job.id, "force");
|
||||
|
||||
expect(cronScriptExecutorMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
jobId: job.id,
|
||||
script: "return { notify: 'queue changed' }",
|
||||
timeoutSeconds: 300,
|
||||
toolBudget: 50,
|
||||
}),
|
||||
);
|
||||
expect(sendCronAnnouncePayloadStrictMock).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({
|
||||
message: "queue changed",
|
||||
jobId: job.id,
|
||||
target: expect.objectContaining({ threadId: 456 }),
|
||||
}),
|
||||
);
|
||||
expect(state.cron.getJob(job.id)?.state.lastRunStatus).toBe("ok");
|
||||
} finally {
|
||||
state.cron.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("delivers isolated script notify through the cron webhook path", async () => {
|
||||
const cfg = createCronConfig("server-cron-script-webhook");
|
||||
cfg.cron = { ...cfg.cron, triggers: { enabled: true } };
|
||||
loadConfigMock.mockReturnValue(cfg);
|
||||
cronScriptExecutorMock.mockResolvedValueOnce({
|
||||
kind: "completed",
|
||||
notify: "queue changed",
|
||||
stateChanged: false,
|
||||
});
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({ release: vi.fn() });
|
||||
|
||||
const state = buildGatewayCronService({
|
||||
cfg,
|
||||
deps: {} as CliDeps,
|
||||
broadcast: () => {},
|
||||
});
|
||||
try {
|
||||
const job = await state.cron.add({
|
||||
name: "script-webhook",
|
||||
enabled: true,
|
||||
deleteAfterRun: false,
|
||||
schedule: { kind: "at", at: new Date(1).toISOString() },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "script", script: "return { notify: 'queue changed' }" },
|
||||
delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" },
|
||||
});
|
||||
|
||||
await state.cron.run(job.id, "force");
|
||||
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledOnce();
|
||||
const request = requireRecord(
|
||||
callArg(fetchWithSsrFGuardMock, 0, 0, "script webhook request"),
|
||||
"script webhook request",
|
||||
);
|
||||
expect(String(requireRecord(request.init, "fetch init").body)).toContain(
|
||||
'"summary":"queue changed"',
|
||||
);
|
||||
expect(state.cron.getJob(job.id)?.state.lastRunStatus).toBe("ok");
|
||||
} finally {
|
||||
state.cron.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not deliver a script webhook when notify is absent", async () => {
|
||||
const cfg = createCronConfig("server-cron-script-webhook-silent");
|
||||
cfg.cron = { ...cfg.cron, triggers: { enabled: true } };
|
||||
loadConfigMock.mockReturnValue(cfg);
|
||||
cronScriptExecutorMock.mockResolvedValueOnce({ kind: "completed", stateChanged: false });
|
||||
|
||||
const state = buildGatewayCronService({
|
||||
cfg,
|
||||
deps: {} as CliDeps,
|
||||
broadcast: () => {},
|
||||
});
|
||||
try {
|
||||
const job = await state.cron.add({
|
||||
name: "silent-script-webhook",
|
||||
enabled: true,
|
||||
deleteAfterRun: false,
|
||||
schedule: { kind: "at", at: new Date(1).toISOString() },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "script", script: "return {}" },
|
||||
delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" },
|
||||
});
|
||||
|
||||
await state.cron.run(job.id, "force");
|
||||
|
||||
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
|
||||
expect(state.cron.getJob(job.id)?.state.lastRunStatus).toBe("ok");
|
||||
} finally {
|
||||
state.cron.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not invoke delivery when a script omits notify", async () => {
|
||||
const cfg = createCronConfig("server-cron-script-silent");
|
||||
cfg.cron = { ...cfg.cron, triggers: { enabled: true } };
|
||||
loadConfigMock.mockReturnValue(cfg);
|
||||
cronScriptExecutorMock.mockResolvedValueOnce({ kind: "completed", stateChanged: false });
|
||||
|
||||
const state = buildGatewayCronService({
|
||||
cfg,
|
||||
deps: {} as CliDeps,
|
||||
broadcast: () => {},
|
||||
});
|
||||
try {
|
||||
const job = await state.cron.add({
|
||||
name: "silent-script",
|
||||
enabled: true,
|
||||
deleteAfterRun: false,
|
||||
schedule: { kind: "at", at: new Date(1).toISOString() },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "script", script: "return {}" },
|
||||
delivery: { mode: "announce", channel: "telegram", to: "123" },
|
||||
});
|
||||
|
||||
await state.cron.run(job.id, "force");
|
||||
|
||||
expect(sendCronAnnouncePayloadStrictMock).not.toHaveBeenCalled();
|
||||
expect(state.cron.getJob(job.id)?.state.lastRunStatus).toBe("ok");
|
||||
} finally {
|
||||
state.cron.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("suppresses command cron NO_REPLY output before webhook delivery", async () => {
|
||||
const cfg = createCronConfig("server-cron-command-webhook-no-reply");
|
||||
loadConfigMock.mockReturnValue(cfg);
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
resolveCronSessionTargetSessionKey,
|
||||
} from "../cron/session-target.js";
|
||||
import { resolveCronJobsStorePath } from "../cron/store.js";
|
||||
import { createCronTriggerEvaluator } from "../cron/trigger-script.js";
|
||||
import { createCronScriptRuntime } from "../cron/trigger-script.js";
|
||||
import type { CronJob, CronPayload } from "../cron/types.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { resolveMainScopedEventSessionKey } from "../infra/event-session-routing.js";
|
||||
@@ -368,9 +368,9 @@ export function buildGatewayCronService(params: {
|
||||
agentId: agentId ?? defaultAgentId,
|
||||
});
|
||||
const sessionStorePath = resolveSessionStorePath(defaultAgentId);
|
||||
const triggerEvaluator =
|
||||
const scriptRuntime =
|
||||
params.cfg.cron?.triggers?.enabled === true
|
||||
? createCronTriggerEvaluator({ config: params.cfg })
|
||||
? createCronScriptRuntime({ config: params.cfg })
|
||||
: undefined;
|
||||
|
||||
const runCronChangedHook = (evt: PluginHookCronChangedEvent) => {
|
||||
@@ -457,10 +457,10 @@ export function buildGatewayCronService(params: {
|
||||
storePath,
|
||||
cronEnabled,
|
||||
cronConfig: params.cfg.cron,
|
||||
...(triggerEvaluator
|
||||
...(scriptRuntime
|
||||
? {
|
||||
evaluateCronTrigger: ({ job, script, state, abortSignal }) =>
|
||||
triggerEvaluator({
|
||||
scriptRuntime.evaluateTrigger({
|
||||
jobId: job.id,
|
||||
agentId: job.agentId,
|
||||
script,
|
||||
@@ -581,8 +581,8 @@ export function buildGatewayCronService(params: {
|
||||
{
|
||||
channel: plan.channel,
|
||||
to: plan.to,
|
||||
accountId: plan.accountId,
|
||||
threadId: plan.threadId,
|
||||
accountId: plan.accountId,
|
||||
source: "explicit" as const,
|
||||
},
|
||||
["channel", "to", "accountId", "threadId", "source"],
|
||||
@@ -630,6 +630,7 @@ export function buildGatewayCronService(params: {
|
||||
target: {
|
||||
channel: plan.channel,
|
||||
to: plan.to,
|
||||
threadId: plan.threadId,
|
||||
accountId: plan.accountId,
|
||||
sessionKey: resolveCronDeliverySessionKey(job),
|
||||
},
|
||||
@@ -670,6 +671,96 @@ export function buildGatewayCronService(params: {
|
||||
};
|
||||
}
|
||||
},
|
||||
runScriptJob: async ({ job, abortSignal }) => {
|
||||
if (!scriptRuntime || job.payload.kind !== "script") {
|
||||
return { status: "error", error: "cron script payload executor is unavailable" };
|
||||
}
|
||||
const execution = await scriptRuntime.executePayload({
|
||||
jobId: job.id,
|
||||
agentId: job.agentId,
|
||||
script: job.payload.script,
|
||||
state: job.state.triggerState,
|
||||
toolsAllow: job.payload.toolsAllow,
|
||||
timeoutSeconds: job.payload.timeoutSeconds,
|
||||
toolBudget: job.payload.toolBudget,
|
||||
abortSignal,
|
||||
});
|
||||
if (execution.kind === "error") {
|
||||
return {
|
||||
status: "error",
|
||||
error: `cron script payload failed (${execution.code}): ${execution.error}`,
|
||||
};
|
||||
}
|
||||
if (execution.nextCheck && !job.pacing) {
|
||||
return {
|
||||
status: "error",
|
||||
error: "cron script payload returned nextCheck, but this job has no pacing bounds",
|
||||
};
|
||||
}
|
||||
|
||||
const notify = execution.notify?.trim() ? execution.notify : undefined;
|
||||
const plan = resolveCronDeliveryPlan(job);
|
||||
const deliveryTrace = {
|
||||
intended: pickDefined(
|
||||
{
|
||||
channel: plan.channel,
|
||||
to: plan.to,
|
||||
accountId: plan.accountId,
|
||||
threadId: plan.threadId,
|
||||
source: "explicit" as const,
|
||||
},
|
||||
["channel", "to", "accountId", "threadId", "source"],
|
||||
),
|
||||
};
|
||||
const base = {
|
||||
status: "ok" as const,
|
||||
notify,
|
||||
wake: execution.wake,
|
||||
stateChanged: execution.stateChanged,
|
||||
...(execution.stateChanged ? { state: execution.state } : {}),
|
||||
nextCheck: execution.nextCheck,
|
||||
delivery: deliveryTrace,
|
||||
};
|
||||
if (job.sessionTarget === "main" || plan.mode !== "announce" || !notify) {
|
||||
return { ...base, deliveryAttempted: false, delivered: false };
|
||||
}
|
||||
|
||||
const { agentId, cfg: runtimeConfig } = resolveCronAgent(job.agentId);
|
||||
try {
|
||||
await sendCronAnnouncePayloadStrict({
|
||||
deps: params.deps,
|
||||
cfg: runtimeConfig,
|
||||
agentId,
|
||||
jobId: job.id,
|
||||
target: {
|
||||
channel: plan.channel,
|
||||
to: plan.to,
|
||||
threadId: plan.threadId,
|
||||
accountId: plan.accountId,
|
||||
sessionKey: resolveCronDeliverySessionKey(job),
|
||||
},
|
||||
message: notify,
|
||||
abortSignal: abortSignal ?? new AbortController().signal,
|
||||
});
|
||||
return {
|
||||
...base,
|
||||
deliveryAttempted: true,
|
||||
delivered: true,
|
||||
delivery: { ...deliveryTrace, delivered: true },
|
||||
};
|
||||
} catch (err) {
|
||||
const error = formatErrorMessage(err);
|
||||
cronLogger.warn({ jobId: job.id, err: error }, "cron: script payload delivery failed");
|
||||
return {
|
||||
...base,
|
||||
status: job.delivery?.bestEffort ? ("ok" as const) : ("error" as const),
|
||||
...(job.delivery?.bestEffort ? { deliveryError: error } : { error }),
|
||||
deliveryAttempted: true,
|
||||
delivered: false,
|
||||
delivery: { ...deliveryTrace, delivered: false },
|
||||
};
|
||||
}
|
||||
},
|
||||
cleanupTimedOutAgentRun: async ({ job, execution }) => {
|
||||
if (!execution?.sessionId) {
|
||||
return;
|
||||
|
||||
@@ -2175,7 +2175,8 @@ describe("cron method validation", () => {
|
||||
expect(context.cron.update).not.toHaveBeenCalled();
|
||||
expectResponseError(respond, {
|
||||
code: "INVALID_REQUEST",
|
||||
messageIncludes: 'isolated/current/session cron jobs require payload.kind="agentTurn"',
|
||||
messageIncludes:
|
||||
'isolated cron jobs require payload.kind="agentTurn", "command", or "script"; script payloads do not support current/session targets',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+21
-3
@@ -427,7 +427,7 @@
|
||||
},
|
||||
{
|
||||
"deferLoading": true,
|
||||
"description": "Gateway schedules/wakes: reminders, later checks/follow-ups, recurring work. Never exec sleep/process-poll as timer. Main job => heartbeat system event; isolated => background task in `openclaw tasks`.\n\nACTIONS:\n- status scheduler; list compact summaries (includeDisabled, session agentId auto-filter; get for full); get jobId\n- add job; update jobId+patch; remove jobId\n- run jobId (due only; runMode=\"force\" now); runs jobId history; next_check in (current paced job only)\n- wake text (+ optional mode). Default caller lane; top-level sessionKey/agentId selects another caller-owned lane.\n\nADD JOB:\n{ \"name\":\"...\", \"schedule\":{...}, \"pacing\":{ \"min\":\"15m\", \"max\":\"4h\" }, \"trigger\":{ \"script\":\"...\", \"once\":false }, \"payload\":{...}, \"delivery\":{...}, \"sessionTarget\":\"main|isolated|current|session:<id>\", \"enabled\":true }\nRequired: schedule,payload. enabled default true. trigger only every/cron.\n\nTARGET/PAYLOAD:\n- main => systemEvent {kind:\"systemEvent\",text:\"...\"}; systemEvent defaults main.\n- isolated/current/session:<id> => agentTurn {kind:\"agentTurn\",message:\"...\",model?,thinking?,timeoutSeconds?}; agentTurn defaults isolated. timeoutSeconds=0 means none.\n- current binds caller session at creation. session:<id> is persistent. Prefer isolated unless user explicitly wants current binding.\n\nSCHEDULE:\n- at: {kind:\"at\",at:\"ISO-8601\"}; timezone-less = UTC.\n- every: {kind:\"every\",everyMs:<ms>,anchorMs?}.\n- cron: {kind:\"cron\",expr:\"...\",tz?:\"IANA\"}. Expr is requested local wall time; never pre-convert to UTC. Missing tz = Gateway host local, not UTC. Shanghai 18:00: {kind:\"cron\",expr:\"0 18 * * *\",tz:\"Asia/Shanghai\"}.\n\nTRIGGER SCRIPT:\n- Requires cron.triggers.enabled; if off, explain and never model-poll fallback.\n- Headless owner allowlist; quiet check has no model. Prior trigger.state is frozen JSON. Return/json({fire:boolean,message?:string,state?:JSONValue}); create new state, never mutate prior.\n- fire:false saves state only; no payload/history. fire:true runs payload and appends message; fired state saves only after payload success.\n- Fire on every actionable state, including failures/timeouts; success-only watchers go silent when broken, which looks healthy. Dedupe by comparing trigger.state and returning new state, never memory.\n- Keep scripts read-only; actions belong in payload. message must be self-contained: it is the fired run's entire context.\n- Silent watcher: top-level delivery.mode=\"none\". Omitted delivery on isolated agentTurn announces and missing route may fail.\n- once:true disables after first successful fire. Per check: 30s, 5 tool calls, 16KB state.\n- Hidden Code Mode tools: await tools.call(\"exec\", {command:\"...\"}); unknown id => search/describe.\n\nDELIVERY top-level: {mode:\"none|announce|webhook\",channel?,to?,threadId?,bestEffort?}\n- Isolated agentTurn omitted delivery => announce. announce only isolated/current/session; channel/to optional; threadId chat topic. Specific chat: set channel/to; no messaging tool inside run.\n- webhook posts finished-run event to URL in to.\n\nRestricted isolated runs may only self status/list, current get/runs/remove, and next_check for their own paced job. wake mode: next-heartbeat default | now. jobId canonical; id compat. contextMessages 0-10 adds prior messages.",
|
||||
"description": "Gateway schedules/wakes: reminders, later checks/follow-ups, recurring work. Never exec sleep/process-poll as timer. Main job => heartbeat system event; isolated => background task in `openclaw tasks`.\n\nACTIONS:\n- status scheduler; list compact summaries (includeDisabled, session agentId auto-filter; get for full); get jobId\n- add job; update jobId+patch; remove jobId\n- run jobId (due only; runMode=\"force\" now); runs jobId history; next_check in (current paced job only)\n- wake text (+ optional mode). Default caller lane; top-level sessionKey/agentId selects another caller-owned lane.\n\nADD JOB:\n{ \"name\":\"...\", \"schedule\":{...}, \"pacing\":{ \"min\":\"15m\", \"max\":\"4h\" }, \"trigger\":{ \"script\":\"...\", \"once\":false }, \"payload\":{...}, \"delivery\":{...}, \"sessionTarget\":\"main|isolated|current|session:<id>\", \"enabled\":true }\nRequired: schedule,payload. enabled default true. trigger only every/cron.\n\nTARGET/PAYLOAD:\n- main => systemEvent {kind:\"systemEvent\",text:\"...\"} or script; systemEvent defaults main.\n- isolated/current/session:<id> => agentTurn {kind:\"agentTurn\",message:\"...\",model?,thinking?,timeoutSeconds?}; agentTurn defaults isolated. timeoutSeconds=0 means none.\n- script {kind:\"script\",script:\"...\",timeoutSeconds?,toolBudget?} supports main or isolated only and requires cron.triggers.enabled.\n- current binds caller session at creation. session:<id> is persistent. Prefer isolated unless user explicitly wants current binding.\n\nSCHEDULE:\n- at: {kind:\"at\",at:\"ISO-8601\"}; timezone-less = UTC.\n- every: {kind:\"every\",everyMs:<ms>,anchorMs?}.\n- cron: {kind:\"cron\",expr:\"...\",tz?:\"IANA\"}. Expr is requested local wall time; never pre-convert to UTC. Missing tz = Gateway host local, not UTC. Shanghai 18:00: {kind:\"cron\",expr:\"0 18 * * *\",tz:\"Asia/Shanghai\"}.\n\nTRIGGER SCRIPT:\n- Requires cron.triggers.enabled; if off, explain and never model-poll fallback.\n- Headless owner allowlist; quiet check has no model. Prior trigger.state is frozen JSON. Return/json({fire:boolean,message?:string,state?:JSONValue}); create new state, never mutate prior.\n- fire:false saves state only; no payload/history. fire:true runs payload and appends message; fired state saves only after payload success.\n- Fire on every actionable state, including failures/timeouts; success-only watchers go silent when broken, which looks healthy. Dedupe by comparing trigger.state and returning new state, never memory.\n- Keep scripts read-only; actions belong in payload. message must be self-contained: it is the fired run's entire context.\n- Silent watcher: top-level delivery.mode=\"none\". Omitted delivery on isolated agentTurn announces and missing route may fail.\n- once:true disables after first successful fire. Per check: 30s, 5 tool calls, 16KB state.\n- Hidden Code Mode tools: await tools.call(\"exec\", {command:\"...\"}); unknown id => search/describe.\n\nDELIVERY top-level: {mode:\"none|announce|webhook\",channel?,to?,threadId?,bestEffort?}\n- Isolated agentTurn omitted delivery => announce. announce only isolated/current/session; channel/to optional; threadId chat topic. Specific chat: set channel/to; no messaging tool inside run.\n- webhook posts finished-run event to URL in to.\n\nRestricted isolated runs may only self status/list, current get/runs/remove, and next_check for their own paced job. wake mode: next-heartbeat default | now. jobId canonical; id compat. contextMessages 0-10 adds prior messages.",
|
||||
"inputSchema": {
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
@@ -656,7 +656,7 @@
|
||||
},
|
||||
"kind": {
|
||||
"description": "Payload kind",
|
||||
"enum": ["systemEvent", "agentTurn"],
|
||||
"enum": ["systemEvent", "agentTurn", "script"],
|
||||
"type": "string"
|
||||
},
|
||||
"lightContext": {
|
||||
@@ -670,6 +670,10 @@
|
||||
"description": "Model override",
|
||||
"type": "string"
|
||||
},
|
||||
"script": {
|
||||
"description": "Headless code-mode script",
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"description": "systemEvent text",
|
||||
"type": "string"
|
||||
@@ -682,6 +686,11 @@
|
||||
"minimum": 0,
|
||||
"type": "number"
|
||||
},
|
||||
"toolBudget": {
|
||||
"description": "Maximum script tool calls",
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"toolsAllow": {
|
||||
"description": "Allowed tools",
|
||||
"items": {
|
||||
@@ -1017,7 +1026,7 @@
|
||||
},
|
||||
"kind": {
|
||||
"description": "Payload kind",
|
||||
"enum": ["systemEvent", "agentTurn"],
|
||||
"enum": ["systemEvent", "agentTurn", "script"],
|
||||
"type": "string"
|
||||
},
|
||||
"lightContext": {
|
||||
@@ -1038,6 +1047,10 @@
|
||||
],
|
||||
"description": "Model override, or null to clear"
|
||||
},
|
||||
"script": {
|
||||
"description": "Headless code-mode script",
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"description": "systemEvent text",
|
||||
"type": "string"
|
||||
@@ -1050,6 +1063,11 @@
|
||||
"minimum": 0,
|
||||
"type": "number"
|
||||
},
|
||||
"toolBudget": {
|
||||
"description": "Maximum script tool calls",
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"toolsAllow": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
+21
-3
@@ -423,7 +423,7 @@
|
||||
},
|
||||
{
|
||||
"deferLoading": true,
|
||||
"description": "Gateway schedules/wakes: reminders, later checks/follow-ups, recurring work. Never exec sleep/process-poll as timer. Main job => heartbeat system event; isolated => background task in `openclaw tasks`.\n\nACTIONS:\n- status scheduler; list compact summaries (includeDisabled, session agentId auto-filter; get for full); get jobId\n- add job; update jobId+patch; remove jobId\n- run jobId (due only; runMode=\"force\" now); runs jobId history; next_check in (current paced job only)\n- wake text (+ optional mode). Default caller lane; top-level sessionKey/agentId selects another caller-owned lane.\n\nADD JOB:\n{ \"name\":\"...\", \"schedule\":{...}, \"pacing\":{ \"min\":\"15m\", \"max\":\"4h\" }, \"trigger\":{ \"script\":\"...\", \"once\":false }, \"payload\":{...}, \"delivery\":{...}, \"sessionTarget\":\"main|isolated|current|session:<id>\", \"enabled\":true }\nRequired: schedule,payload. enabled default true. trigger only every/cron.\n\nTARGET/PAYLOAD:\n- main => systemEvent {kind:\"systemEvent\",text:\"...\"}; systemEvent defaults main.\n- isolated/current/session:<id> => agentTurn {kind:\"agentTurn\",message:\"...\",model?,thinking?,timeoutSeconds?}; agentTurn defaults isolated. timeoutSeconds=0 means none.\n- current binds caller session at creation. session:<id> is persistent. Prefer isolated unless user explicitly wants current binding.\n\nSCHEDULE:\n- at: {kind:\"at\",at:\"ISO-8601\"}; timezone-less = UTC.\n- every: {kind:\"every\",everyMs:<ms>,anchorMs?}.\n- cron: {kind:\"cron\",expr:\"...\",tz?:\"IANA\"}. Expr is requested local wall time; never pre-convert to UTC. Missing tz = Gateway host local, not UTC. Shanghai 18:00: {kind:\"cron\",expr:\"0 18 * * *\",tz:\"Asia/Shanghai\"}.\n\nTRIGGER SCRIPT:\n- Requires cron.triggers.enabled; if off, explain and never model-poll fallback.\n- Headless owner allowlist; quiet check has no model. Prior trigger.state is frozen JSON. Return/json({fire:boolean,message?:string,state?:JSONValue}); create new state, never mutate prior.\n- fire:false saves state only; no payload/history. fire:true runs payload and appends message; fired state saves only after payload success.\n- Fire on every actionable state, including failures/timeouts; success-only watchers go silent when broken, which looks healthy. Dedupe by comparing trigger.state and returning new state, never memory.\n- Keep scripts read-only; actions belong in payload. message must be self-contained: it is the fired run's entire context.\n- Silent watcher: top-level delivery.mode=\"none\". Omitted delivery on isolated agentTurn announces and missing route may fail.\n- once:true disables after first successful fire. Per check: 30s, 5 tool calls, 16KB state.\n- Hidden Code Mode tools: await tools.call(\"exec\", {command:\"...\"}); unknown id => search/describe.\n\nDELIVERY top-level: {mode:\"none|announce|webhook\",channel?,to?,threadId?,bestEffort?}\n- Isolated agentTurn omitted delivery => announce. announce only isolated/current/session; channel/to optional; threadId chat topic. Specific chat: set channel/to; no messaging tool inside run.\n- webhook posts finished-run event to URL in to.\n\nRestricted isolated runs may only self status/list, current get/runs/remove, and next_check for their own paced job. wake mode: next-heartbeat default | now. jobId canonical; id compat. contextMessages 0-10 adds prior messages.",
|
||||
"description": "Gateway schedules/wakes: reminders, later checks/follow-ups, recurring work. Never exec sleep/process-poll as timer. Main job => heartbeat system event; isolated => background task in `openclaw tasks`.\n\nACTIONS:\n- status scheduler; list compact summaries (includeDisabled, session agentId auto-filter; get for full); get jobId\n- add job; update jobId+patch; remove jobId\n- run jobId (due only; runMode=\"force\" now); runs jobId history; next_check in (current paced job only)\n- wake text (+ optional mode). Default caller lane; top-level sessionKey/agentId selects another caller-owned lane.\n\nADD JOB:\n{ \"name\":\"...\", \"schedule\":{...}, \"pacing\":{ \"min\":\"15m\", \"max\":\"4h\" }, \"trigger\":{ \"script\":\"...\", \"once\":false }, \"payload\":{...}, \"delivery\":{...}, \"sessionTarget\":\"main|isolated|current|session:<id>\", \"enabled\":true }\nRequired: schedule,payload. enabled default true. trigger only every/cron.\n\nTARGET/PAYLOAD:\n- main => systemEvent {kind:\"systemEvent\",text:\"...\"} or script; systemEvent defaults main.\n- isolated/current/session:<id> => agentTurn {kind:\"agentTurn\",message:\"...\",model?,thinking?,timeoutSeconds?}; agentTurn defaults isolated. timeoutSeconds=0 means none.\n- script {kind:\"script\",script:\"...\",timeoutSeconds?,toolBudget?} supports main or isolated only and requires cron.triggers.enabled.\n- current binds caller session at creation. session:<id> is persistent. Prefer isolated unless user explicitly wants current binding.\n\nSCHEDULE:\n- at: {kind:\"at\",at:\"ISO-8601\"}; timezone-less = UTC.\n- every: {kind:\"every\",everyMs:<ms>,anchorMs?}.\n- cron: {kind:\"cron\",expr:\"...\",tz?:\"IANA\"}. Expr is requested local wall time; never pre-convert to UTC. Missing tz = Gateway host local, not UTC. Shanghai 18:00: {kind:\"cron\",expr:\"0 18 * * *\",tz:\"Asia/Shanghai\"}.\n\nTRIGGER SCRIPT:\n- Requires cron.triggers.enabled; if off, explain and never model-poll fallback.\n- Headless owner allowlist; quiet check has no model. Prior trigger.state is frozen JSON. Return/json({fire:boolean,message?:string,state?:JSONValue}); create new state, never mutate prior.\n- fire:false saves state only; no payload/history. fire:true runs payload and appends message; fired state saves only after payload success.\n- Fire on every actionable state, including failures/timeouts; success-only watchers go silent when broken, which looks healthy. Dedupe by comparing trigger.state and returning new state, never memory.\n- Keep scripts read-only; actions belong in payload. message must be self-contained: it is the fired run's entire context.\n- Silent watcher: top-level delivery.mode=\"none\". Omitted delivery on isolated agentTurn announces and missing route may fail.\n- once:true disables after first successful fire. Per check: 30s, 5 tool calls, 16KB state.\n- Hidden Code Mode tools: await tools.call(\"exec\", {command:\"...\"}); unknown id => search/describe.\n\nDELIVERY top-level: {mode:\"none|announce|webhook\",channel?,to?,threadId?,bestEffort?}\n- Isolated agentTurn omitted delivery => announce. announce only isolated/current/session; channel/to optional; threadId chat topic. Specific chat: set channel/to; no messaging tool inside run.\n- webhook posts finished-run event to URL in to.\n\nRestricted isolated runs may only self status/list, current get/runs/remove, and next_check for their own paced job. wake mode: next-heartbeat default | now. jobId canonical; id compat. contextMessages 0-10 adds prior messages.",
|
||||
"inputSchema": {
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
@@ -652,7 +652,7 @@
|
||||
},
|
||||
"kind": {
|
||||
"description": "Payload kind",
|
||||
"enum": ["systemEvent", "agentTurn"],
|
||||
"enum": ["systemEvent", "agentTurn", "script"],
|
||||
"type": "string"
|
||||
},
|
||||
"lightContext": {
|
||||
@@ -666,6 +666,10 @@
|
||||
"description": "Model override",
|
||||
"type": "string"
|
||||
},
|
||||
"script": {
|
||||
"description": "Headless code-mode script",
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"description": "systemEvent text",
|
||||
"type": "string"
|
||||
@@ -678,6 +682,11 @@
|
||||
"minimum": 0,
|
||||
"type": "number"
|
||||
},
|
||||
"toolBudget": {
|
||||
"description": "Maximum script tool calls",
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"toolsAllow": {
|
||||
"description": "Allowed tools",
|
||||
"items": {
|
||||
@@ -1013,7 +1022,7 @@
|
||||
},
|
||||
"kind": {
|
||||
"description": "Payload kind",
|
||||
"enum": ["systemEvent", "agentTurn"],
|
||||
"enum": ["systemEvent", "agentTurn", "script"],
|
||||
"type": "string"
|
||||
},
|
||||
"lightContext": {
|
||||
@@ -1034,6 +1043,10 @@
|
||||
],
|
||||
"description": "Model override, or null to clear"
|
||||
},
|
||||
"script": {
|
||||
"description": "Headless code-mode script",
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"description": "systemEvent text",
|
||||
"type": "string"
|
||||
@@ -1046,6 +1059,11 @@
|
||||
"minimum": 0,
|
||||
"type": "number"
|
||||
},
|
||||
"toolBudget": {
|
||||
"description": "Maximum script tool calls",
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"toolsAllow": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
+21
-3
@@ -423,7 +423,7 @@
|
||||
},
|
||||
{
|
||||
"deferLoading": true,
|
||||
"description": "Gateway schedules/wakes: reminders, later checks/follow-ups, recurring work. Never exec sleep/process-poll as timer. Main job => heartbeat system event; isolated => background task in `openclaw tasks`.\n\nACTIONS:\n- status scheduler; list compact summaries (includeDisabled, session agentId auto-filter; get for full); get jobId\n- add job; update jobId+patch; remove jobId\n- run jobId (due only; runMode=\"force\" now); runs jobId history; next_check in (current paced job only)\n- wake text (+ optional mode). Default caller lane; top-level sessionKey/agentId selects another caller-owned lane.\n\nADD JOB:\n{ \"name\":\"...\", \"schedule\":{...}, \"pacing\":{ \"min\":\"15m\", \"max\":\"4h\" }, \"trigger\":{ \"script\":\"...\", \"once\":false }, \"payload\":{...}, \"delivery\":{...}, \"sessionTarget\":\"main|isolated|current|session:<id>\", \"enabled\":true }\nRequired: schedule,payload. enabled default true. trigger only every/cron.\n\nTARGET/PAYLOAD:\n- main => systemEvent {kind:\"systemEvent\",text:\"...\"}; systemEvent defaults main.\n- isolated/current/session:<id> => agentTurn {kind:\"agentTurn\",message:\"...\",model?,thinking?,timeoutSeconds?}; agentTurn defaults isolated. timeoutSeconds=0 means none.\n- current binds caller session at creation. session:<id> is persistent. Prefer isolated unless user explicitly wants current binding.\n\nSCHEDULE:\n- at: {kind:\"at\",at:\"ISO-8601\"}; timezone-less = UTC.\n- every: {kind:\"every\",everyMs:<ms>,anchorMs?}.\n- cron: {kind:\"cron\",expr:\"...\",tz?:\"IANA\"}. Expr is requested local wall time; never pre-convert to UTC. Missing tz = Gateway host local, not UTC. Shanghai 18:00: {kind:\"cron\",expr:\"0 18 * * *\",tz:\"Asia/Shanghai\"}.\n\nTRIGGER SCRIPT:\n- Requires cron.triggers.enabled; if off, explain and never model-poll fallback.\n- Headless owner allowlist; quiet check has no model. Prior trigger.state is frozen JSON. Return/json({fire:boolean,message?:string,state?:JSONValue}); create new state, never mutate prior.\n- fire:false saves state only; no payload/history. fire:true runs payload and appends message; fired state saves only after payload success.\n- Fire on every actionable state, including failures/timeouts; success-only watchers go silent when broken, which looks healthy. Dedupe by comparing trigger.state and returning new state, never memory.\n- Keep scripts read-only; actions belong in payload. message must be self-contained: it is the fired run's entire context.\n- Silent watcher: top-level delivery.mode=\"none\". Omitted delivery on isolated agentTurn announces and missing route may fail.\n- once:true disables after first successful fire. Per check: 30s, 5 tool calls, 16KB state.\n- Hidden Code Mode tools: await tools.call(\"exec\", {command:\"...\"}); unknown id => search/describe.\n\nDELIVERY top-level: {mode:\"none|announce|webhook\",channel?,to?,threadId?,bestEffort?}\n- Isolated agentTurn omitted delivery => announce. announce only isolated/current/session; channel/to optional; threadId chat topic. Specific chat: set channel/to; no messaging tool inside run.\n- webhook posts finished-run event to URL in to.\n\nRestricted isolated runs may only self status/list, current get/runs/remove, and next_check for their own paced job. wake mode: next-heartbeat default | now. jobId canonical; id compat. contextMessages 0-10 adds prior messages.",
|
||||
"description": "Gateway schedules/wakes: reminders, later checks/follow-ups, recurring work. Never exec sleep/process-poll as timer. Main job => heartbeat system event; isolated => background task in `openclaw tasks`.\n\nACTIONS:\n- status scheduler; list compact summaries (includeDisabled, session agentId auto-filter; get for full); get jobId\n- add job; update jobId+patch; remove jobId\n- run jobId (due only; runMode=\"force\" now); runs jobId history; next_check in (current paced job only)\n- wake text (+ optional mode). Default caller lane; top-level sessionKey/agentId selects another caller-owned lane.\n\nADD JOB:\n{ \"name\":\"...\", \"schedule\":{...}, \"pacing\":{ \"min\":\"15m\", \"max\":\"4h\" }, \"trigger\":{ \"script\":\"...\", \"once\":false }, \"payload\":{...}, \"delivery\":{...}, \"sessionTarget\":\"main|isolated|current|session:<id>\", \"enabled\":true }\nRequired: schedule,payload. enabled default true. trigger only every/cron.\n\nTARGET/PAYLOAD:\n- main => systemEvent {kind:\"systemEvent\",text:\"...\"} or script; systemEvent defaults main.\n- isolated/current/session:<id> => agentTurn {kind:\"agentTurn\",message:\"...\",model?,thinking?,timeoutSeconds?}; agentTurn defaults isolated. timeoutSeconds=0 means none.\n- script {kind:\"script\",script:\"...\",timeoutSeconds?,toolBudget?} supports main or isolated only and requires cron.triggers.enabled.\n- current binds caller session at creation. session:<id> is persistent. Prefer isolated unless user explicitly wants current binding.\n\nSCHEDULE:\n- at: {kind:\"at\",at:\"ISO-8601\"}; timezone-less = UTC.\n- every: {kind:\"every\",everyMs:<ms>,anchorMs?}.\n- cron: {kind:\"cron\",expr:\"...\",tz?:\"IANA\"}. Expr is requested local wall time; never pre-convert to UTC. Missing tz = Gateway host local, not UTC. Shanghai 18:00: {kind:\"cron\",expr:\"0 18 * * *\",tz:\"Asia/Shanghai\"}.\n\nTRIGGER SCRIPT:\n- Requires cron.triggers.enabled; if off, explain and never model-poll fallback.\n- Headless owner allowlist; quiet check has no model. Prior trigger.state is frozen JSON. Return/json({fire:boolean,message?:string,state?:JSONValue}); create new state, never mutate prior.\n- fire:false saves state only; no payload/history. fire:true runs payload and appends message; fired state saves only after payload success.\n- Fire on every actionable state, including failures/timeouts; success-only watchers go silent when broken, which looks healthy. Dedupe by comparing trigger.state and returning new state, never memory.\n- Keep scripts read-only; actions belong in payload. message must be self-contained: it is the fired run's entire context.\n- Silent watcher: top-level delivery.mode=\"none\". Omitted delivery on isolated agentTurn announces and missing route may fail.\n- once:true disables after first successful fire. Per check: 30s, 5 tool calls, 16KB state.\n- Hidden Code Mode tools: await tools.call(\"exec\", {command:\"...\"}); unknown id => search/describe.\n\nDELIVERY top-level: {mode:\"none|announce|webhook\",channel?,to?,threadId?,bestEffort?}\n- Isolated agentTurn omitted delivery => announce. announce only isolated/current/session; channel/to optional; threadId chat topic. Specific chat: set channel/to; no messaging tool inside run.\n- webhook posts finished-run event to URL in to.\n\nRestricted isolated runs may only self status/list, current get/runs/remove, and next_check for their own paced job. wake mode: next-heartbeat default | now. jobId canonical; id compat. contextMessages 0-10 adds prior messages.",
|
||||
"inputSchema": {
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
@@ -652,7 +652,7 @@
|
||||
},
|
||||
"kind": {
|
||||
"description": "Payload kind",
|
||||
"enum": ["systemEvent", "agentTurn"],
|
||||
"enum": ["systemEvent", "agentTurn", "script"],
|
||||
"type": "string"
|
||||
},
|
||||
"lightContext": {
|
||||
@@ -666,6 +666,10 @@
|
||||
"description": "Model override",
|
||||
"type": "string"
|
||||
},
|
||||
"script": {
|
||||
"description": "Headless code-mode script",
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"description": "systemEvent text",
|
||||
"type": "string"
|
||||
@@ -678,6 +682,11 @@
|
||||
"minimum": 0,
|
||||
"type": "number"
|
||||
},
|
||||
"toolBudget": {
|
||||
"description": "Maximum script tool calls",
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"toolsAllow": {
|
||||
"description": "Allowed tools",
|
||||
"items": {
|
||||
@@ -1013,7 +1022,7 @@
|
||||
},
|
||||
"kind": {
|
||||
"description": "Payload kind",
|
||||
"enum": ["systemEvent", "agentTurn"],
|
||||
"enum": ["systemEvent", "agentTurn", "script"],
|
||||
"type": "string"
|
||||
},
|
||||
"lightContext": {
|
||||
@@ -1034,6 +1043,10 @@
|
||||
],
|
||||
"description": "Model override, or null to clear"
|
||||
},
|
||||
"script": {
|
||||
"description": "Headless code-mode script",
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"description": "systemEvent text",
|
||||
"type": "string"
|
||||
@@ -1046,6 +1059,11 @@
|
||||
"minimum": 0,
|
||||
"type": "number"
|
||||
},
|
||||
"toolBudget": {
|
||||
"description": "Maximum script tool calls",
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"toolsAllow": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
Vendored
+4
-4
@@ -216,8 +216,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 0
|
||||
},
|
||||
"dynamicToolsJson": {
|
||||
"chars": 54868,
|
||||
"roughTokens": 13717
|
||||
"chars": 55806,
|
||||
"roughTokens": 13952
|
||||
},
|
||||
"openClawDeveloperInstructions": {
|
||||
"chars": 3559,
|
||||
@@ -228,8 +228,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 7021
|
||||
},
|
||||
"totalWithDynamicToolsJson": {
|
||||
"chars": 82954,
|
||||
"roughTokens": 20739
|
||||
"chars": 83892,
|
||||
"roughTokens": 20973
|
||||
},
|
||||
"userInputText": {
|
||||
"chars": 1442,
|
||||
|
||||
test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md
Vendored
+4
-4
@@ -216,8 +216,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 0
|
||||
},
|
||||
"dynamicToolsJson": {
|
||||
"chars": 54595,
|
||||
"roughTokens": 13649
|
||||
"chars": 55533,
|
||||
"roughTokens": 13884
|
||||
},
|
||||
"openClawDeveloperInstructions": {
|
||||
"chars": 2450,
|
||||
@@ -228,8 +228,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 6642
|
||||
},
|
||||
"totalWithDynamicToolsJson": {
|
||||
"chars": 81163,
|
||||
"roughTokens": 20291
|
||||
"chars": 82101,
|
||||
"roughTokens": 20526
|
||||
},
|
||||
"userInputText": {
|
||||
"chars": 1033,
|
||||
|
||||
Vendored
+4
-4
@@ -217,8 +217,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 0
|
||||
},
|
||||
"dynamicToolsJson": {
|
||||
"chars": 55885,
|
||||
"roughTokens": 13972
|
||||
"chars": 56823,
|
||||
"roughTokens": 14206
|
||||
},
|
||||
"openClawDeveloperInstructions": {
|
||||
"chars": 2469,
|
||||
@@ -229,8 +229,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 6777
|
||||
},
|
||||
"totalWithDynamicToolsJson": {
|
||||
"chars": 82992,
|
||||
"roughTokens": 20748
|
||||
"chars": 83930,
|
||||
"roughTokens": 20983
|
||||
},
|
||||
"userInputText": {
|
||||
"chars": 1271,
|
||||
|
||||
Reference in New Issue
Block a user