Cron: add scheduler, wakeups, and run history
This commit is contained in:
341
src/cron/isolated-agent.ts
Normal file
341
src/cron/isolated-agent.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { chunkText } from "../auto-reply/chunk.js";
|
||||
import { runCommandReply } from "../auto-reply/command-reply.js";
|
||||
import {
|
||||
applyTemplate,
|
||||
type TemplateContext,
|
||||
} from "../auto-reply/templating.js";
|
||||
import { normalizeThinkLevel } from "../auto-reply/thinking.js";
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import type { ClawdisConfig } from "../config/config.js";
|
||||
import {
|
||||
DEFAULT_IDLE_MINUTES,
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
saveSessionStore,
|
||||
} from "../config/sessions.js";
|
||||
import { enqueueCommandInLane } from "../process/command-queue.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import type { CronJob } from "./types.js";
|
||||
|
||||
export type RunCronAgentTurnResult = {
|
||||
status: "ok" | "error" | "skipped";
|
||||
summary?: string;
|
||||
};
|
||||
|
||||
function assertCommandReplyConfig(cfg: ClawdisConfig) {
|
||||
const reply = cfg.inbound?.reply;
|
||||
if (!reply || reply.mode !== "command" || !reply.command?.length) {
|
||||
throw new Error(
|
||||
"Configure inbound.reply.mode=command with reply.command before using cron agent jobs.",
|
||||
);
|
||||
}
|
||||
return reply as NonNullable<
|
||||
NonNullable<ClawdisConfig["inbound"]>["reply"]
|
||||
> & {
|
||||
mode: "command";
|
||||
command: string[];
|
||||
};
|
||||
}
|
||||
|
||||
function pickSummaryFromOutput(text: string | undefined) {
|
||||
const clean = (text ?? "").trim();
|
||||
if (!clean) return undefined;
|
||||
const oneLine = clean.replace(/\s+/g, " ");
|
||||
return oneLine.length > 200 ? `${oneLine.slice(0, 200)}…` : oneLine;
|
||||
}
|
||||
|
||||
function resolveDeliveryTarget(
|
||||
cfg: ClawdisConfig,
|
||||
jobPayload: {
|
||||
channel?: "last" | "whatsapp" | "telegram";
|
||||
to?: string;
|
||||
},
|
||||
) {
|
||||
const requestedChannel =
|
||||
typeof jobPayload.channel === "string" ? jobPayload.channel : "last";
|
||||
const explicitTo =
|
||||
typeof jobPayload.to === "string" && jobPayload.to.trim()
|
||||
? jobPayload.to.trim()
|
||||
: undefined;
|
||||
|
||||
const sessionCfg = cfg.inbound?.reply?.session;
|
||||
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
|
||||
const storePath = resolveStorePath(sessionCfg?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
const main = store[mainKey];
|
||||
const lastChannel =
|
||||
main?.lastChannel && main.lastChannel !== "webchat"
|
||||
? main.lastChannel
|
||||
: undefined;
|
||||
const lastTo = typeof main?.lastTo === "string" ? main.lastTo.trim() : "";
|
||||
|
||||
const channel = (() => {
|
||||
if (requestedChannel === "whatsapp" || requestedChannel === "telegram") {
|
||||
return requestedChannel;
|
||||
}
|
||||
return lastChannel ?? "whatsapp";
|
||||
})();
|
||||
|
||||
const to = (() => {
|
||||
if (explicitTo) return explicitTo;
|
||||
return lastTo || undefined;
|
||||
})();
|
||||
|
||||
const sanitizedWhatsappTo = (() => {
|
||||
if (channel !== "whatsapp") return to;
|
||||
const rawAllow = cfg.inbound?.allowFrom ?? [];
|
||||
if (rawAllow.includes("*")) return to;
|
||||
const allowFrom = rawAllow
|
||||
.map((val) => normalizeE164(val))
|
||||
.filter((val) => val.length > 1);
|
||||
if (allowFrom.length === 0) return to;
|
||||
if (!to) return allowFrom[0];
|
||||
const normalized = normalizeE164(to);
|
||||
if (allowFrom.includes(normalized)) return normalized;
|
||||
return allowFrom[0];
|
||||
})();
|
||||
|
||||
return {
|
||||
channel,
|
||||
to: channel === "whatsapp" ? sanitizedWhatsappTo : to,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCronSession(params: {
|
||||
cfg: ClawdisConfig;
|
||||
sessionKey: string;
|
||||
nowMs: number;
|
||||
}) {
|
||||
const sessionCfg = params.cfg.inbound?.reply?.session;
|
||||
const idleMinutes = Math.max(
|
||||
sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES,
|
||||
1,
|
||||
);
|
||||
const idleMs = idleMinutes * 60_000;
|
||||
const storePath = resolveStorePath(sessionCfg?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = store[params.sessionKey];
|
||||
const fresh = entry && params.nowMs - entry.updatedAt <= idleMs;
|
||||
const sessionId = fresh ? entry.sessionId : crypto.randomUUID();
|
||||
const systemSent = fresh ? Boolean(entry.systemSent) : false;
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId,
|
||||
updatedAt: params.nowMs,
|
||||
systemSent,
|
||||
thinkingLevel: entry?.thinkingLevel,
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
model: entry?.model,
|
||||
contextTokens: entry?.contextTokens,
|
||||
lastChannel: entry?.lastChannel,
|
||||
lastTo: entry?.lastTo,
|
||||
syncing: entry?.syncing,
|
||||
};
|
||||
return { storePath, store, sessionEntry, systemSent, isNewSession: !fresh };
|
||||
}
|
||||
|
||||
export async function runCronIsolatedAgentTurn(params: {
|
||||
cfg: ClawdisConfig;
|
||||
deps: CliDeps;
|
||||
job: CronJob;
|
||||
message: string;
|
||||
sessionKey: string;
|
||||
lane?: string;
|
||||
}): Promise<RunCronAgentTurnResult> {
|
||||
const replyCfg = assertCommandReplyConfig(params.cfg);
|
||||
const now = Date.now();
|
||||
const cronSession = resolveCronSession({
|
||||
cfg: params.cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
nowMs: now,
|
||||
});
|
||||
const sendSystemOnce = replyCfg.session?.sendSystemOnce === true;
|
||||
const isFirstTurnInSession =
|
||||
cronSession.isNewSession || !cronSession.systemSent;
|
||||
const sessionIntro = replyCfg.session?.sessionIntro
|
||||
? applyTemplate(replyCfg.session.sessionIntro, {
|
||||
SessionId: cronSession.sessionEntry.sessionId,
|
||||
})
|
||||
: "";
|
||||
const bodyPrefix = replyCfg.bodyPrefix
|
||||
? applyTemplate(replyCfg.bodyPrefix, {
|
||||
SessionId: cronSession.sessionEntry.sessionId,
|
||||
})
|
||||
: "";
|
||||
|
||||
const thinkOverride = normalizeThinkLevel(replyCfg.thinkingDefault);
|
||||
const jobThink = normalizeThinkLevel(
|
||||
(params.job.payload.kind === "agentTurn"
|
||||
? params.job.payload.thinking
|
||||
: undefined) ?? undefined,
|
||||
);
|
||||
const thinkLevel = jobThink ?? thinkOverride;
|
||||
|
||||
const timeoutSecondsRaw =
|
||||
params.job.payload.kind === "agentTurn" && params.job.payload.timeoutSeconds
|
||||
? params.job.payload.timeoutSeconds
|
||||
: (replyCfg.timeoutSeconds ?? 600);
|
||||
const timeoutSeconds = Math.max(Math.floor(timeoutSecondsRaw), 1);
|
||||
const timeoutMs = timeoutSeconds * 1000;
|
||||
|
||||
const delivery =
|
||||
params.job.payload.kind === "agentTurn" &&
|
||||
params.job.payload.deliver === true;
|
||||
const bestEffortDeliver =
|
||||
params.job.payload.kind === "agentTurn" &&
|
||||
params.job.payload.bestEffortDeliver === true;
|
||||
|
||||
const resolvedDelivery = resolveDeliveryTarget(params.cfg, {
|
||||
channel:
|
||||
params.job.payload.kind === "agentTurn"
|
||||
? params.job.payload.channel
|
||||
: "last",
|
||||
to:
|
||||
params.job.payload.kind === "agentTurn"
|
||||
? params.job.payload.to
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const base =
|
||||
`[cron:${params.job.id}${params.job.name ? ` ${params.job.name}` : ""}] ${params.message}`.trim();
|
||||
|
||||
let commandBody = base;
|
||||
if (!sendSystemOnce || isFirstTurnInSession) {
|
||||
commandBody = bodyPrefix ? `${bodyPrefix}${commandBody}` : commandBody;
|
||||
}
|
||||
if (sessionIntro) {
|
||||
commandBody = `${sessionIntro}\n\n${commandBody}`;
|
||||
}
|
||||
|
||||
const templatingCtx: TemplateContext = {
|
||||
Body: commandBody,
|
||||
BodyStripped: commandBody,
|
||||
SessionId: cronSession.sessionEntry.sessionId,
|
||||
From: resolvedDelivery.to ?? "",
|
||||
To: resolvedDelivery.to ?? "",
|
||||
Surface: "Cron",
|
||||
IsNewSession: cronSession.isNewSession ? "true" : "false",
|
||||
};
|
||||
|
||||
// Persist systemSent before the run, mirroring the inbound auto-reply behavior.
|
||||
if (sendSystemOnce && isFirstTurnInSession) {
|
||||
cronSession.sessionEntry.systemSent = true;
|
||||
cronSession.store[params.sessionKey] = cronSession.sessionEntry;
|
||||
await saveSessionStore(cronSession.storePath, cronSession.store);
|
||||
} else {
|
||||
cronSession.store[params.sessionKey] = cronSession.sessionEntry;
|
||||
await saveSessionStore(cronSession.storePath, cronSession.store);
|
||||
}
|
||||
|
||||
const lane = params.lane?.trim() || "cron";
|
||||
|
||||
const runResult = await runCommandReply({
|
||||
reply: { ...replyCfg, mode: "command" },
|
||||
templatingCtx,
|
||||
sendSystemOnce,
|
||||
isNewSession: cronSession.isNewSession,
|
||||
isFirstTurnInSession,
|
||||
systemSent: cronSession.sessionEntry.systemSent ?? false,
|
||||
timeoutMs,
|
||||
timeoutSeconds,
|
||||
thinkLevel,
|
||||
enqueue: (task, opts) => enqueueCommandInLane(lane, task, opts),
|
||||
runId: cronSession.sessionEntry.sessionId,
|
||||
});
|
||||
|
||||
const payloads = runResult.payloads ?? [];
|
||||
const firstText = payloads[0]?.text ?? "";
|
||||
const summary = pickSummaryFromOutput(firstText);
|
||||
|
||||
if (delivery) {
|
||||
if (resolvedDelivery.channel === "whatsapp") {
|
||||
if (!resolvedDelivery.to) {
|
||||
if (!bestEffortDeliver) {
|
||||
return {
|
||||
status: "error",
|
||||
summary: "Cron delivery to WhatsApp requires a recipient.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "skipped",
|
||||
summary: "Delivery skipped (no WhatsApp recipient).",
|
||||
};
|
||||
}
|
||||
const to = normalizeE164(resolvedDelivery.to);
|
||||
try {
|
||||
for (const payload of payloads) {
|
||||
const mediaList =
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const primaryMedia = mediaList[0];
|
||||
await params.deps.sendMessageWhatsApp(to, payload.text ?? "", {
|
||||
verbose: false,
|
||||
mediaUrl: primaryMedia,
|
||||
});
|
||||
for (const extra of mediaList.slice(1)) {
|
||||
await params.deps.sendMessageWhatsApp(to, "", {
|
||||
verbose: false,
|
||||
mediaUrl: extra,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!bestEffortDeliver) throw err;
|
||||
return {
|
||||
status: "ok",
|
||||
summary: summary
|
||||
? `${summary} (delivery failed)`
|
||||
: "completed (delivery failed)",
|
||||
};
|
||||
}
|
||||
} else if (resolvedDelivery.channel === "telegram") {
|
||||
if (!resolvedDelivery.to) {
|
||||
if (!bestEffortDeliver) {
|
||||
return {
|
||||
status: "error",
|
||||
summary: "Cron delivery to Telegram requires a chatId.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "skipped",
|
||||
summary: "Delivery skipped (no Telegram chatId).",
|
||||
};
|
||||
}
|
||||
const chatId = resolvedDelivery.to;
|
||||
try {
|
||||
for (const payload of payloads) {
|
||||
const mediaList =
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
if (mediaList.length === 0) {
|
||||
for (const chunk of chunkText(payload.text ?? "", 4000)) {
|
||||
await params.deps.sendMessageTelegram(chatId, chunk, {
|
||||
verbose: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let first = true;
|
||||
for (const url of mediaList) {
|
||||
const caption = first ? (payload.text ?? "") : "";
|
||||
first = false;
|
||||
await params.deps.sendMessageTelegram(chatId, caption, {
|
||||
verbose: false,
|
||||
mediaUrl: url,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!bestEffortDeliver) throw err;
|
||||
return {
|
||||
status: "ok",
|
||||
summary: summary
|
||||
? `${summary} (delivery failed)`
|
||||
: "completed (delivery failed)",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { status: "ok", summary };
|
||||
}
|
||||
98
src/cron/run-log.test.ts
Normal file
98
src/cron/run-log.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
appendCronRunLog,
|
||||
readCronRunLogEntries,
|
||||
resolveCronRunLogPath,
|
||||
} from "./run-log.js";
|
||||
|
||||
describe("cron run log", () => {
|
||||
it("resolves a flat store path to cron.runs.jsonl", () => {
|
||||
const storePath = path.join(os.tmpdir(), "cron.json");
|
||||
const p = resolveCronRunLogPath({ storePath, jobId: "job-1" });
|
||||
expect(p.endsWith(path.join(os.tmpdir(), "cron.runs.jsonl"))).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves jobs.json to per-job runs/<jobId>.jsonl", () => {
|
||||
const storePath = path.join(os.tmpdir(), "cron", "jobs.json");
|
||||
const p = resolveCronRunLogPath({ storePath, jobId: "job-1" });
|
||||
expect(
|
||||
p.endsWith(path.join(os.tmpdir(), "cron", "runs", "job-1.jsonl")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("appends JSONL and prunes by line count", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-cron-log-"));
|
||||
const logPath = path.join(dir, "cron.runs.jsonl");
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await appendCronRunLog(
|
||||
logPath,
|
||||
{
|
||||
ts: 1000 + i,
|
||||
jobId: "job-1",
|
||||
action: "finished",
|
||||
status: "ok",
|
||||
durationMs: i,
|
||||
},
|
||||
{ maxBytes: 1, keepLines: 3 },
|
||||
);
|
||||
}
|
||||
|
||||
const raw = await fs.readFile(logPath, "utf-8");
|
||||
const lines = raw
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
expect(lines.length).toBe(3);
|
||||
const last = JSON.parse(lines[2] ?? "{}") as { ts?: number };
|
||||
expect(last.ts).toBe(1009);
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("reads newest entries and filters by jobId", async () => {
|
||||
const dir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdis-cron-log-read-"),
|
||||
);
|
||||
const logPath = path.join(dir, "cron.runs.jsonl");
|
||||
|
||||
await appendCronRunLog(logPath, {
|
||||
ts: 1,
|
||||
jobId: "a",
|
||||
action: "finished",
|
||||
status: "ok",
|
||||
});
|
||||
await appendCronRunLog(logPath, {
|
||||
ts: 2,
|
||||
jobId: "b",
|
||||
action: "finished",
|
||||
status: "error",
|
||||
error: "nope",
|
||||
});
|
||||
await appendCronRunLog(logPath, {
|
||||
ts: 3,
|
||||
jobId: "a",
|
||||
action: "finished",
|
||||
status: "skipped",
|
||||
});
|
||||
|
||||
const all = await readCronRunLogEntries(logPath, { limit: 10 });
|
||||
expect(all.map((e) => e.jobId)).toEqual(["a", "b", "a"]);
|
||||
|
||||
const onlyA = await readCronRunLogEntries(logPath, {
|
||||
limit: 10,
|
||||
jobId: "a",
|
||||
});
|
||||
expect(onlyA.map((e) => e.ts)).toEqual([1, 3]);
|
||||
|
||||
const lastOne = await readCronRunLogEntries(logPath, { limit: 1 });
|
||||
expect(lastOne.map((e) => e.ts)).toEqual([3]);
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
101
src/cron/run-log.ts
Normal file
101
src/cron/run-log.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export type CronRunLogEntry = {
|
||||
ts: number;
|
||||
jobId: string;
|
||||
action: "finished";
|
||||
status?: "ok" | "error" | "skipped";
|
||||
error?: string;
|
||||
runAtMs?: number;
|
||||
durationMs?: number;
|
||||
nextRunAtMs?: number;
|
||||
};
|
||||
|
||||
export function resolveCronRunLogPath(params: {
|
||||
storePath: string;
|
||||
jobId: string;
|
||||
}) {
|
||||
const storePath = path.resolve(params.storePath);
|
||||
const dir = path.dirname(storePath);
|
||||
const base = path.basename(storePath);
|
||||
if (base === "jobs.json") {
|
||||
return path.join(dir, "runs", `${params.jobId}.jsonl`);
|
||||
}
|
||||
|
||||
const ext = path.extname(base);
|
||||
const baseNoExt = ext ? base.slice(0, -ext.length) : base;
|
||||
return path.join(dir, `${baseNoExt}.runs.jsonl`);
|
||||
}
|
||||
|
||||
const writesByPath = new Map<string, Promise<void>>();
|
||||
|
||||
async function pruneIfNeeded(
|
||||
filePath: string,
|
||||
opts: { maxBytes: number; keepLines: number },
|
||||
) {
|
||||
const stat = await fs.stat(filePath).catch(() => null);
|
||||
if (!stat || stat.size <= opts.maxBytes) return;
|
||||
|
||||
const raw = await fs.readFile(filePath, "utf-8").catch(() => "");
|
||||
const lines = raw
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const kept = lines.slice(Math.max(0, lines.length - opts.keepLines));
|
||||
const tmp = `${filePath}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
|
||||
await fs.writeFile(tmp, `${kept.join("\n")}\n`, "utf-8");
|
||||
await fs.rename(tmp, filePath);
|
||||
}
|
||||
|
||||
export async function appendCronRunLog(
|
||||
filePath: string,
|
||||
entry: CronRunLogEntry,
|
||||
opts?: { maxBytes?: number; keepLines?: number },
|
||||
) {
|
||||
const resolved = path.resolve(filePath);
|
||||
const prev = writesByPath.get(resolved) ?? Promise.resolve();
|
||||
const next = prev
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
||||
await fs.appendFile(resolved, `${JSON.stringify(entry)}\n`, "utf-8");
|
||||
await pruneIfNeeded(resolved, {
|
||||
maxBytes: opts?.maxBytes ?? 2_000_000,
|
||||
keepLines: opts?.keepLines ?? 2_000,
|
||||
});
|
||||
});
|
||||
writesByPath.set(resolved, next);
|
||||
await next;
|
||||
}
|
||||
|
||||
export async function readCronRunLogEntries(
|
||||
filePath: string,
|
||||
opts?: { limit?: number; jobId?: string },
|
||||
): Promise<CronRunLogEntry[]> {
|
||||
const limit = Math.max(1, Math.min(5000, Math.floor(opts?.limit ?? 200)));
|
||||
const jobId = opts?.jobId?.trim() || undefined;
|
||||
const raw = await fs
|
||||
.readFile(path.resolve(filePath), "utf-8")
|
||||
.catch(() => "");
|
||||
if (!raw.trim()) return [];
|
||||
const parsed: CronRunLogEntry[] = [];
|
||||
const lines = raw.split("\n");
|
||||
for (let i = lines.length - 1; i >= 0 && parsed.length < limit; i--) {
|
||||
const line = lines[i]?.trim();
|
||||
if (!line) continue;
|
||||
try {
|
||||
const obj = JSON.parse(line) as Partial<CronRunLogEntry> | null;
|
||||
if (!obj || typeof obj !== "object") continue;
|
||||
if (obj.action !== "finished") continue;
|
||||
if (typeof obj.jobId !== "string" || obj.jobId.trim().length === 0)
|
||||
continue;
|
||||
if (typeof obj.ts !== "number" || !Number.isFinite(obj.ts)) continue;
|
||||
if (jobId && obj.jobId !== jobId) continue;
|
||||
parsed.push(obj as CronRunLogEntry);
|
||||
} catch {
|
||||
// ignore invalid lines
|
||||
}
|
||||
}
|
||||
return parsed.reverse();
|
||||
}
|
||||
26
src/cron/schedule.test.ts
Normal file
26
src/cron/schedule.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computeNextRunAtMs } from "./schedule.js";
|
||||
|
||||
describe("cron schedule", () => {
|
||||
it("computes next run for cron expression with timezone", () => {
|
||||
// Saturday, Dec 13 2025 00:00:00Z
|
||||
const nowMs = Date.parse("2025-12-13T00:00:00.000Z");
|
||||
const next = computeNextRunAtMs(
|
||||
{ kind: "cron", expr: "0 9 * * 3", tz: "America/Los_Angeles" },
|
||||
nowMs,
|
||||
);
|
||||
// Next Wednesday at 09:00 PST -> 17:00Z
|
||||
expect(next).toBe(Date.parse("2025-12-17T17:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("computes next run for every schedule", () => {
|
||||
const anchor = Date.parse("2025-12-13T00:00:00.000Z");
|
||||
const now = anchor + 10_000;
|
||||
const next = computeNextRunAtMs(
|
||||
{ kind: "every", everyMs: 30_000, anchorMs: anchor },
|
||||
now,
|
||||
);
|
||||
expect(next).toBe(anchor + 30_000);
|
||||
});
|
||||
});
|
||||
29
src/cron/schedule.ts
Normal file
29
src/cron/schedule.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Cron } from "croner";
|
||||
import type { CronSchedule } from "./types.js";
|
||||
|
||||
export function computeNextRunAtMs(
|
||||
schedule: CronSchedule,
|
||||
nowMs: number,
|
||||
): number | undefined {
|
||||
if (schedule.kind === "at") {
|
||||
return schedule.atMs > nowMs ? schedule.atMs : undefined;
|
||||
}
|
||||
|
||||
if (schedule.kind === "every") {
|
||||
const everyMs = Math.max(1, Math.floor(schedule.everyMs));
|
||||
const anchor = Math.max(0, Math.floor(schedule.anchorMs ?? nowMs));
|
||||
if (nowMs <= anchor) return anchor;
|
||||
const elapsed = nowMs - anchor;
|
||||
const steps = Math.floor((elapsed + everyMs - 1) / everyMs);
|
||||
return anchor + steps * everyMs;
|
||||
}
|
||||
|
||||
const expr = schedule.expr.trim();
|
||||
if (!expr) return undefined;
|
||||
const cron = new Cron(expr, {
|
||||
timezone: schedule.tz?.trim() || undefined,
|
||||
catch: false,
|
||||
});
|
||||
const next = cron.nextRun(new Date(nowMs));
|
||||
return next ? next.getTime() : undefined;
|
||||
}
|
||||
120
src/cron/service.test.ts
Normal file
120
src/cron/service.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CronService } from "./service.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
async function makeStorePath() {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-cron-"));
|
||||
return {
|
||||
storePath: path.join(dir, "cron.json"),
|
||||
cleanup: async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("CronService", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-12-13T00:00:00.000Z"));
|
||||
noopLogger.debug.mockClear();
|
||||
noopLogger.info.mockClear();
|
||||
noopLogger.warn.mockClear();
|
||||
noopLogger.error.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("runs a one-shot main job and disables it after success", async () => {
|
||||
const store = await makeStorePath();
|
||||
const enqueueSystemEvent = vi.fn();
|
||||
const requestReplyHeartbeatNow = vi.fn();
|
||||
|
||||
const cron = new CronService({
|
||||
storePath: store.storePath,
|
||||
cronEnabled: true,
|
||||
log: noopLogger,
|
||||
enqueueSystemEvent,
|
||||
requestReplyHeartbeatNow,
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
|
||||
});
|
||||
|
||||
await cron.start();
|
||||
const atMs = Date.parse("2025-12-13T00:00:02.000Z");
|
||||
const job = await cron.add({
|
||||
enabled: true,
|
||||
schedule: { kind: "at", atMs },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
});
|
||||
|
||||
expect(job.state.nextRunAtMs).toBe(atMs);
|
||||
|
||||
vi.setSystemTime(new Date("2025-12-13T00:00:02.000Z"));
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
const jobs = await cron.list({ includeDisabled: true });
|
||||
const updated = jobs.find((j) => j.id === job.id);
|
||||
expect(updated?.enabled).toBe(false);
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledWith("hello");
|
||||
expect(requestReplyHeartbeatNow).toHaveBeenCalled();
|
||||
|
||||
await cron.list({ includeDisabled: true });
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
});
|
||||
|
||||
it("runs an isolated job and posts summary to main", async () => {
|
||||
const store = await makeStorePath();
|
||||
const enqueueSystemEvent = vi.fn();
|
||||
const requestReplyHeartbeatNow = vi.fn();
|
||||
const runIsolatedAgentJob = vi.fn(async () => ({
|
||||
status: "ok" as const,
|
||||
summary: "done",
|
||||
}));
|
||||
|
||||
const cron = new CronService({
|
||||
storePath: store.storePath,
|
||||
cronEnabled: true,
|
||||
log: noopLogger,
|
||||
enqueueSystemEvent,
|
||||
requestReplyHeartbeatNow,
|
||||
runIsolatedAgentJob,
|
||||
});
|
||||
|
||||
await cron.start();
|
||||
const atMs = Date.parse("2025-12-13T00:00:01.000Z");
|
||||
await cron.add({
|
||||
enabled: true,
|
||||
name: "weekly",
|
||||
schedule: { kind: "at", atMs },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "do it", deliver: false },
|
||||
isolation: { postToMain: true, postToMainPrefix: "Cron" },
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z"));
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
await cron.list({ includeDisabled: true });
|
||||
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledWith("Cron: done");
|
||||
expect(requestReplyHeartbeatNow).toHaveBeenCalled();
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
});
|
||||
});
|
||||
431
src/cron/service.ts
Normal file
431
src/cron/service.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { computeNextRunAtMs } from "./schedule.js";
|
||||
import { loadCronStore, saveCronStore } from "./store.js";
|
||||
import type {
|
||||
CronJob,
|
||||
CronJobCreate,
|
||||
CronJobPatch,
|
||||
CronPayload,
|
||||
CronStoreFile,
|
||||
} from "./types.js";
|
||||
|
||||
export type CronEvent = {
|
||||
jobId: string;
|
||||
action: "added" | "updated" | "removed" | "started" | "finished";
|
||||
runAtMs?: number;
|
||||
durationMs?: number;
|
||||
status?: "ok" | "error" | "skipped";
|
||||
error?: string;
|
||||
nextRunAtMs?: number;
|
||||
};
|
||||
|
||||
type Logger = {
|
||||
debug: (obj: unknown, msg?: string) => void;
|
||||
info: (obj: unknown, msg?: string) => void;
|
||||
warn: (obj: unknown, msg?: string) => void;
|
||||
error: (obj: unknown, msg?: string) => void;
|
||||
};
|
||||
|
||||
export type CronServiceDeps = {
|
||||
nowMs?: () => number;
|
||||
log: Logger;
|
||||
storePath: string;
|
||||
cronEnabled: boolean;
|
||||
enqueueSystemEvent: (text: string) => void;
|
||||
requestReplyHeartbeatNow: (opts?: { reason?: string }) => void;
|
||||
runIsolatedAgentJob: (params: {
|
||||
job: CronJob;
|
||||
message: string;
|
||||
}) => Promise<{ status: "ok" | "error" | "skipped"; summary?: string }>;
|
||||
onEvent?: (evt: CronEvent) => void;
|
||||
};
|
||||
|
||||
const STUCK_RUN_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function normalizePayloadToSystemText(payload: CronPayload) {
|
||||
if (payload.kind === "systemEvent") return payload.text.trim();
|
||||
return payload.message.trim();
|
||||
}
|
||||
|
||||
export class CronService {
|
||||
private readonly deps: Required<Omit<CronServiceDeps, "onEvent">> &
|
||||
Pick<CronServiceDeps, "onEvent">;
|
||||
private store: CronStoreFile | null = null;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private running = false;
|
||||
private op: Promise<unknown> = Promise.resolve();
|
||||
|
||||
constructor(deps: CronServiceDeps) {
|
||||
this.deps = {
|
||||
...deps,
|
||||
nowMs: deps.nowMs ?? (() => Date.now()),
|
||||
onEvent: deps.onEvent,
|
||||
};
|
||||
}
|
||||
|
||||
async start() {
|
||||
await this.locked(async () => {
|
||||
if (!this.deps.cronEnabled) {
|
||||
this.deps.log.info({ enabled: false }, "cron: disabled");
|
||||
return;
|
||||
}
|
||||
await this.ensureLoaded();
|
||||
this.recomputeNextRuns();
|
||||
await this.persist();
|
||||
this.armTimer();
|
||||
this.deps.log.info(
|
||||
{
|
||||
enabled: true,
|
||||
jobs: this.store?.jobs.length ?? 0,
|
||||
nextWakeAtMs: this.nextWakeAtMs() ?? null,
|
||||
},
|
||||
"cron: started",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
async list(opts?: { includeDisabled?: boolean }) {
|
||||
return await this.locked(async () => {
|
||||
await this.ensureLoaded();
|
||||
const includeDisabled = opts?.includeDisabled === true;
|
||||
const jobs = (this.store?.jobs ?? []).filter(
|
||||
(j) => includeDisabled || j.enabled,
|
||||
);
|
||||
return jobs.sort(
|
||||
(a, b) => (a.state.nextRunAtMs ?? 0) - (b.state.nextRunAtMs ?? 0),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async add(input: CronJobCreate) {
|
||||
return await this.locked(async () => {
|
||||
await this.ensureLoaded();
|
||||
const now = this.deps.nowMs();
|
||||
const id = crypto.randomUUID();
|
||||
const job: CronJob = {
|
||||
id,
|
||||
name: input.name?.trim() || undefined,
|
||||
enabled: input.enabled !== false,
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
schedule: input.schedule,
|
||||
sessionTarget: input.sessionTarget,
|
||||
wakeMode: input.wakeMode,
|
||||
payload: input.payload,
|
||||
isolation: input.isolation,
|
||||
state: {
|
||||
...input.state,
|
||||
},
|
||||
};
|
||||
job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, now);
|
||||
this.store?.jobs.push(job);
|
||||
await this.persist();
|
||||
this.armTimer();
|
||||
this.emit({
|
||||
jobId: id,
|
||||
action: "added",
|
||||
nextRunAtMs: job.state.nextRunAtMs,
|
||||
});
|
||||
return job;
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, patch: CronJobPatch) {
|
||||
return await this.locked(async () => {
|
||||
await this.ensureLoaded();
|
||||
const job = this.findJobOrThrow(id);
|
||||
const now = this.deps.nowMs();
|
||||
|
||||
if (isNonEmptyString(patch.name)) job.name = patch.name.trim();
|
||||
if (patch.name === null || patch.name === "") job.name = undefined;
|
||||
if (typeof patch.enabled === "boolean") job.enabled = patch.enabled;
|
||||
if (patch.schedule) job.schedule = patch.schedule;
|
||||
if (patch.sessionTarget) job.sessionTarget = patch.sessionTarget;
|
||||
if (patch.wakeMode) job.wakeMode = patch.wakeMode;
|
||||
if (patch.payload) job.payload = patch.payload;
|
||||
if (patch.isolation) job.isolation = patch.isolation;
|
||||
if (patch.state) job.state = { ...job.state, ...patch.state };
|
||||
|
||||
job.updatedAtMs = now;
|
||||
if (job.enabled) {
|
||||
job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, now);
|
||||
} else {
|
||||
job.state.nextRunAtMs = undefined;
|
||||
job.state.runningAtMs = undefined;
|
||||
}
|
||||
await this.persist();
|
||||
this.armTimer();
|
||||
this.emit({
|
||||
jobId: id,
|
||||
action: "updated",
|
||||
nextRunAtMs: job.state.nextRunAtMs,
|
||||
});
|
||||
return job;
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
return await this.locked(async () => {
|
||||
await this.ensureLoaded();
|
||||
const before = this.store?.jobs.length ?? 0;
|
||||
if (!this.store) return { ok: false, removed: false };
|
||||
this.store.jobs = this.store.jobs.filter((j) => j.id !== id);
|
||||
const removed = (this.store.jobs.length ?? 0) !== before;
|
||||
await this.persist();
|
||||
this.armTimer();
|
||||
if (removed) this.emit({ jobId: id, action: "removed" });
|
||||
return { ok: true, removed };
|
||||
});
|
||||
}
|
||||
|
||||
async run(id: string, mode?: "due" | "force") {
|
||||
return await this.locked(async () => {
|
||||
await this.ensureLoaded();
|
||||
const job = this.findJobOrThrow(id);
|
||||
const now = this.deps.nowMs();
|
||||
const due =
|
||||
mode === "force" ||
|
||||
(job.enabled &&
|
||||
typeof job.state.nextRunAtMs === "number" &&
|
||||
now >= job.state.nextRunAtMs);
|
||||
if (!due) return { ok: true, ran: false, reason: "not-due" as const };
|
||||
await this.executeJob(job, now, { forced: mode === "force" });
|
||||
await this.persist();
|
||||
this.armTimer();
|
||||
return { ok: true, ran: true };
|
||||
});
|
||||
}
|
||||
|
||||
wake(opts: { mode: "now" | "next-heartbeat"; text: string }) {
|
||||
const text = opts.text.trim();
|
||||
if (!text) return { ok: false };
|
||||
this.deps.enqueueSystemEvent(text);
|
||||
if (opts.mode === "now") {
|
||||
this.deps.requestReplyHeartbeatNow({ reason: "wake" });
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async locked<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const next = this.op.then(fn, fn);
|
||||
// Keep the chain alive even when the operation fails.
|
||||
this.op = next.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return (await next) as T;
|
||||
}
|
||||
|
||||
private async ensureLoaded() {
|
||||
if (this.store) return;
|
||||
const loaded = await loadCronStore(this.deps.storePath);
|
||||
this.store = { version: 1, jobs: loaded.jobs ?? [] };
|
||||
}
|
||||
|
||||
private async persist() {
|
||||
if (!this.store) return;
|
||||
await saveCronStore(this.deps.storePath, this.store);
|
||||
}
|
||||
|
||||
private findJobOrThrow(id: string) {
|
||||
const job = this.store?.jobs.find((j) => j.id === id);
|
||||
if (!job) throw new Error(`unknown cron job id: ${id}`);
|
||||
return job;
|
||||
}
|
||||
|
||||
private computeJobNextRunAtMs(job: CronJob, nowMs: number) {
|
||||
if (!job.enabled) return undefined;
|
||||
if (job.schedule.kind === "at") {
|
||||
// One-shot jobs stay due until they successfully finish.
|
||||
if (job.state.lastStatus === "ok" && job.state.lastRunAtMs)
|
||||
return undefined;
|
||||
return job.schedule.atMs;
|
||||
}
|
||||
return computeNextRunAtMs(job.schedule, nowMs);
|
||||
}
|
||||
|
||||
private recomputeNextRuns() {
|
||||
if (!this.store) return;
|
||||
const now = this.deps.nowMs();
|
||||
for (const job of this.store.jobs) {
|
||||
if (!job.state) job.state = {};
|
||||
if (!job.enabled) {
|
||||
job.state.nextRunAtMs = undefined;
|
||||
job.state.runningAtMs = undefined;
|
||||
continue;
|
||||
}
|
||||
const runningAt = job.state.runningAtMs;
|
||||
if (typeof runningAt === "number" && now - runningAt > STUCK_RUN_MS) {
|
||||
this.deps.log.warn(
|
||||
{ jobId: job.id, runningAtMs: runningAt },
|
||||
"cron: clearing stuck running marker",
|
||||
);
|
||||
job.state.runningAtMs = undefined;
|
||||
}
|
||||
job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, now);
|
||||
}
|
||||
}
|
||||
|
||||
private nextWakeAtMs() {
|
||||
const jobs = this.store?.jobs ?? [];
|
||||
const enabled = jobs.filter(
|
||||
(j) => j.enabled && typeof j.state.nextRunAtMs === "number",
|
||||
);
|
||||
if (enabled.length === 0) return undefined;
|
||||
return enabled.reduce(
|
||||
(min, j) => Math.min(min, j.state.nextRunAtMs as number),
|
||||
enabled[0].state.nextRunAtMs as number,
|
||||
);
|
||||
}
|
||||
|
||||
private armTimer() {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
if (!this.deps.cronEnabled) return;
|
||||
const nextAt = this.nextWakeAtMs();
|
||||
if (!nextAt) return;
|
||||
const delay = Math.max(nextAt - this.deps.nowMs(), 0);
|
||||
this.timer = setTimeout(() => {
|
||||
void this.onTimer().catch((err) => {
|
||||
this.deps.log.error({ err: String(err) }, "cron: timer tick failed");
|
||||
});
|
||||
}, delay);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
private async onTimer() {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
try {
|
||||
await this.locked(async () => {
|
||||
await this.ensureLoaded();
|
||||
await this.runDueJobs();
|
||||
await this.persist();
|
||||
this.armTimer();
|
||||
});
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async runDueJobs() {
|
||||
if (!this.store) return;
|
||||
const now = this.deps.nowMs();
|
||||
const due = this.store.jobs.filter((j) => {
|
||||
if (!j.enabled) return false;
|
||||
if (typeof j.state.runningAtMs === "number") return false;
|
||||
const next = j.state.nextRunAtMs;
|
||||
return typeof next === "number" && now >= next;
|
||||
});
|
||||
for (const job of due) {
|
||||
await this.executeJob(job, now, { forced: false });
|
||||
}
|
||||
}
|
||||
|
||||
private async executeJob(
|
||||
job: CronJob,
|
||||
nowMs: number,
|
||||
opts: { forced: boolean },
|
||||
) {
|
||||
const startedAt = this.deps.nowMs();
|
||||
job.state.runningAtMs = startedAt;
|
||||
job.state.lastError = undefined;
|
||||
this.emit({ jobId: job.id, action: "started", runAtMs: startedAt });
|
||||
|
||||
const finish = async (
|
||||
status: "ok" | "error" | "skipped",
|
||||
err?: string,
|
||||
summary?: string,
|
||||
) => {
|
||||
const endedAt = this.deps.nowMs();
|
||||
job.state.runningAtMs = undefined;
|
||||
job.state.lastRunAtMs = startedAt;
|
||||
job.state.lastStatus = status;
|
||||
job.state.lastDurationMs = Math.max(0, endedAt - startedAt);
|
||||
job.state.lastError = err;
|
||||
|
||||
if (job.schedule.kind === "at" && status === "ok") {
|
||||
// One-shot job completed successfully; disable it.
|
||||
job.enabled = false;
|
||||
job.state.nextRunAtMs = undefined;
|
||||
} else if (job.enabled) {
|
||||
job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, endedAt);
|
||||
} else {
|
||||
job.state.nextRunAtMs = undefined;
|
||||
}
|
||||
|
||||
this.emit({
|
||||
jobId: job.id,
|
||||
action: "finished",
|
||||
status,
|
||||
error: err,
|
||||
runAtMs: startedAt,
|
||||
durationMs: job.state.lastDurationMs,
|
||||
nextRunAtMs: job.state.nextRunAtMs,
|
||||
});
|
||||
|
||||
if (summary && job.isolation?.postToMain) {
|
||||
const prefix = job.isolation.postToMainPrefix?.trim() || "Cron";
|
||||
this.deps.enqueueSystemEvent(`${prefix}: ${summary}`);
|
||||
if (job.wakeMode === "now") {
|
||||
this.deps.requestReplyHeartbeatNow({ reason: `cron:${job.id}:post` });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (job.sessionTarget === "main") {
|
||||
const text = normalizePayloadToSystemText(job.payload);
|
||||
this.deps.enqueueSystemEvent(text);
|
||||
if (job.wakeMode === "now") {
|
||||
this.deps.requestReplyHeartbeatNow({ reason: `cron:${job.id}` });
|
||||
}
|
||||
await finish("ok");
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.payload.kind !== "agentTurn") {
|
||||
await finish("skipped", "isolated job requires payload.kind=agentTurn");
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await this.deps.runIsolatedAgentJob({
|
||||
job,
|
||||
message: job.payload.message,
|
||||
});
|
||||
if (res.status === "ok") await finish("ok", undefined, res.summary);
|
||||
else if (res.status === "skipped")
|
||||
await finish("skipped", undefined, res.summary);
|
||||
else await finish("error", res.summary ?? "cron job failed");
|
||||
} catch (err) {
|
||||
await finish("error", String(err));
|
||||
} finally {
|
||||
job.updatedAtMs = nowMs;
|
||||
if (!opts.forced && job.enabled) {
|
||||
// Keep nextRunAtMs in sync in case the schedule advanced during a long run.
|
||||
job.state.nextRunAtMs = this.computeJobNextRunAtMs(
|
||||
job,
|
||||
this.deps.nowMs(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private emit(evt: CronEvent) {
|
||||
try {
|
||||
this.deps.onEvent?.(evt);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
52
src/cron/store.ts
Normal file
52
src/cron/store.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import JSON5 from "json5";
|
||||
import { CONFIG_DIR } from "../utils.js";
|
||||
import type { CronStoreFile } from "./types.js";
|
||||
|
||||
export const LEGACY_CRON_STORE_PATH = path.join(
|
||||
CONFIG_DIR,
|
||||
"cron",
|
||||
"jobs.json",
|
||||
);
|
||||
export const DEFAULT_CRON_STORE_PATH = path.join(CONFIG_DIR, "cron.json");
|
||||
|
||||
export function resolveCronStorePath(storePath?: string) {
|
||||
if (storePath?.trim()) {
|
||||
const raw = storePath.trim();
|
||||
if (raw.startsWith("~"))
|
||||
return path.resolve(raw.replace("~", os.homedir()));
|
||||
return path.resolve(raw);
|
||||
}
|
||||
if (fs.existsSync(LEGACY_CRON_STORE_PATH)) return LEGACY_CRON_STORE_PATH;
|
||||
return DEFAULT_CRON_STORE_PATH;
|
||||
}
|
||||
|
||||
export async function loadCronStore(storePath: string): Promise<CronStoreFile> {
|
||||
try {
|
||||
const raw = await fs.promises.readFile(storePath, "utf-8");
|
||||
const parsed = JSON5.parse(raw) as Partial<CronStoreFile> | null;
|
||||
const jobs = Array.isArray(parsed?.jobs) ? (parsed?.jobs as never[]) : [];
|
||||
return {
|
||||
version: 1,
|
||||
jobs: jobs.filter(Boolean) as never as CronStoreFile["jobs"],
|
||||
};
|
||||
} catch {
|
||||
return { version: 1, jobs: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCronStore(storePath: string, store: CronStoreFile) {
|
||||
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
||||
const tmp = `${storePath}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
|
||||
const json = JSON.stringify(store, null, 2);
|
||||
await fs.promises.writeFile(tmp, json, "utf-8");
|
||||
await fs.promises.rename(tmp, storePath);
|
||||
try {
|
||||
await fs.promises.copyFile(storePath, `${storePath}.bak`);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
64
src/cron/types.ts
Normal file
64
src/cron/types.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export type CronSchedule =
|
||||
| { kind: "at"; atMs: number }
|
||||
| { kind: "every"; everyMs: number; anchorMs?: number }
|
||||
| { kind: "cron"; expr: string; tz?: string };
|
||||
|
||||
export type CronSessionTarget = "main" | "isolated";
|
||||
export type CronWakeMode = "next-heartbeat" | "now";
|
||||
|
||||
export type CronPayload =
|
||||
| { kind: "systemEvent"; text: string }
|
||||
| {
|
||||
kind: "agentTurn";
|
||||
message: string;
|
||||
thinking?: string;
|
||||
timeoutSeconds?: number;
|
||||
deliver?: boolean;
|
||||
channel?: "last" | "whatsapp" | "telegram";
|
||||
to?: string;
|
||||
bestEffortDeliver?: boolean;
|
||||
};
|
||||
|
||||
export type CronIsolation = {
|
||||
postToMain?: boolean;
|
||||
postToMainPrefix?: string;
|
||||
};
|
||||
|
||||
export type CronJobState = {
|
||||
nextRunAtMs?: number;
|
||||
runningAtMs?: number;
|
||||
lastRunAtMs?: number;
|
||||
lastStatus?: "ok" | "error" | "skipped";
|
||||
lastError?: string;
|
||||
lastDurationMs?: number;
|
||||
};
|
||||
|
||||
export type CronJob = {
|
||||
id: string;
|
||||
name?: string;
|
||||
enabled: boolean;
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
schedule: CronSchedule;
|
||||
sessionTarget: CronSessionTarget;
|
||||
wakeMode: CronWakeMode;
|
||||
payload: CronPayload;
|
||||
isolation?: CronIsolation;
|
||||
state: CronJobState;
|
||||
};
|
||||
|
||||
export type CronStoreFile = {
|
||||
version: 1;
|
||||
jobs: CronJob[];
|
||||
};
|
||||
|
||||
export type CronJobCreate = Omit<
|
||||
CronJob,
|
||||
"id" | "createdAtMs" | "updatedAtMs" | "state"
|
||||
> & {
|
||||
state?: Partial<CronJobState>;
|
||||
};
|
||||
|
||||
export type CronJobPatch = Partial<
|
||||
Omit<CronJob, "id" | "createdAtMs" | "state"> & { state: CronJobState }
|
||||
>;
|
||||
Reference in New Issue
Block a user