// Shared cron CLI formatting, parsing, delivery preview, and warning helpers. import { MAX_DATE_TIMESTAMP_MS, parseStrictNonNegativeInteger, parseStrictPositiveInteger, resolveExpiresAtMsFromDurationMs, timestampMsToIsoString, } from "@openclaw/normalization-core/number-coercion"; import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { GatewayClientRequestError } from "../../../packages/gateway-client/src/request-error.js"; import { readCronJobNotFoundError } from "../../../packages/gateway-protocol/src/index.js"; import { truncateToVisibleWidth, visibleWidth } from "../../../packages/terminal-core/src/ansi.js"; import { sanitizeTerminalText } from "../../../packages/terminal-core/src/safe-text.js"; import { colorize, isRich, theme } from "../../../packages/terminal-core/src/theme.js"; import { listChannelPlugins } from "../../channels/plugins/index.js"; import { parseAbsoluteTimeMs } from "../../cron/parse.js"; import { resolveCronStaggerMs } from "../../cron/stagger.js"; import type { CronDeliveryPreview, CronJob, CronSchedule } from "../../cron/types.js"; import { danger } from "../../globals.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { formatExactDuration } from "../../infra/format-time/format-duration-exact.js"; import { formatDurationHuman } from "../../infra/format-time/format-duration.ts"; import { parseOffsetlessIsoDateTimeInTimeZone } from "../../infra/format-time/parse-offsetless-zoned-datetime.js"; import { formatTimestamp } from "../../logging/timestamps.js"; import { defaultRuntime, ExitError, type RuntimeEnv } from "../../runtime.js"; import { isOffsetlessIsoDateTime } from "../../shared/iso-time.js"; import { formatLookupMiss } from "../error-format.js"; import { ExpectedCliError, formatCliOperatorError, rethrowExpectedCliError, } from "../failure-output.js"; import type { GatewayRpcOpts } from "../gateway-rpc.js"; import { callGatewayFromCli } from "../gateway-rpc.js"; import { isJsonOutputModeActive } from "../json-output-mode.js"; import { exitCliAfterOutput } from "../one-shot-exit.js"; import { parseDurationMs as parseSharedDurationMs } from "../parse-duration.js"; import { CronCliError } from "./cron-cli-error.js"; export function parseCronIntegerOption( value: unknown, flag: string, kind: "positive" | "non-negative" = "positive", ): number | undefined { const parsed = kind === "non-negative" ? parseStrictNonNegativeInteger(value) : parseStrictPositiveInteger(value); if (value !== undefined && parsed === undefined) { throw new CronCliError(`Invalid ${flag} (must be a ${kind} integer).`); } return parsed; } export function parseCronNoOutputTimeoutOption(opts: Record): number | undefined { // Commander strips the leading no- from this option's attribute name. const raw = opts.noOutputTimeoutSeconds ?? (typeof opts.outputTimeoutSeconds === "string" || typeof opts.outputTimeoutSeconds === "number" ? opts.outputTimeoutSeconds : undefined); return parseCronIntegerOption(raw, "--no-output-timeout-seconds"); } function parseCronArgv(value: unknown, flag: string): string[] | undefined { if (typeof value !== "string") { return undefined; } let parsed: unknown; try { parsed = JSON.parse(value); } catch { throw new CronCliError(`${flag} must be a JSON array of strings`); } if ( !Array.isArray(parsed) || parsed.length === 0 || parsed.some((entry) => typeof entry !== "string" || entry.length === 0) ) { throw new CronCliError(`${flag} must be a non-empty JSON array of non-empty strings`); } return parsed; } export function parseCronCommandArgv(value: unknown): string[] | undefined { return parseCronArgv(value, "--command-argv"); } export function parseCronStreamCommandArgv(value: unknown): string[] | undefined { return parseCronArgv(value, "--stream-command"); } export function parseCronCommandEnv(values: unknown): Record | undefined { const rawValues = Array.isArray(values) ? values : typeof values === "string" ? [values] : []; if (rawValues.length === 0) { return undefined; } const env: Record = {}; for (const raw of rawValues) { if (typeof raw !== "string") { throw new CronCliError("--command-env must be KEY=VALUE"); } const idx = raw.indexOf("="); const key = idx > 0 ? raw.slice(0, idx).trim() : ""; if (!key) { throw new CronCliError("--command-env must be KEY=VALUE"); } env[key] = raw.slice(idx + 1); } return env; } export const getCronChannelOptions = () => { // Keep help truthful even before the plugin registry is bootstrapped. The fallback names the // channel plugin id the runtime resolves, not a per-conversation platform channel identifier. const pluginIds = listChannelPlugins() .map((plugin) => plugin.id) .filter(Boolean); return pluginIds.length > 0 ? ["last", ...pluginIds].join("|") : "last|"; }; function toLocalIsoTime(value: unknown): string | undefined { return typeof value === "number" && Number.isFinite(value) ? formatTimestamp(new Date(value), { style: "long" }) : undefined; } /** * CLI-only display enrichment for `cron runs` history entries: adds a short * `cause` alias for `errorReason` plus readable local-offset ISO mirrors of the * numeric timestamps (matching the diagnostic log `time` format). Stored data * and the gateway protocol stay unchanged; raw numeric fields are preserved. */ function enrichCronRunEntriesForDisplay(value: unknown): unknown { if (!value || typeof value !== "object") { return value; } const record = value as Record; const entries = record.entries; if (!Array.isArray(entries)) { return value; } const nextEntries = entries.map((entry) => { if (!entry || typeof entry !== "object") { return entry; } const item = entry as Record; if (item.action !== "finished") { return item; } const extra: Record = {}; const cause = typeof item.errorReason === "string" ? item.errorReason.trim() : ""; if (cause) { extra.cause = cause; } const tsIso = toLocalIsoTime(item.ts); if (tsIso) { extra.tsIso = tsIso; } const runAtIso = toLocalIsoTime(item.runAtMs); if (runAtIso) { extra.runAtIso = runAtIso; } const nextRunAtIso = toLocalIsoTime(item.nextRunAtMs); if (nextRunAtIso) { extra.nextRunAtIso = nextRunAtIso; } return Object.keys(extra).length > 0 ? Object.assign({}, item, extra) : item; }); return { ...record, entries: nextEntries }; } export function printCronJson(value: unknown) { defaultRuntime.writeJson(enrichCronRunEntriesForDisplay(value)); } /** * Enrich a CronJob (or list response) with a computed `status` field * derived from enabled + state.runningAtMs + state.lastRunStatus. * This mirrors the human-readable status shown by `cron list` / `cron show`. */ export function enrichCronJsonWithStatus(value: unknown): unknown { if (!value || typeof value !== "object") { return value; } const obj = value as Record; // Single job object (has 'state' and 'enabled') if ("state" in obj && "enabled" in obj) { return { ...obj, status: computeStatus(obj) }; } // List response (has 'jobs' array) if ("jobs" in obj && Array.isArray(obj.jobs)) { const enrichedJobs = (obj.jobs as CronJob[]).map((job) => { const status = computeStatus(job); return Object.assign({}, job, { status }); }); return { ...obj, jobs: enrichedJobs }; } return value; } function computeStatus(job: { enabled?: unknown; state?: unknown }): string { const state = asOptionalRecord(job.state) ?? {}; if (state.runningAtMs) { return "running"; } if (!job.enabled) { return "disabled"; } return typeof state.lastRunStatus === "string" ? state.lastRunStatus : typeof state.lastStatus === "string" ? state.lastStatus : "idle"; } // Human-facing decoration only: enrichCronJsonWithStatus() emits computeStatus() // verbatim as the --json `status` field, so failure and disable detail stays out of it. function decorateStatusWithFailures(status: string, consecutiveErrors: number | undefined): string { const failures = consecutiveErrors ?? 0; if (status !== "error" || failures <= 1) { return status; } // Capped so the Status column never overflows (a minute cron failing for a day // reaches 4 digits); past 99 the exact figure adds nothing over "chronic". return failures > 99 ? `${status} (99+x)` : `${status} (${failures}x)`; } function formatCronStatusForDisplay(job: CronJob) { const state = job.state ?? {}; const status = computeStatus(job); const streamDisabled = job.enabled && job.schedule?.kind === "stream" && state.streamStatus === "disabled"; const undelivered = status === "ok" && state.lastDeliveryStatus === "not-delivered"; const suppressed = undelivered && !streamDisabled && state.deliverySuppressionReason !== undefined; // The recorded non-outcome, not completion success, distinguishes silence from failed best-effort delivery. const color = status === "error" ? theme.error : status === "running" || (undelivered && !suppressed) ? theme.warn : status === "ok" ? theme.success : theme.muted; let label = decorateStatusWithFailures(status, state.consecutiveErrors); if (streamDisabled && status !== "running") { label = "disabled"; } else if (status === "disabled" && state.autoDisabled) { label = state.autoDisabled.reason === "schedule-errors" ? "disabled (schedule)" : `disabled (${state.autoDisabled.consecutiveErrors}x)`; } else if (undelivered) { label = suppressed ? "ok (suppressed)" : "ok (not delivered)"; } return { label, color }; } export function handleCronCliError(err: unknown) { // Completed outcomes must reach CLI cleanup, not become new cron errors. if (err instanceof ExitError) { throw err; } rethrowExpectedCliError(err); const missingJob = readCronJobNotFoundError(err); const diagnostic = err instanceof CronCliError ? (err.originalError ?? err) : err; if (isJsonOutputModeActive(process.argv)) { if ( !missingJob && !(err instanceof CronCliError) && !(err instanceof GatewayClientRequestError) ) { throw err; } // Both machine-mode streams share the canonical debug gate. Unexpected // exceptions keep their identity and reach the root crash renderer. const message = missingJob ? formatCronLookupMiss(missingJob.jobId) : formatCliOperatorError(diagnostic); throw new ExpectedCliError({ message, humanOutput: danger(message), machineOutput: message, }); } const message = missingJob ? formatCronLookupMiss(missingJob.jobId) : formatErrorMessage(diagnostic); defaultRuntime.error(danger(message)); exitCliAfterOutput(defaultRuntime, 1); } export const formatCronLookupMiss = (jobId: string) => formatLookupMiss({ noun: "Automation", value: sanitizeTerminalText(jobId), listCommand: "openclaw cron list", valueLabel: "automation id", }); // A blank id usually comes from an empty shell variable; reject it here instead of // letting the Gateway answer with a raw params-schema error. export function requireCronJobId(id: unknown, accepted = "Pass it positionally."): string { const jobId = normalizeOptionalString(id); if (!jobId) { throw new CronCliError(`Missing job id. ${accepted}`); } return jobId; } export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) { // Old/offline gateways should not make successful cron mutations fail after the fact. try { const res = (await callGatewayFromCli("cron.status", opts, {})) as { enabled?: boolean; storePath?: string; storage?: string; sqlitePath?: string; }; if (res?.enabled !== false) { return; } const store = typeof res?.sqlitePath === "string" ? res.sqlitePath : typeof res?.storePath === "string" ? res.storePath : ""; defaultRuntime.error( [ "warning: the automations scheduler is disabled in the Gateway; jobs are saved but will not run automatically.", "To enable automatic runs, set `cron.enabled: true` (or remove `cron.enabled: false`), remove `OPENCLAW_SKIP_CRON=1` from the Gateway's launch environment, and restart the Gateway.", store ? `store: ${store}` : "", ] .filter(Boolean) .join("\n"), ); } catch { // Ignore status failures (older gateway, offline, etc.) } } export function parsePositiveCronDurationMs(input: string): number | null { try { const result = parseSharedDurationMs(input); return result > 0 && result <= MAX_DATE_TIMESTAMP_MS ? result : null; } catch { return null; } } export function parseCronStaggerMs(params: { staggerRaw: string; useExact: boolean; }): number | undefined { if (params.useExact) { return 0; } if (!params.staggerRaw) { return undefined; } const parsed = parsePositiveCronDurationMs(params.staggerRaw); if (!parsed) { throw new CronCliError("Invalid --stagger; use e.g. 30s, 1m, 5m"); } return parsed; } export function parseCronStringList(input: unknown): string[] | undefined { if (input === undefined) { return undefined; } const raw = Array.isArray(input) ? input.map((value) => String(value)).join(" ") : typeof input === "string" ? input : ""; return raw .split(/[,\s]+/u) .map((entry) => normalizeOptionalString(entry)) .filter((entry): entry is string => Boolean(entry)); } /** * Parse a one-shot `--at` value into an ISO string (UTC). * * When `tz` is provided and the input is an offset-less datetime * (e.g. `2026-03-23T23:00:00`), the datetime is interpreted in * that IANA timezone instead of UTC. */ export function parseAt(input: string, tz?: string): string | null { const raw = input.trim(); if (!raw) { return null; } // If a timezone is provided and the input looks like an offset-less ISO datetime, // resolve it in the given IANA timezone so users get the time they expect. if (tz && isOffsetlessIsoDateTime(raw)) { return parseOffsetlessIsoDateTimeInTimeZone(raw, tz); } const absolute = parseAbsoluteTimeMs(raw); if (absolute !== null) { return timestampMsToIsoString(absolute) ?? null; } const durationInput = raw.startsWith("+") ? raw.slice(1) : raw; const dur = parsePositiveCronDurationMs(durationInput); if (dur !== null) { const expiresAt = resolveExpiresAtMsFromDurationMs(dur); return timestampMsToIsoString(expiresAt) ?? null; } return null; } const CRON_ID_PAD = 36; const CRON_DECLARATION_PAD = 24; const CRON_NAME_PAD = 24; const CRON_SCHEDULE_PAD = 32; const CRON_NEXT_PAD = 10; const CRON_LAST_PAD = 10; const CRON_STATUS_PAD = 19; const CRON_TARGET_PAD = 9; const CRON_DELIVERY_PAD = 64; const CRON_AGENT_PAD = 10; const CRON_OWNER_PAD = 24; const CRON_MODEL_PAD = 20; const TRUNCATED_SUFFIX = "..."; const stringifyCell = (value: unknown, fallback = "-") => { if (typeof value === "string") { return value; } if (typeof value === "number" || typeof value === "boolean") { return String(value); } return fallback; }; const formatCell = (value: unknown, width: number) => { const text = sanitizeTerminalText(stringifyCell(value)); const truncated = visibleWidth(text) <= width ? text : width <= TRUNCATED_SUFFIX.length ? truncateToVisibleWidth(text, width) : `${truncateToVisibleWidth(text, width - TRUNCATED_SUFFIX.length)}${TRUNCATED_SUFFIX}`; const remaining = width - visibleWidth(truncated); return remaining > 0 ? `${truncated}${" ".repeat(remaining)}` : truncated; }; const formatIsoMinute = (iso: string) => { const isoStr = timestampMsToIsoString(parseAbsoluteTimeMs(iso)); // Date.toISOString() has a fixed :ss.sssZ suffix but variable-width years. return isoStr ? `${isoStr.slice(0, -8).replace("T", " ")}Z` : "-"; }; const formatSpan = (ms: number) => (ms < 60_000 ? "<1m" : formatDurationHuman(ms)); const formatRelative = (ms: number | null | undefined, nowMs: number) => { if (!ms) { return "-"; } const delta = ms - nowMs; const label = formatSpan(Math.abs(delta)); return delta >= 0 ? `in ${label}` : `${label} ago`; }; const formatSchedule = (schedule: CronSchedule | undefined, hasTrigger = false) => { const suffix = hasTrigger ? "+trigger" : ""; if (schedule?.kind === "at") { return `at ${formatIsoMinute(schedule.at)}${suffix}`; } if (schedule?.kind === "every") { return `every ${formatExactDuration(schedule.everyMs)}${suffix}`; } if (schedule?.kind === "on-exit") { const cwd = schedule.cwd ? ` @ ${schedule.cwd}` : ""; return `on-exit ${schedule.command}${cwd}`; } if (schedule?.kind === "stream") { const cwd = schedule.cwd ? ` @ ${schedule.cwd}` : ""; return `stream ${schedule.command.join(" ")}${cwd}${suffix}`; } if (schedule?.kind !== "cron") { return "-"; } const base = schedule.tz ? `cron ${schedule.expr} @ ${schedule.tz}${suffix}` : `cron ${schedule.expr}${suffix}`; const staggerMs = resolveCronStaggerMs(schedule); if (staggerMs <= 0) { return `${base} (exact)`; } return `${base} (stagger ${formatExactDuration(staggerMs)})`; }; export function coerceCronDeliveryPreviews(value: unknown): Map { const previews = value && typeof value === "object" ? (value as { deliveryPreviews?: unknown }).deliveryPreviews : undefined; if (!previews || typeof previews !== "object") { return new Map(); } return new Map( Object.entries(previews as Record).flatMap(([jobId, preview]) => { if (!preview || typeof preview !== "object") { return []; } const record = preview as { label?: unknown; detail?: unknown }; if (typeof record.label !== "string" || typeof record.detail !== "string") { return []; } return [[jobId, { label: record.label, detail: record.detail }]]; }), ); } export function printCronList( jobs: Array, runtime: RuntimeEnv = defaultRuntime, opts?: { deliveryPreviews?: Map }, ) { if (jobs.length === 0) { runtime.log("No automations."); return; } const rich = isRich(); const header = [ formatCell("ID", CRON_ID_PAD), formatCell("Declaration", CRON_DECLARATION_PAD), formatCell("Name", CRON_NAME_PAD), formatCell("Schedule", CRON_SCHEDULE_PAD), formatCell("Next", CRON_NEXT_PAD), formatCell("Last", CRON_LAST_PAD), formatCell("Status", CRON_STATUS_PAD), formatCell("Target", CRON_TARGET_PAD), formatCell("Delivery", CRON_DELIVERY_PAD), formatCell("Agent ID", CRON_AGENT_PAD), formatCell("Owner", CRON_OWNER_PAD), formatCell("Model", CRON_MODEL_PAD), ].join(" "); const lines = [rich ? theme.heading(header) : header]; const now = Date.now(); for (const job of jobs) { const state = job.state ?? {}; const idLabel = formatCell(job.id, CRON_ID_PAD); const declarationLabel = formatCell(job.declarationKey, CRON_DECLARATION_PAD); const nameLabel = formatCell(job.displayName ?? job.name, CRON_NAME_PAD); const scheduleLabel = formatCell( formatSchedule(job.schedule, job.trigger !== undefined), CRON_SCHEDULE_PAD, ); const nextLabel = formatCell( job.enabled ? formatRelative(state.nextRunAtMs, now) : "-", CRON_NEXT_PAD, ); const lastLabel = formatCell(formatRelative(state.lastRunAtMs, now), CRON_LAST_PAD); const status = formatCronStatusForDisplay(job); const statusLabel = formatCell(status.label, CRON_STATUS_PAD); const targetLabel = formatCell(job.sessionTarget, CRON_TARGET_PAD); const deliveryPreview = opts?.deliveryPreviews?.get(job.id); const deliveryText = deliveryPreview ? `${deliveryPreview.label} (${deliveryPreview.detail})` : "-"; const deliveryLabel = formatCell(deliveryText, CRON_DELIVERY_PAD); const agentId = job.effectiveAgentId ?? job.agentId; const agentLabel = formatCell(agentId ?? "unresolved", CRON_AGENT_PAD); const ownerLabel = formatCell(job.owner?.sessionKey ?? job.owner?.agentId, CRON_OWNER_PAD); const modelLabel = formatCell( job.payload?.kind === "agentTurn" ? job.payload.model : undefined, CRON_MODEL_PAD, ); const coloredTarget = job.sessionTarget === "main" ? colorize(rich, theme.accent, targetLabel) : colorize(rich, theme.accentBright, targetLabel); const coloredAgent = agentId ? colorize(rich, theme.info, agentLabel) : colorize(rich, theme.muted, agentLabel); const line = [ colorize(rich, theme.accent, idLabel), colorize(rich, theme.muted, declarationLabel), colorize(rich, theme.info, nameLabel), colorize(rich, theme.info, scheduleLabel), colorize(rich, theme.muted, nextLabel), colorize(rich, theme.muted, lastLabel), colorize(rich, status.color, statusLabel), coloredTarget, deliveryPreview ? colorize(rich, theme.info, deliveryLabel) : colorize(rich, theme.muted, deliveryLabel), coloredAgent, colorize(rich, job.owner ? theme.info : theme.muted, ownerLabel), job.payload?.kind === "agentTurn" && job.payload.model ? colorize(rich, theme.info, modelLabel) : colorize(rich, theme.muted, modelLabel), ].join(" "); lines.push(line.trimEnd()); } runtime.log(lines.join("\n")); } export function printCronShow( job: CronJob, runtime: RuntimeEnv = defaultRuntime, opts?: { deliveryPreview?: CronDeliveryPreview }, ) { const preview = opts?.deliveryPreview ?? { label: "-", detail: "unavailable" }; const showValue = (value: unknown) => sanitizeTerminalText(stringifyCell(value)); runtime.log(`id: ${showValue(job.id)}`); runtime.log(`declaration: ${showValue(job.declarationKey)}`); runtime.log(`name: ${showValue(job.name)}`); runtime.log(`display name: ${showValue(job.displayName)}`); runtime.log(`owner agent: ${showValue(job.owner?.agentId)}`); runtime.log(`owner session: ${showValue(job.owner?.sessionKey)}`); runtime.log(`enabled: ${job.enabled ? "yes" : "no"}`); runtime.log(`schedule: ${showValue(formatSchedule(job.schedule, job.trigger !== undefined))}`); if (job.schedule?.kind === "stream") { runtime.log(`stream status: ${showValue(job.state.streamStatus)}`); runtime.log(`stream error: ${showValue(job.state.streamError)}`); } runtime.log( `trigger: ${job.trigger ? `once=${job.trigger.once === true ? "yes" : "no"}; evals=${job.state.triggerEvalCount ?? 0}; last eval=${formatRelative(job.state.lastTriggerEvalAtMs, Date.now())}; last fire=${formatRelative(job.state.lastTriggerFireAtMs, Date.now())}` : "-"}`, ); runtime.log(`session: ${showValue(job.sessionTarget)}`); runtime.log(`agent: ${showValue(job.agentId)}`); runtime.log( `model: ${showValue(job.payload.kind === "agentTurn" ? job.payload.model : undefined)}`, ); runtime.log(`delivery: ${showValue(preview.label)} (${showValue(preview.detail)})`); runtime.log(`next: ${formatRelative(job.state.nextRunAtMs, Date.now())}`); runtime.log(`last: ${formatRelative(job.state.lastRunAtMs, Date.now())}`); runtime.log(`status: ${showValue(formatCronStatusForDisplay(job).label)}`); // lastError is the run/schedule failure message; the diagnostic line below is // the run-diagnostics summary and can be empty when only lastError is set. runtime.log(`last error: ${showValue(job.state.lastError)}`); runtime.log(`last delivery: ${showValue(job.state.lastDeliveryStatus)}`); runtime.log(`last delivery suppression: ${showValue(job.state.deliverySuppressionReason)}`); runtime.log(`last delivery error: ${showValue(job.state.lastDeliveryError)}`); runtime.log(`diagnostic: ${showValue(job.state.lastDiagnosticSummary)}`); }