190 lines
6.3 KiB
TypeScript
190 lines
6.3 KiB
TypeScript
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
|
import { parseAbsoluteTimeMs } from "../../cron/parse.js";
|
|
import type { CronJob, CronSchedule } from "../../cron/types.js";
|
|
import { defaultRuntime } from "../../runtime.js";
|
|
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
|
import type { GatewayRpcOpts } from "../gateway-rpc.js";
|
|
import { callGatewayFromCli } from "../gateway-rpc.js";
|
|
|
|
export const getCronChannelOptions = () =>
|
|
["last", ...listChannelPlugins().map((plugin) => plugin.id)].join("|");
|
|
|
|
export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) {
|
|
try {
|
|
const res = (await callGatewayFromCli("cron.status", opts, {})) as {
|
|
enabled?: boolean;
|
|
storePath?: string;
|
|
};
|
|
if (res?.enabled === true) return;
|
|
const store = 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 = (match[2] ?? "").toLowerCase();
|
|
const factor =
|
|
unit === "ms"
|
|
? 1
|
|
: unit === "s"
|
|
? 1000
|
|
: unit === "m"
|
|
? 60_000
|
|
: unit === "h"
|
|
? 3_600_000
|
|
: 86_400_000;
|
|
return Math.floor(n * factor);
|
|
}
|
|
|
|
export function parseAtMs(input: string): number | null {
|
|
const raw = input.trim();
|
|
if (!raw) return null;
|
|
const absolute = parseAbsoluteTimeMs(raw);
|
|
if (absolute) return absolute;
|
|
const dur = parseDurationMs(raw);
|
|
if (dur) return Date.now() + dur;
|
|
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_AGENT_PAD = 10;
|
|
|
|
const pad = (value: string, width: number) => 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 = (ms: number) => {
|
|
const d = new Date(ms);
|
|
if (Number.isNaN(d.getTime())) return "-";
|
|
const iso = d.toISOString();
|
|
return `${iso.slice(0, 10)} ${iso.slice(11, 16)}Z`;
|
|
};
|
|
|
|
const formatDuration = (ms: number) => {
|
|
if (ms < 60_000) return `${Math.max(1, Math.round(ms / 1000))}s`;
|
|
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 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) => {
|
|
if (schedule.kind === "at") return `at ${formatIsoMinute(schedule.atMs)}`;
|
|
if (schedule.kind === "every") return `every ${formatDuration(schedule.everyMs)}`;
|
|
return schedule.tz ? `cron ${schedule.expr} @ ${schedule.tz}` : `cron ${schedule.expr}`;
|
|
};
|
|
|
|
const formatStatus = (job: CronJob) => {
|
|
if (!job.enabled) return "disabled";
|
|
if (job.state.runningAtMs) return "running";
|
|
return job.state.lastStatus ?? "idle";
|
|
};
|
|
|
|
export function printCronList(jobs: CronJob[], runtime = defaultRuntime) {
|
|
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("Agent", CRON_AGENT_PAD),
|
|
].join(" ");
|
|
|
|
runtime.log(rich ? theme.heading(header) : header);
|
|
const now = Date.now();
|
|
|
|
for (const job of jobs) {
|
|
const idLabel = pad(job.id, CRON_ID_PAD);
|
|
const nameLabel = pad(truncate(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(job.state.nextRunAtMs, now) : "-",
|
|
CRON_NEXT_PAD,
|
|
);
|
|
const lastLabel = pad(formatRelative(job.state.lastRunAtMs, now), CRON_LAST_PAD);
|
|
const statusRaw = formatStatus(job);
|
|
const statusLabel = pad(statusRaw, CRON_STATUS_PAD);
|
|
const targetLabel = pad(job.sessionTarget, CRON_TARGET_PAD);
|
|
const agentLabel = pad(truncate(job.agentId ?? "default", CRON_AGENT_PAD), CRON_AGENT_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 === "isolated"
|
|
? colorize(rich, theme.accentBright, targetLabel)
|
|
: colorize(rich, theme.accent, 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,
|
|
coloredAgent,
|
|
].join(" ");
|
|
|
|
runtime.log(line.trimEnd());
|
|
}
|
|
}
|