mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-27 05:49:32 +00:00
cron list/show printed "idle" for a job whose status is ok/error/skipped when only lastRunStatus (the primary field) was set: formatStatus used `lastStatus ?? "idle"` and omitted lastRunStatus, diverging from computeStatus (the --json status resolver) whose JSDoc says it mirrors the human output. Delete the duplicate formatStatus and render via the canonical computeStatus. Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
526 lines
16 KiB
TypeScript
526 lines
16 KiB
TypeScript
// Shared cron CLI formatting, parsing, delivery preview, and warning helpers.
|
|
import {
|
|
resolveExpiresAtMsFromDurationMs,
|
|
timestampMsToIsoString,
|
|
} from "@openclaw/normalization-core/number-coercion";
|
|
import {
|
|
normalizeLowercaseStringOrEmpty,
|
|
normalizeOptionalString,
|
|
} from "@openclaw/normalization-core/string-coerce";
|
|
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 { formatDurationHuman } from "../../infra/format-time/format-duration.ts";
|
|
import {
|
|
isOffsetlessIsoDateTime,
|
|
parseOffsetlessIsoDateTimeInTimeZone,
|
|
} from "../../infra/format-time/parse-offsetless-zoned-datetime.js";
|
|
import { formatTimestamp } from "../../logging/timestamps.js";
|
|
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
|
import type { GatewayRpcOpts } from "../gateway-rpc.js";
|
|
import { callGatewayFromCli } from "../gateway-rpc.js";
|
|
|
|
export function parseCronCommandArgv(value: unknown): string[] | undefined {
|
|
if (typeof value !== "string") {
|
|
return undefined;
|
|
}
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(value);
|
|
} catch {
|
|
throw new Error("--command-argv 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 Error("--command-argv must be a non-empty JSON array of non-empty strings");
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
export function parseCronCommandEnv(values: unknown): Record<string, string> | undefined {
|
|
const rawValues = Array.isArray(values) ? values : typeof values === "string" ? [values] : [];
|
|
if (rawValues.length === 0) {
|
|
return undefined;
|
|
}
|
|
const env: Record<string, string> = {};
|
|
for (const raw of rawValues) {
|
|
if (typeof raw !== "string") {
|
|
throw new Error("--command-env must be KEY=VALUE");
|
|
}
|
|
const idx = raw.indexOf("=");
|
|
const key = idx > 0 ? raw.slice(0, idx).trim() : "";
|
|
if (!key) {
|
|
throw new Error("--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.
|
|
const pluginIds = listChannelPlugins()
|
|
.map((plugin) => plugin.id)
|
|
.filter(Boolean);
|
|
return pluginIds.length > 0 ? ["last", ...pluginIds].join("|") : "last|<channel-id>";
|
|
};
|
|
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
if (item.action !== "finished") {
|
|
return item;
|
|
}
|
|
const extra: Record<string, unknown> = {};
|
|
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<string, unknown>;
|
|
|
|
// Single job object (has 'state' and 'enabled')
|
|
if ("state" in obj && "enabled" in obj) {
|
|
return { ...obj, status: computeStatus(obj as unknown as CronJob) };
|
|
}
|
|
|
|
// 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: CronJob): string {
|
|
if (!job.enabled) {
|
|
return "disabled";
|
|
}
|
|
const state = job.state ?? {};
|
|
if (state.runningAtMs) {
|
|
return "running";
|
|
}
|
|
return state.lastRunStatus ?? state.lastStatus ?? "idle";
|
|
}
|
|
|
|
export function handleCronCliError(err: unknown) {
|
|
defaultRuntime.error(danger(String(err)));
|
|
defaultRuntime.exit(1);
|
|
}
|
|
|
|
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 === true) {
|
|
return;
|
|
}
|
|
const store =
|
|
typeof res?.sqlitePath === "string"
|
|
? res.sqlitePath
|
|
: typeof res?.storePath === "string"
|
|
? res.storePath
|
|
: "";
|
|
defaultRuntime.error(
|
|
[
|
|
"warning: cron scheduler is disabled in the Gateway; jobs are saved but will not run automatically.",
|
|
"Re-enable with `cron.enabled: true` (or remove `cron.enabled: false`) and restart the Gateway.",
|
|
store ? `store: ${store}` : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n"),
|
|
);
|
|
} catch {
|
|
// Ignore status failures (older gateway, offline, etc.)
|
|
}
|
|
}
|
|
|
|
export function parseDurationMs(input: string): number | null {
|
|
const raw = input.trim();
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
const match = raw.match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/i);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
const n = Number.parseFloat(match[1] ?? "");
|
|
if (!Number.isFinite(n) || n <= 0) {
|
|
return null;
|
|
}
|
|
const unit = normalizeLowercaseStringOrEmpty(match[2] ?? "");
|
|
const factor =
|
|
unit === "ms"
|
|
? 1
|
|
: unit === "s"
|
|
? 1000
|
|
: unit === "m"
|
|
? 60_000
|
|
: unit === "h"
|
|
? 3_600_000
|
|
: 86_400_000;
|
|
const result = Math.floor(n * factor);
|
|
if (!Number.isFinite(result)) {
|
|
// A finite mantissa can still overflow to Infinity for a large unit (e.g. a long
|
|
// pure-digit string with "d"); reject it instead of returning Infinity ms.
|
|
return null;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function parseCronStaggerMs(params: {
|
|
staggerRaw: string;
|
|
useExact: boolean;
|
|
}): number | undefined {
|
|
if (params.useExact) {
|
|
return 0;
|
|
}
|
|
if (!params.staggerRaw) {
|
|
return undefined;
|
|
}
|
|
const parsed = parseDurationMs(params.staggerRaw);
|
|
if (!parsed) {
|
|
throw new Error("Invalid --stagger; use e.g. 30s, 1m, 5m");
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
export function parseCronToolsAllow(input: unknown): string[] | undefined {
|
|
const raw = Array.isArray(input)
|
|
? input.map((value) => String(value)).join(" ")
|
|
: typeof input === "string"
|
|
? input
|
|
: "";
|
|
const tools = raw
|
|
.split(/[,\s]+/u)
|
|
.map((tool) => normalizeOptionalString(tool))
|
|
.filter((tool): tool is string => Boolean(tool));
|
|
return tools.length > 0 ? tools : undefined;
|
|
}
|
|
|
|
/**
|
|
* 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 = parseDurationMs(durationInput);
|
|
if (dur !== null) {
|
|
const expiresAt = resolveExpiresAtMsFromDurationMs(dur);
|
|
return timestampMsToIsoString(expiresAt) ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const CRON_ID_PAD = 36;
|
|
const CRON_NAME_PAD = 24;
|
|
const CRON_SCHEDULE_PAD = 32;
|
|
const CRON_NEXT_PAD = 10;
|
|
const CRON_LAST_PAD = 10;
|
|
const CRON_STATUS_PAD = 9;
|
|
const CRON_TARGET_PAD = 9;
|
|
const CRON_DELIVERY_PAD = 64;
|
|
const CRON_AGENT_PAD = 10;
|
|
const CRON_MODEL_PAD = 20;
|
|
|
|
const stringifyCell = (value: unknown, fallback = "-") => {
|
|
if (typeof value === "string") {
|
|
return value;
|
|
}
|
|
if (typeof value === "number" || typeof value === "boolean") {
|
|
return String(value);
|
|
}
|
|
return fallback;
|
|
};
|
|
|
|
const pad = (value: unknown, width: number) => stringifyCell(value).padEnd(width);
|
|
|
|
const truncate = (value: string, width: number) => {
|
|
if (value.length <= width) {
|
|
return value;
|
|
}
|
|
if (width <= 3) {
|
|
return value.slice(0, width);
|
|
}
|
|
return `${value.slice(0, width - 3)}...`;
|
|
};
|
|
|
|
const formatIsoMinute = (iso: string) => {
|
|
const parsed = parseAbsoluteTimeMs(iso);
|
|
const d = new Date(parsed ?? Number.NaN);
|
|
if (Number.isNaN(d.getTime())) {
|
|
return "-";
|
|
}
|
|
const isoStr = d.toISOString();
|
|
return `${isoStr.slice(0, 10)} ${isoStr.slice(11, 16)}Z`;
|
|
};
|
|
|
|
const formatSpan = (ms: number) => {
|
|
if (ms < 60_000) {
|
|
return "<1m";
|
|
}
|
|
if (ms < 3_600_000) {
|
|
return `${Math.round(ms / 60_000)}m`;
|
|
}
|
|
if (ms < 86_400_000) {
|
|
return `${Math.round(ms / 3_600_000)}h`;
|
|
}
|
|
return `${Math.round(ms / 86_400_000)}d`;
|
|
};
|
|
|
|
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) => {
|
|
if (schedule?.kind === "at") {
|
|
return `at ${formatIsoMinute(schedule.at)}`;
|
|
}
|
|
if (schedule?.kind === "every") {
|
|
return `every ${formatDurationHuman(schedule.everyMs)}`;
|
|
}
|
|
if (schedule?.kind !== "cron") {
|
|
return "-";
|
|
}
|
|
const base = schedule.tz ? `cron ${schedule.expr} @ ${schedule.tz}` : `cron ${schedule.expr}`;
|
|
const staggerMs = resolveCronStaggerMs(schedule);
|
|
if (staggerMs <= 0) {
|
|
return `${base} (exact)`;
|
|
}
|
|
return `${base} (stagger ${formatDurationHuman(staggerMs)})`;
|
|
};
|
|
|
|
export function coerceCronDeliveryPreviews(value: unknown): Map<string, CronDeliveryPreview> {
|
|
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<string, unknown>).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: CronJob[],
|
|
runtime: RuntimeEnv = defaultRuntime,
|
|
opts?: { deliveryPreviews?: Map<string, CronDeliveryPreview> },
|
|
) {
|
|
if (jobs.length === 0) {
|
|
runtime.log("No cron jobs.");
|
|
return;
|
|
}
|
|
|
|
const rich = isRich();
|
|
const header = [
|
|
pad("ID", CRON_ID_PAD),
|
|
pad("Name", CRON_NAME_PAD),
|
|
pad("Schedule", CRON_SCHEDULE_PAD),
|
|
pad("Next", CRON_NEXT_PAD),
|
|
pad("Last", CRON_LAST_PAD),
|
|
pad("Status", CRON_STATUS_PAD),
|
|
pad("Target", CRON_TARGET_PAD),
|
|
pad("Delivery", CRON_DELIVERY_PAD),
|
|
pad("Agent ID", CRON_AGENT_PAD),
|
|
pad("Model", CRON_MODEL_PAD),
|
|
].join(" ");
|
|
|
|
runtime.log(rich ? theme.heading(header) : header);
|
|
const now = Date.now();
|
|
|
|
for (const job of jobs) {
|
|
const state = job.state ?? {};
|
|
const idLabel = pad(job.id, CRON_ID_PAD);
|
|
const nameLabel = pad(truncate(stringifyCell(job.name), CRON_NAME_PAD), CRON_NAME_PAD);
|
|
const scheduleLabel = pad(
|
|
truncate(formatSchedule(job.schedule), CRON_SCHEDULE_PAD),
|
|
CRON_SCHEDULE_PAD,
|
|
);
|
|
const nextLabel = pad(
|
|
job.enabled ? formatRelative(state.nextRunAtMs, now) : "-",
|
|
CRON_NEXT_PAD,
|
|
);
|
|
const lastLabel = pad(formatRelative(state.lastRunAtMs, now), CRON_LAST_PAD);
|
|
const statusRaw = computeStatus(job);
|
|
const statusLabel = pad(statusRaw, CRON_STATUS_PAD);
|
|
const targetLabel = pad(job.sessionTarget ?? "-", CRON_TARGET_PAD);
|
|
const deliveryPreview = opts?.deliveryPreviews?.get(job.id);
|
|
const deliveryText = deliveryPreview
|
|
? `${deliveryPreview.label} (${deliveryPreview.detail})`
|
|
: "-";
|
|
const deliveryLabel = pad(truncate(deliveryText, CRON_DELIVERY_PAD), CRON_DELIVERY_PAD);
|
|
const agentLabel = pad(truncate(job.agentId ?? "-", CRON_AGENT_PAD), CRON_AGENT_PAD);
|
|
const modelLabel = pad(
|
|
truncate(
|
|
(job.payload?.kind === "agentTurn" ? job.payload.model : undefined) ?? "-",
|
|
CRON_MODEL_PAD,
|
|
),
|
|
CRON_MODEL_PAD,
|
|
);
|
|
|
|
const coloredStatus = (() => {
|
|
if (statusRaw === "ok") {
|
|
return colorize(rich, theme.success, statusLabel);
|
|
}
|
|
if (statusRaw === "error") {
|
|
return colorize(rich, theme.error, statusLabel);
|
|
}
|
|
if (statusRaw === "running") {
|
|
return colorize(rich, theme.warn, statusLabel);
|
|
}
|
|
if (statusRaw === "skipped") {
|
|
return colorize(rich, theme.muted, statusLabel);
|
|
}
|
|
return colorize(rich, theme.muted, statusLabel);
|
|
})();
|
|
|
|
const coloredTarget =
|
|
job.sessionTarget === "main"
|
|
? colorize(rich, theme.accent, targetLabel)
|
|
: colorize(rich, theme.accentBright, targetLabel);
|
|
const coloredAgent = job.agentId
|
|
? colorize(rich, theme.info, agentLabel)
|
|
: colorize(rich, theme.muted, agentLabel);
|
|
|
|
const line = [
|
|
colorize(rich, theme.accent, idLabel),
|
|
colorize(rich, theme.info, nameLabel),
|
|
colorize(rich, theme.info, scheduleLabel),
|
|
colorize(rich, theme.muted, nextLabel),
|
|
colorize(rich, theme.muted, lastLabel),
|
|
coloredStatus,
|
|
coloredTarget,
|
|
deliveryPreview
|
|
? colorize(rich, theme.info, deliveryLabel)
|
|
: colorize(rich, theme.muted, deliveryLabel),
|
|
coloredAgent,
|
|
job.payload?.kind === "agentTurn" && job.payload.model
|
|
? colorize(rich, theme.info, modelLabel)
|
|
: colorize(rich, theme.muted, modelLabel),
|
|
].join(" ");
|
|
|
|
runtime.log(line.trimEnd());
|
|
}
|
|
}
|
|
|
|
export function printCronShow(
|
|
job: CronJob,
|
|
runtime: RuntimeEnv = defaultRuntime,
|
|
opts?: { deliveryPreview?: CronDeliveryPreview },
|
|
) {
|
|
const preview = opts?.deliveryPreview ?? { label: "-", detail: "unavailable" };
|
|
runtime.log(`id: ${job.id}`);
|
|
runtime.log(`name: ${job.name}`);
|
|
runtime.log(`enabled: ${job.enabled ? "yes" : "no"}`);
|
|
runtime.log(`schedule: ${formatSchedule(job.schedule)}`);
|
|
runtime.log(`session: ${job.sessionTarget ?? "-"}`);
|
|
runtime.log(`agent: ${job.agentId ?? "-"}`);
|
|
runtime.log(`model: ${job.payload.kind === "agentTurn" ? (job.payload.model ?? "-") : "-"}`);
|
|
runtime.log(`delivery: ${preview.label} (${preview.detail})`);
|
|
runtime.log(`next: ${formatRelative(job.state.nextRunAtMs, Date.now())}`);
|
|
runtime.log(`last: ${formatRelative(job.state.lastRunAtMs, Date.now())}`);
|
|
runtime.log(`status: ${computeStatus(job)}`);
|
|
runtime.log(`diagnostic: ${job.state.lastDiagnosticSummary ?? "-"}`);
|
|
}
|