Cron: add scheduler, wakeups, and run history

This commit is contained in:
Peter Steinberger
2025-12-13 02:34:11 +00:00
parent 572d17f46b
commit f9409cbe43
26 changed files with 3401 additions and 342 deletions

View File

@@ -9,6 +9,21 @@ import {
ChatSendParamsSchema,
type ConnectParams,
ConnectParamsSchema,
type CronAddParams,
CronAddParamsSchema,
type CronJob,
CronJobSchema,
type CronListParams,
CronListParamsSchema,
type CronRemoveParams,
CronRemoveParamsSchema,
type CronRunLogEntry,
type CronRunParams,
CronRunParamsSchema,
type CronRunsParams,
CronRunsParamsSchema,
type CronUpdateParams,
CronUpdateParamsSchema,
ErrorCodes,
type ErrorShape,
ErrorShapeSchema,
@@ -36,6 +51,8 @@ import {
StateVersionSchema,
type TickEvent,
TickEventSchema,
type WakeParams,
WakeParamsSchema,
} from "./schema.js";
const ajv = new (
@@ -54,6 +71,21 @@ export const validateRequestFrame =
ajv.compile<RequestFrame>(RequestFrameSchema);
export const validateSendParams = ajv.compile(SendParamsSchema);
export const validateAgentParams = ajv.compile(AgentParamsSchema);
export const validateWakeParams = ajv.compile<WakeParams>(WakeParamsSchema);
export const validateCronListParams =
ajv.compile<CronListParams>(CronListParamsSchema);
export const validateCronAddParams =
ajv.compile<CronAddParams>(CronAddParamsSchema);
export const validateCronUpdateParams = ajv.compile<CronUpdateParams>(
CronUpdateParamsSchema,
);
export const validateCronRemoveParams = ajv.compile<CronRemoveParams>(
CronRemoveParamsSchema,
);
export const validateCronRunParams =
ajv.compile<CronRunParams>(CronRunParamsSchema);
export const validateCronRunsParams =
ajv.compile<CronRunsParams>(CronRunsParamsSchema);
export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema);
export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);
export const validateChatEvent = ajv.compile(ChatEventSchema);
@@ -80,6 +112,14 @@ export {
ChatEventSchema,
SendParamsSchema,
AgentParamsSchema,
WakeParamsSchema,
CronJobSchema,
CronListParamsSchema,
CronAddParamsSchema,
CronUpdateParamsSchema,
CronRemoveParamsSchema,
CronRunParamsSchema,
CronRunsParamsSchema,
ChatHistoryParamsSchema,
ChatSendParamsSchema,
TickEventSchema,
@@ -105,4 +145,13 @@ export type {
ChatEvent,
TickEvent,
ShutdownEvent,
WakeParams,
CronJob,
CronListParams,
CronAddParams,
CronUpdateParams,
CronRemoveParams,
CronRunParams,
CronRunsParams,
CronRunLogEntry,
};

View File

@@ -203,6 +203,185 @@ export const AgentParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const WakeParamsSchema = Type.Object(
{
mode: Type.Union([Type.Literal("now"), Type.Literal("next-heartbeat")]),
text: NonEmptyString,
},
{ additionalProperties: false },
);
export const CronScheduleSchema = Type.Union([
Type.Object(
{
kind: Type.Literal("at"),
atMs: Type.Integer({ minimum: 0 }),
},
{ additionalProperties: false },
),
Type.Object(
{
kind: Type.Literal("every"),
everyMs: Type.Integer({ minimum: 1 }),
anchorMs: Type.Optional(Type.Integer({ minimum: 0 })),
},
{ additionalProperties: false },
),
Type.Object(
{
kind: Type.Literal("cron"),
expr: NonEmptyString,
tz: Type.Optional(Type.String()),
},
{ additionalProperties: false },
),
]);
export const CronPayloadSchema = Type.Union([
Type.Object(
{
kind: Type.Literal("systemEvent"),
text: NonEmptyString,
},
{ additionalProperties: false },
),
Type.Object(
{
kind: Type.Literal("agentTurn"),
message: NonEmptyString,
thinking: Type.Optional(Type.String()),
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1 })),
deliver: Type.Optional(Type.Boolean()),
channel: Type.Optional(
Type.Union([
Type.Literal("last"),
Type.Literal("whatsapp"),
Type.Literal("telegram"),
]),
),
to: Type.Optional(Type.String()),
bestEffortDeliver: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
),
]);
export const CronIsolationSchema = Type.Object(
{
postToMain: Type.Optional(Type.Boolean()),
postToMainPrefix: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
export const CronJobStateSchema = Type.Object(
{
nextRunAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
runningAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
lastRunAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
lastStatus: Type.Optional(
Type.Union([
Type.Literal("ok"),
Type.Literal("error"),
Type.Literal("skipped"),
]),
),
lastError: Type.Optional(Type.String()),
lastDurationMs: Type.Optional(Type.Integer({ minimum: 0 })),
},
{ additionalProperties: false },
);
export const CronJobSchema = Type.Object(
{
id: NonEmptyString,
name: Type.Optional(Type.String()),
enabled: Type.Boolean(),
createdAtMs: Type.Integer({ minimum: 0 }),
updatedAtMs: Type.Integer({ minimum: 0 }),
schedule: CronScheduleSchema,
sessionTarget: Type.Union([Type.Literal("main"), Type.Literal("isolated")]),
wakeMode: Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]),
payload: CronPayloadSchema,
isolation: Type.Optional(CronIsolationSchema),
state: CronJobStateSchema,
},
{ additionalProperties: false },
);
export const CronListParamsSchema = Type.Object(
{
includeDisabled: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);
export const CronAddParamsSchema = Type.Object(
{
name: Type.Optional(Type.String()),
enabled: Type.Optional(Type.Boolean()),
schedule: CronScheduleSchema,
sessionTarget: Type.Union([Type.Literal("main"), Type.Literal("isolated")]),
wakeMode: Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]),
payload: CronPayloadSchema,
isolation: Type.Optional(CronIsolationSchema),
},
{ additionalProperties: false },
);
export const CronUpdateParamsSchema = Type.Object(
{
id: NonEmptyString,
patch: Type.Partial(CronAddParamsSchema),
},
{ additionalProperties: false },
);
export const CronRemoveParamsSchema = Type.Object(
{
id: NonEmptyString,
},
{ additionalProperties: false },
);
export const CronRunParamsSchema = Type.Object(
{
id: NonEmptyString,
mode: Type.Optional(
Type.Union([Type.Literal("due"), Type.Literal("force")]),
),
},
{ additionalProperties: false },
);
export const CronRunsParamsSchema = Type.Object(
{
id: Type.Optional(NonEmptyString),
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 5000 })),
},
{ additionalProperties: false },
);
export const CronRunLogEntrySchema = Type.Object(
{
ts: Type.Integer({ minimum: 0 }),
jobId: NonEmptyString,
action: Type.Literal("finished"),
status: Type.Optional(
Type.Union([
Type.Literal("ok"),
Type.Literal("error"),
Type.Literal("skipped"),
]),
),
error: Type.Optional(Type.String()),
runAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
durationMs: Type.Optional(Type.Integer({ minimum: 0 })),
nextRunAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
},
{ additionalProperties: false },
);
// WebChat/WebSocket-native chat methods
export const ChatHistoryParamsSchema = Type.Object(
{
@@ -256,6 +435,15 @@ export const ProtocolSchemas: Record<string, TSchema> = {
AgentEvent: AgentEventSchema,
SendParams: SendParamsSchema,
AgentParams: AgentParamsSchema,
WakeParams: WakeParamsSchema,
CronJob: CronJobSchema,
CronListParams: CronListParamsSchema,
CronAddParams: CronAddParamsSchema,
CronUpdateParams: CronUpdateParamsSchema,
CronRemoveParams: CronRemoveParamsSchema,
CronRunParams: CronRunParamsSchema,
CronRunsParams: CronRunsParamsSchema,
CronRunLogEntry: CronRunLogEntrySchema,
ChatHistoryParams: ChatHistoryParamsSchema,
ChatSendParams: ChatSendParamsSchema,
ChatEvent: ChatEventSchema,
@@ -276,6 +464,15 @@ export type PresenceEntry = Static<typeof PresenceEntrySchema>;
export type ErrorShape = Static<typeof ErrorShapeSchema>;
export type StateVersion = Static<typeof StateVersionSchema>;
export type AgentEvent = Static<typeof AgentEventSchema>;
export type WakeParams = Static<typeof WakeParamsSchema>;
export type CronJob = Static<typeof CronJobSchema>;
export type CronListParams = Static<typeof CronListParamsSchema>;
export type CronAddParams = Static<typeof CronAddParamsSchema>;
export type CronUpdateParams = Static<typeof CronUpdateParamsSchema>;
export type CronRemoveParams = Static<typeof CronRemoveParamsSchema>;
export type CronRunParams = Static<typeof CronRunParamsSchema>;
export type CronRunsParams = Static<typeof CronRunsParamsSchema>;
export type CronRunLogEntry = Static<typeof CronRunLogEntrySchema>;
export type ChatEvent = Static<typeof ChatEventSchema>;
export type TickEvent = Static<typeof TickEventSchema>;
export type ShutdownEvent = Static<typeof ShutdownEventSchema>;

View File

@@ -14,6 +14,7 @@ import { startGatewayServer } from "./server.js";
let testSessionStorePath: string | undefined;
let testAllowFrom: string[] | undefined;
let testCronStorePath: string | undefined;
vi.mock("../config/config.js", () => ({
loadConfig: () => ({
inbound: {
@@ -24,6 +25,7 @@ vi.mock("../config/config.js", () => ({
session: { mainKey: "main", store: testSessionStorePath },
},
},
cron: { enabled: false, store: testCronStorePath },
}),
}));
@@ -173,6 +175,273 @@ async function connectOk(
}
describe("gateway server", () => {
test("supports cron.add and cron.list", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-cron-"));
testCronStorePath = path.join(dir, "cron.json");
await fs.writeFile(
testCronStorePath,
JSON.stringify({ version: 1, jobs: [] }),
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
ws.send(
JSON.stringify({
type: "req",
id: "cron-add-1",
method: "cron.add",
params: {
name: "daily",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
},
}),
);
const addRes = await onceMessage<{
type: "res";
ok: boolean;
payload?: unknown;
}>(ws, (o) => o.type === "res" && o.id === "cron-add-1");
expect(addRes.ok).toBe(true);
expect(typeof (addRes.payload as { id?: unknown } | null)?.id).toBe(
"string",
);
ws.send(
JSON.stringify({
type: "req",
id: "cron-list-1",
method: "cron.list",
params: { includeDisabled: true },
}),
);
const listRes = await onceMessage<{
type: "res";
ok: boolean;
payload?: unknown;
}>(ws, (o) => o.type === "res" && o.id === "cron-list-1");
expect(listRes.ok).toBe(true);
const jobs = (listRes.payload as { jobs?: unknown } | null)?.jobs;
expect(Array.isArray(jobs)).toBe(true);
expect((jobs as unknown[]).length).toBe(1);
expect(((jobs as Array<{ name?: unknown }>)[0]?.name as string) ?? "").toBe(
"daily",
);
ws.close();
await server.close();
await fs.rm(dir, { recursive: true, force: true });
testCronStorePath = undefined;
});
test("writes cron run history for flat store paths", async () => {
const dir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdis-gw-cron-log-"),
);
testCronStorePath = path.join(dir, "cron.json");
await fs.writeFile(
testCronStorePath,
JSON.stringify({ version: 1, jobs: [] }),
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const atMs = Date.now() - 1;
ws.send(
JSON.stringify({
type: "req",
id: "cron-add-log-1",
method: "cron.add",
params: {
enabled: true,
schedule: { kind: "at", atMs },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
},
}),
);
const addRes = await onceMessage<{
type: "res";
ok: boolean;
payload?: unknown;
}>(ws, (o) => o.type === "res" && o.id === "cron-add-log-1");
expect(addRes.ok).toBe(true);
const jobId = String((addRes.payload as { id?: unknown } | null)?.id ?? "");
expect(jobId.length > 0).toBe(true);
ws.send(
JSON.stringify({
type: "req",
id: "cron-run-log-1",
method: "cron.run",
params: { id: jobId, mode: "force" },
}),
);
const runRes = await onceMessage<{ type: "res"; ok: boolean }>(
ws,
(o) => o.type === "res" && o.id === "cron-run-log-1",
8000,
);
expect(runRes.ok).toBe(true);
const logPath = path.join(dir, "cron.runs.jsonl");
const waitForLog = async () => {
for (let i = 0; i < 200; i++) {
const raw = await fs.readFile(logPath, "utf-8").catch(() => "");
if (raw.trim().length > 0) return raw;
await new Promise((r) => setTimeout(r, 10));
}
throw new Error("timeout waiting for cron run log");
};
const raw = await waitForLog();
const lines = raw
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
expect(lines.length).toBeGreaterThan(0);
const last = JSON.parse(lines.at(-1) ?? "{}") as {
jobId?: unknown;
action?: unknown;
status?: unknown;
};
expect(last.action).toBe("finished");
expect(last.jobId).toBe(jobId);
expect(last.status).toBe("ok");
ws.send(
JSON.stringify({
type: "req",
id: "cron-runs-1",
method: "cron.runs",
params: { id: jobId, limit: 50 },
}),
);
const runsRes = await onceMessage<{
type: "res";
ok: boolean;
payload?: unknown;
}>(ws, (o) => o.type === "res" && o.id === "cron-runs-1", 8000);
expect(runsRes.ok).toBe(true);
const entries = (runsRes.payload as { entries?: unknown } | null)?.entries;
expect(Array.isArray(entries)).toBe(true);
expect((entries as Array<{ jobId?: unknown }>).at(-1)?.jobId).toBe(jobId);
ws.close();
await server.close();
await fs.rm(dir, { recursive: true, force: true });
testCronStorePath = undefined;
});
test("writes cron run history to per-job runs/ when store is jobs.json", async () => {
const dir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdis-gw-cron-log-jobs-"),
);
const cronDir = path.join(dir, "cron");
testCronStorePath = path.join(cronDir, "jobs.json");
await fs.mkdir(cronDir, { recursive: true });
await fs.writeFile(
testCronStorePath,
JSON.stringify({ version: 1, jobs: [] }),
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const atMs = Date.now() - 1;
ws.send(
JSON.stringify({
type: "req",
id: "cron-add-log-2",
method: "cron.add",
params: {
enabled: true,
schedule: { kind: "at", atMs },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
},
}),
);
const addRes = await onceMessage<{
type: "res";
ok: boolean;
payload?: unknown;
}>(ws, (o) => o.type === "res" && o.id === "cron-add-log-2");
expect(addRes.ok).toBe(true);
const jobId = String((addRes.payload as { id?: unknown } | null)?.id ?? "");
expect(jobId.length > 0).toBe(true);
ws.send(
JSON.stringify({
type: "req",
id: "cron-run-log-2",
method: "cron.run",
params: { id: jobId, mode: "force" },
}),
);
const runRes = await onceMessage<{ type: "res"; ok: boolean }>(
ws,
(o) => o.type === "res" && o.id === "cron-run-log-2",
8000,
);
expect(runRes.ok).toBe(true);
const logPath = path.join(cronDir, "runs", `${jobId}.jsonl`);
const waitForLog = async () => {
for (let i = 0; i < 200; i++) {
const raw = await fs.readFile(logPath, "utf-8").catch(() => "");
if (raw.trim().length > 0) return raw;
await new Promise((r) => setTimeout(r, 10));
}
throw new Error("timeout waiting for per-job cron run log");
};
const raw = await waitForLog();
const line = raw
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.at(-1);
const last = JSON.parse(line ?? "{}") as {
jobId?: unknown;
action?: unknown;
};
expect(last.action).toBe("finished");
expect(last.jobId).toBe(jobId);
ws.send(
JSON.stringify({
type: "req",
id: "cron-runs-2",
method: "cron.runs",
params: { id: jobId, limit: 20 },
}),
);
const runsRes = await onceMessage<{
type: "res";
ok: boolean;
payload?: unknown;
}>(ws, (o) => o.type === "res" && o.id === "cron-runs-2", 8000);
expect(runsRes.ok).toBe(true);
const entries = (runsRes.payload as { entries?: unknown } | null)?.entries;
expect(Array.isArray(entries)).toBe(true);
expect((entries as Array<{ jobId?: unknown }>).at(-1)?.jobId).toBe(jobId);
ws.close();
await server.close();
await fs.rm(dir, { recursive: true, force: true });
testCronStorePath = undefined;
});
test("broadcasts heartbeat events and serves last-heartbeat", async () => {
type HeartbeatPayload = {
ts: number;
@@ -196,16 +465,7 @@ describe("gateway server", () => {
};
const { server, ws } = await startServerWithClient();
ws.send(
JSON.stringify({
type: "hello",
minProtocol: 1,
maxProtocol: 1,
client: { name: "test", version: "1", platform: "test", mode: "test" },
caps: [],
}),
);
await onceMessage(ws, (o) => o.type === "hello-ok");
await connectOk(ws);
const waitHeartbeat = onceMessage<EventFrame>(
ws,
@@ -631,13 +891,18 @@ describe("gateway server", () => {
await connectOk(ws);
// Emit a fake agent event directly through the shared emitter.
const runId = randomUUID();
const evtPromise = onceMessage(
ws,
(o) => o.type === "event" && o.event === "agent",
(o) =>
o.type === "event" &&
o.event === "agent" &&
o.payload?.runId === runId &&
o.payload?.stream === "job",
);
emitAgentEvent({ runId: "run-1", stream: "job", data: { msg: "hi" } });
emitAgentEvent({ runId, stream: "job", data: { msg: "hi" } });
const evt = await evtPromise;
expect(evt.payload.runId).toBe("run-1");
expect(evt.payload.runId).toBe(runId);
expect(typeof evt.seq).toBe("number");
expect(evt.payload.data.msg).toBe("hi");

View File

@@ -19,6 +19,15 @@ import {
type SessionEntry,
saveSessionStore,
} from "../config/sessions.js";
import { runCronIsolatedAgentTurn } from "../cron/isolated-agent.js";
import {
appendCronRunLog,
readCronRunLogEntries,
resolveCronRunLogPath,
} from "../cron/run-log.js";
import { CronService } from "../cron/service.js";
import { resolveCronStorePath } from "../cron/store.js";
import type { CronJobCreate, CronJobPatch } from "../cron/types.js";
import { isVerbose } from "../globals.js";
import { onAgentEvent } from "../infra/agent-events.js";
import { GatewayLockError } from "../infra/gateway-lock.js";
@@ -33,7 +42,12 @@ import {
upsertPresence,
} from "../infra/system-presence.js";
import { logError, logInfo, logWarn } from "../logger.js";
import { getLogger, getResolvedLoggerSettings } from "../logging.js";
import {
getChildLogger,
getLogger,
getResolvedLoggerSettings,
} from "../logging.js";
import { setCommandLaneConcurrency } from "../process/command-queue.js";
import { monitorWebProvider, webAuthExists } from "../providers/web/index.js";
import { defaultRuntime } from "../runtime.js";
import { monitorTelegramProvider } from "../telegram/monitor.js";
@@ -41,6 +55,7 @@ import { sendMessageTelegram } from "../telegram/send.js";
import { normalizeE164 } from "../utils.js";
import { setHeartbeatsEnabled } from "../web/auto-reply.js";
import { sendMessageWhatsApp } from "../web/outbound.js";
import { requestReplyHeartbeatNow } from "../web/reply-heartbeat-wake.js";
import { ensureWebChatServerFromConfig } from "../webchat/server.js";
import { buildMessageWithAttachments } from "./chat-attachments.js";
import {
@@ -56,8 +71,15 @@ import {
validateChatHistoryParams,
validateChatSendParams,
validateConnectParams,
validateCronAddParams,
validateCronListParams,
validateCronRemoveParams,
validateCronRunParams,
validateCronRunsParams,
validateCronUpdateParams,
validateRequestFrame,
validateSendParams,
validateWakeParams,
} from "./protocol/index.js";
type Client = {
@@ -72,6 +94,13 @@ const METHODS = [
"status",
"last-heartbeat",
"set-heartbeats",
"wake",
"cron.list",
"cron.add",
"cron.update",
"cron.remove",
"cron.run",
"cron.runs",
"system-presence",
"system-event",
"send",
@@ -89,6 +118,7 @@ const EVENTS = [
"shutdown",
"health",
"heartbeat",
"cron",
];
export type GatewayServer = {
@@ -322,6 +352,59 @@ export async function startGatewayServer(
const providerAbort = new AbortController();
const providerTasks: Array<Promise<unknown>> = [];
const clients = new Set<Client>();
const cfgAtStart = loadConfig();
setCommandLaneConcurrency("cron", cfgAtStart.cron?.maxConcurrentRuns ?? 1);
const cronStorePath = resolveCronStorePath(cfgAtStart.cron?.store);
const cronLogger = getChildLogger({
module: "cron",
storePath: cronStorePath,
});
const cronDeps = createDefaultDeps();
const cronEnabled =
process.env.CLAWDIS_SKIP_CRON !== "1" && cfgAtStart.cron?.enabled === true;
const cron = new CronService({
storePath: cronStorePath,
cronEnabled,
enqueueSystemEvent,
requestReplyHeartbeatNow,
runIsolatedAgentJob: async ({ job, message }) => {
const cfg = loadConfig();
return await runCronIsolatedAgentTurn({
cfg,
deps: cronDeps,
job,
message,
sessionKey: `cron:${job.id}`,
lane: "cron",
});
},
log: cronLogger,
onEvent: (evt) => {
broadcast("cron", evt, { dropIfSlow: true });
if (evt.action === "finished") {
const logPath = resolveCronRunLogPath({
storePath: cronStorePath,
jobId: evt.jobId,
});
void appendCronRunLog(logPath, {
ts: Date.now(),
jobId: evt.jobId,
action: "finished",
status: evt.status,
error: evt.error,
runAtMs: evt.runAtMs,
durationMs: evt.durationMs,
nextRunAtMs: evt.nextRunAtMs,
}).catch((err) => {
cronLogger.warn(
{ err: String(err), logPath },
"cron: run log append failed",
);
});
}
},
});
const startProviders = async () => {
const cfg = loadConfig();
@@ -513,6 +596,10 @@ export async function startGatewayServer(
broadcast("heartbeat", evt, { dropIfSlow: true });
});
void cron
.start()
.catch((err) => logError(`cron failed to start: ${String(err)}`));
wss.on("connection", (socket) => {
let client: Client | null = null;
let closed = false;
@@ -988,6 +1075,157 @@ export async function startGatewayServer(
}
break;
}
case "wake": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateWakeParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid wake params: ${formatValidationErrors(validateWakeParams.errors)}`,
),
);
break;
}
const p = params as {
mode: "now" | "next-heartbeat";
text: string;
};
const result = cron.wake({ mode: p.mode, text: p.text });
respond(true, result, undefined);
break;
}
case "cron.list": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateCronListParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid cron.list params: ${formatValidationErrors(validateCronListParams.errors)}`,
),
);
break;
}
const p = params as { includeDisabled?: boolean };
const jobs = await cron.list({
includeDisabled: p.includeDisabled,
});
respond(true, { jobs }, undefined);
break;
}
case "cron.add": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateCronAddParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid cron.add params: ${formatValidationErrors(validateCronAddParams.errors)}`,
),
);
break;
}
const job = await cron.add(params as unknown as CronJobCreate);
respond(true, job, undefined);
break;
}
case "cron.update": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateCronUpdateParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid cron.update params: ${formatValidationErrors(validateCronUpdateParams.errors)}`,
),
);
break;
}
const p = params as { id: string; patch: Record<string, unknown> };
const job = await cron.update(
p.id,
p.patch as unknown as CronJobPatch,
);
respond(true, job, undefined);
break;
}
case "cron.remove": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateCronRemoveParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid cron.remove params: ${formatValidationErrors(validateCronRemoveParams.errors)}`,
),
);
break;
}
const p = params as { id: string };
const result = await cron.remove(p.id);
respond(true, result, undefined);
break;
}
case "cron.run": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateCronRunParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid cron.run params: ${formatValidationErrors(validateCronRunParams.errors)}`,
),
);
break;
}
const p = params as { id: string; mode?: "due" | "force" };
const result = await cron.run(p.id, p.mode);
respond(true, result, undefined);
break;
}
case "cron.runs": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateCronRunsParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid cron.runs params: ${formatValidationErrors(validateCronRunsParams.errors)}`,
),
);
break;
}
const p = params as { id?: string; limit?: number };
if (!p.id && cronStorePath.endsWith(`${path.sep}jobs.json`)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"cron.runs requires id when using jobs.json store layout",
),
);
break;
}
const logPath = resolveCronRunLogPath({
storePath: cronStorePath,
jobId: p.id ?? "all",
});
const entries = await readCronRunLogEntries(logPath, {
limit: p.limit,
jobId: p.id,
});
respond(true, { entries }, undefined);
break;
}
case "status": {
const status = await getStatusSummary();
respond(true, status, undefined);
@@ -1426,6 +1664,7 @@ export async function startGatewayServer(
return {
close: async () => {
providerAbort.abort();
cron.stop();
broadcast("shutdown", {
reason: "gateway stopping",
restartExpectedMs: null,