import { finiteSecondsToTimerSafeMilliseconds } from "@openclaw/normalization-core/number-coercion"; import { runCommandWithTimeout } from "../process/exec.js"; import { buildCronCommandSummary, isCronCommandActionCriticalLine, } from "./command-output-summary.js"; import type { CronRunDiagnostics, CronRunOutcome, CronRunStatus, CronJob } from "./types.js"; const DEFAULT_COMMAND_TIMEOUT_MS = 10 * 60_000; const EFFECTIVELY_UNBOUNDED_TIMEOUT_MS = 2_147_483_647; function secondsToMs(value: number | undefined): number | undefined { if (typeof value !== "number") { return undefined; } if (value <= 0) { return EFFECTIVELY_UNBOUNDED_TIMEOUT_MS; } return finiteSecondsToTimerSafeMilliseconds(value) ?? undefined; } function formatCommand(argv: string[]): string { return argv.map((arg) => JSON.stringify(arg)).join(" "); } function commandErrorMessage(params: { code: number | null; signal: NodeJS.Signals | null; termination: string; }): string { if (params.termination === "timeout") { return "command timed out"; } if (params.termination === "no-output-timeout") { return "command produced no output before noOutputTimeoutSeconds"; } if (params.termination === "signal") { return params.signal ? `command stopped by signal ${params.signal}` : "command stopped"; } if (typeof params.code === "number") { return `command exited with code ${params.code}`; } return "command failed"; } function buildDiagnostics(params: { command: string; status: CronRunStatus; summary?: string; code: number | null; signal: NodeJS.Signals | null; stdoutTruncatedBytes?: number; stderrTruncatedBytes?: number; nowMs: () => number; }): CronRunDiagnostics { const truncated = Boolean(params.stdoutTruncatedBytes && params.stdoutTruncatedBytes > 0) || Boolean(params.stderrTruncatedBytes && params.stderrTruncatedBytes > 0); return { ...(params.summary ? { summary: params.summary } : {}), entries: [ { ts: params.nowMs(), source: "exec", severity: params.status === "ok" ? "info" : "error", message: params.summary ? `command ${params.status}: ${params.command}` : `command ${params.status} with no output: ${params.command}`, exitCode: params.code, truncated, ...(params.signal ? { toolName: `signal:${params.signal}` } : {}), }, ], }; } /** Executes a cron command payload without starting an agent/model run. */ export async function runCronCommandJob(params: { job: CronJob; abortSignal?: AbortSignal; nowMs?: () => number; }): Promise { const nowMs = params.nowMs ?? Date.now; const { payload } = params.job; if (payload.kind !== "command") { return { status: "skipped", error: 'command runner requires payload.kind="command"', }; } if (!Array.isArray(payload.argv) || payload.argv.length === 0) { return { status: "skipped", error: 'command payload requires non-empty "argv"', }; } const command = formatCommand(payload.argv); const noOutputTimeoutMs = secondsToMs(payload.noOutputTimeoutSeconds); try { const result = await runCommandWithTimeout(payload.argv, { timeoutMs: secondsToMs(payload.timeoutSeconds) ?? DEFAULT_COMMAND_TIMEOUT_MS, ...(payload.cwd ? { cwd: payload.cwd } : {}), ...(payload.input !== undefined ? { input: payload.input } : {}), ...(payload.env ? { env: payload.env } : {}), ...(noOutputTimeoutMs !== undefined ? { noOutputTimeoutMs } : {}), ...(payload.outputMaxBytes !== undefined ? { maxOutputBytes: payload.outputMaxBytes } : {}), preserveOutputLine: isCronCommandActionCriticalLine, ...(params.abortSignal ? { signal: params.abortSignal } : {}), killProcessTree: true, }); const termination = result.termination === "signal" && params.abortSignal?.reason instanceof Error && params.abortSignal.reason.name === "TimeoutError" ? "timeout" : result.termination; const ok = result.code === 0 && !result.killed && termination !== "timeout" && termination !== "no-output-timeout" && termination !== "signal"; const status: CronRunStatus = ok ? "ok" : "error"; const summary = buildCronCommandSummary({ stdout: result.stdout, stderr: result.stderr, preservedStdoutLines: result.preservedStdoutLines, preservedStderrLines: result.preservedStderrLines, }); const error = ok ? undefined : commandErrorMessage({ code: result.code, signal: result.signal, termination, }); const failureNotificationDetail = termination === "timeout" ? ({ kind: "command-timeout", mode: "wall-clock" } as const) : termination === "no-output-timeout" ? ({ kind: "command-timeout", mode: "no-output" } as const) : termination === "exit" && typeof result.code === "number" && result.code !== 0 ? ({ kind: "command-exit", exitCode: result.code } as const) : undefined; return { status, ...(error ? { error } : {}), ...(failureNotificationDetail ? { failureNotificationDetail, errorClassification: failureNotificationDetail.kind === "command-timeout" ? ({ kind: "reason", reason: "timeout" } as const) : ({ kind: "permanent" } as const), } : {}), ...(summary ? { summary } : {}), diagnostics: buildDiagnostics({ command, status, summary, code: result.code, signal: result.signal, stdoutTruncatedBytes: result.stdoutTruncatedBytes, stderrTruncatedBytes: result.stderrTruncatedBytes, nowMs, }), }; } catch (err) { const error = err instanceof Error ? err.message : String(err); return { status: "error", error, ...(err instanceof Error && "code" in err && err.code === "ENOENT" ? { errorClassification: { kind: "permanent" as const } } : {}), diagnostics: { summary: error, entries: [ { ts: nowMs(), source: "exec", severity: "error", message: `command failed to start: ${command}: ${error}`, exitCode: null, }, ], }, }; } }