refactor(src): split oversized modules

This commit is contained in:
Peter Steinberger
2026-01-14 01:08:15 +00:00
parent b2179de839
commit bcbfb357be
675 changed files with 91476 additions and 73453 deletions

147
src/cron/service/jobs.ts Normal file
View File

@@ -0,0 +1,147 @@
import crypto from "node:crypto";
import { computeNextRunAtMs } from "../schedule.js";
import type { CronJob, CronJobCreate, CronJobPatch } from "../types.js";
import {
normalizeOptionalAgentId,
normalizeOptionalText,
normalizePayloadToSystemText,
normalizeRequiredName,
} from "./normalize.js";
import type { CronServiceState } from "./state.js";
const STUCK_RUN_MS = 2 * 60 * 60 * 1000;
export function assertSupportedJobSpec(
job: Pick<CronJob, "sessionTarget" | "payload">,
) {
if (job.sessionTarget === "main" && job.payload.kind !== "systemEvent") {
throw new Error('main cron jobs require payload.kind="systemEvent"');
}
if (job.sessionTarget === "isolated" && job.payload.kind !== "agentTurn") {
throw new Error('isolated cron jobs require payload.kind="agentTurn"');
}
}
export function findJobOrThrow(state: CronServiceState, id: string) {
const job = state.store?.jobs.find((j) => j.id === id);
if (!job) throw new Error(`unknown cron job id: ${id}`);
return job;
}
export function computeJobNextRunAtMs(
job: CronJob,
nowMs: number,
): number | undefined {
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);
}
export function recomputeNextRuns(state: CronServiceState) {
if (!state.store) return;
const now = state.deps.nowMs();
for (const job of state.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) {
state.deps.log.warn(
{ jobId: job.id, runningAtMs: runningAt },
"cron: clearing stuck running marker",
);
job.state.runningAtMs = undefined;
}
job.state.nextRunAtMs = computeJobNextRunAtMs(job, now);
}
}
export function nextWakeAtMs(state: CronServiceState) {
const jobs = state.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,
);
}
export function createJob(
state: CronServiceState,
input: CronJobCreate,
): CronJob {
const now = state.deps.nowMs();
const id = crypto.randomUUID();
const job: CronJob = {
id,
agentId: normalizeOptionalAgentId(input.agentId),
name: normalizeRequiredName(input.name),
description: normalizeOptionalText(input.description),
enabled: input.enabled !== false,
deleteAfterRun: input.deleteAfterRun,
createdAtMs: now,
updatedAtMs: now,
schedule: input.schedule,
sessionTarget: input.sessionTarget,
wakeMode: input.wakeMode,
payload: input.payload,
isolation: input.isolation,
state: {
...input.state,
},
};
assertSupportedJobSpec(job);
job.state.nextRunAtMs = computeJobNextRunAtMs(job, now);
return job;
}
export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
if ("name" in patch) job.name = normalizeRequiredName(patch.name);
if ("description" in patch)
job.description = normalizeOptionalText(patch.description);
if (typeof patch.enabled === "boolean") job.enabled = patch.enabled;
if (typeof patch.deleteAfterRun === "boolean")
job.deleteAfterRun = patch.deleteAfterRun;
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 };
if ("agentId" in patch) {
job.agentId = normalizeOptionalAgentId(
(patch as { agentId?: unknown }).agentId,
);
}
assertSupportedJobSpec(job);
}
export function isJobDue(
job: CronJob,
nowMs: number,
opts: { forced: boolean },
) {
if (opts.forced) return true;
return (
job.enabled &&
typeof job.state.nextRunAtMs === "number" &&
nowMs >= job.state.nextRunAtMs
);
}
export function resolveJobPayloadTextForMain(job: CronJob): string | undefined {
if (job.payload.kind !== "systemEvent") return undefined;
const text = normalizePayloadToSystemText(job.payload);
return text.trim() ? text : undefined;
}

View File

@@ -0,0 +1,14 @@
import type { CronServiceState } from "./state.js";
export async function locked<T>(
state: CronServiceState,
fn: () => Promise<T>,
): Promise<T> {
const next = state.op.then(fn, fn);
// Keep the chain alive even when the operation fails.
state.op = next.then(
() => undefined,
() => undefined,
);
return (await next) as T;
}

View File

@@ -0,0 +1,60 @@
import { normalizeAgentId } from "../../routing/session-key.js";
import { truncateUtf16Safe } from "../../utils.js";
import type { CronPayload } from "../types.js";
export function normalizeRequiredName(raw: unknown) {
if (typeof raw !== "string") throw new Error("cron job name is required");
const name = raw.trim();
if (!name) throw new Error("cron job name is required");
return name;
}
export function normalizeOptionalText(raw: unknown) {
if (typeof raw !== "string") return undefined;
const trimmed = raw.trim();
return trimmed ? trimmed : undefined;
}
function truncateText(input: string, maxLen: number) {
if (input.length <= maxLen) return input;
return `${truncateUtf16Safe(input, Math.max(0, maxLen - 1)).trimEnd()}`;
}
export function normalizeOptionalAgentId(raw: unknown) {
if (typeof raw !== "string") return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;
return normalizeAgentId(trimmed);
}
export function inferLegacyName(job: {
schedule?: { kind?: unknown; everyMs?: unknown; expr?: unknown };
payload?: { kind?: unknown; text?: unknown; message?: unknown };
}) {
const text =
job?.payload?.kind === "systemEvent" && typeof job.payload.text === "string"
? job.payload.text
: job?.payload?.kind === "agentTurn" &&
typeof job.payload.message === "string"
? job.payload.message
: "";
const firstLine =
text
.split("\n")
.map((l) => l.trim())
.find(Boolean) ?? "";
if (firstLine) return truncateText(firstLine, 60);
const kind = typeof job?.schedule?.kind === "string" ? job.schedule.kind : "";
if (kind === "cron" && typeof job?.schedule?.expr === "string")
return `Cron: ${truncateText(job.schedule.expr, 52)}`;
if (kind === "every" && typeof job?.schedule?.everyMs === "number")
return `Every: ${job.schedule.everyMs}ms`;
if (kind === "at") return "One-shot";
return "Cron job";
}
export function normalizePayloadToSystemText(payload: CronPayload) {
if (payload.kind === "systemEvent") return payload.text.trim();
return payload.message.trim();
}

156
src/cron/service/ops.ts Normal file
View File

@@ -0,0 +1,156 @@
import type { CronJobCreate, CronJobPatch } from "../types.js";
import {
applyJobPatch,
computeJobNextRunAtMs,
createJob,
findJobOrThrow,
isJobDue,
nextWakeAtMs,
recomputeNextRuns,
} from "./jobs.js";
import { locked } from "./locked.js";
import type { CronServiceState } from "./state.js";
import { ensureLoaded, persist, warnIfDisabled } from "./store.js";
import { armTimer, emit, executeJob, stopTimer, wake } from "./timer.js";
export async function start(state: CronServiceState) {
await locked(state, async () => {
if (!state.deps.cronEnabled) {
state.deps.log.info({ enabled: false }, "cron: disabled");
return;
}
await ensureLoaded(state);
recomputeNextRuns(state);
await persist(state);
armTimer(state);
state.deps.log.info(
{
enabled: true,
jobs: state.store?.jobs.length ?? 0,
nextWakeAtMs: nextWakeAtMs(state) ?? null,
},
"cron: started",
);
});
}
export function stop(state: CronServiceState) {
stopTimer(state);
}
export async function status(state: CronServiceState) {
return await locked(state, async () => {
await ensureLoaded(state);
return {
enabled: state.deps.cronEnabled,
storePath: state.deps.storePath,
jobs: state.store?.jobs.length ?? 0,
nextWakeAtMs:
state.deps.cronEnabled === true ? (nextWakeAtMs(state) ?? null) : null,
};
});
}
export async function list(
state: CronServiceState,
opts?: { includeDisabled?: boolean },
) {
return await locked(state, async () => {
await ensureLoaded(state);
const includeDisabled = opts?.includeDisabled === true;
const jobs = (state.store?.jobs ?? []).filter(
(j) => includeDisabled || j.enabled,
);
return jobs.sort(
(a, b) => (a.state.nextRunAtMs ?? 0) - (b.state.nextRunAtMs ?? 0),
);
});
}
export async function add(state: CronServiceState, input: CronJobCreate) {
return await locked(state, async () => {
warnIfDisabled(state, "add");
await ensureLoaded(state);
const job = createJob(state, input);
state.store?.jobs.push(job);
await persist(state);
armTimer(state);
emit(state, {
jobId: job.id,
action: "added",
nextRunAtMs: job.state.nextRunAtMs,
});
return job;
});
}
export async function update(
state: CronServiceState,
id: string,
patch: CronJobPatch,
) {
return await locked(state, async () => {
warnIfDisabled(state, "update");
await ensureLoaded(state);
const job = findJobOrThrow(state, id);
const now = state.deps.nowMs();
applyJobPatch(job, patch);
job.updatedAtMs = now;
if (job.enabled) {
job.state.nextRunAtMs = computeJobNextRunAtMs(job, now);
} else {
job.state.nextRunAtMs = undefined;
job.state.runningAtMs = undefined;
}
await persist(state);
armTimer(state);
emit(state, {
jobId: id,
action: "updated",
nextRunAtMs: job.state.nextRunAtMs,
});
return job;
});
}
export async function remove(state: CronServiceState, id: string) {
return await locked(state, async () => {
warnIfDisabled(state, "remove");
await ensureLoaded(state);
const before = state.store?.jobs.length ?? 0;
if (!state.store) return { ok: false, removed: false } as const;
state.store.jobs = state.store.jobs.filter((j) => j.id !== id);
const removed = (state.store.jobs.length ?? 0) !== before;
await persist(state);
armTimer(state);
if (removed) emit(state, { jobId: id, action: "removed" });
return { ok: true, removed } as const;
});
}
export async function run(
state: CronServiceState,
id: string,
mode?: "due" | "force",
) {
return await locked(state, async () => {
warnIfDisabled(state, "run");
await ensureLoaded(state);
const job = findJobOrThrow(state, id);
const now = state.deps.nowMs();
const due = isJobDue(job, now, { forced: mode === "force" });
if (!due) return { ok: true, ran: false, reason: "not-due" as const };
await executeJob(state, job, now, { forced: mode === "force" });
await persist(state);
armTimer(state);
return { ok: true, ran: true } as const;
});
}
export function wakeNow(
state: CronServiceState,
opts: { mode: "now" | "next-heartbeat"; text: string },
) {
return wake(state, opts);
}

95
src/cron/service/state.ts Normal file
View File

@@ -0,0 +1,95 @@
import type { HeartbeatRunResult } from "../../infra/heartbeat-wake.js";
import type {
CronJob,
CronJobCreate,
CronJobPatch,
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;
summary?: string;
nextRunAtMs?: number;
};
export 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, opts?: { agentId?: string }) => void;
requestHeartbeatNow: (opts?: { reason?: string }) => void;
runHeartbeatOnce?: (opts?: {
reason?: string;
}) => Promise<HeartbeatRunResult>;
runIsolatedAgentJob: (params: { job: CronJob; message: string }) => Promise<{
status: "ok" | "error" | "skipped";
summary?: string;
error?: string;
}>;
onEvent?: (evt: CronEvent) => void;
};
export type CronServiceDepsInternal = Omit<CronServiceDeps, "nowMs"> & {
nowMs: () => number;
};
export type CronServiceState = {
deps: CronServiceDepsInternal;
store: CronStoreFile | null;
timer: NodeJS.Timeout | null;
running: boolean;
op: Promise<unknown>;
warnedDisabled: boolean;
};
export function createCronServiceState(
deps: CronServiceDeps,
): CronServiceState {
return {
deps: { ...deps, nowMs: deps.nowMs ?? (() => Date.now()) },
store: null,
timer: null,
running: false,
op: Promise.resolve(),
warnedDisabled: false,
};
}
export type CronRunMode = "due" | "force";
export type CronWakeMode = "now" | "next-heartbeat";
export type CronStatusSummary = {
enabled: boolean;
storePath: string;
jobs: number;
nextWakeAtMs: number | null;
};
export type CronRunResult =
| { ok: true; ran: true }
| { ok: true; ran: false; reason: "not-due" }
| { ok: false };
export type CronRemoveResult =
| { ok: true; removed: boolean }
| { ok: false; removed: false };
export type CronAddResult = CronJob;
export type CronUpdateResult = CronJob;
export type CronListResult = CronJob[];
export type CronAddInput = CronJobCreate;
export type CronUpdateInput = CronJobPatch;

54
src/cron/service/store.ts Normal file
View File

@@ -0,0 +1,54 @@
import { migrateLegacyCronPayload } from "../payload-migration.js";
import { loadCronStore, saveCronStore } from "../store.js";
import type { CronJob } from "../types.js";
import { inferLegacyName, normalizeOptionalText } from "./normalize.js";
import type { CronServiceState } from "./state.js";
export async function ensureLoaded(state: CronServiceState) {
if (state.store) return;
const loaded = await loadCronStore(state.deps.storePath);
const jobs = (loaded.jobs ?? []) as unknown as Array<Record<string, unknown>>;
let mutated = false;
for (const raw of jobs) {
const nameRaw = raw.name;
if (typeof nameRaw !== "string" || nameRaw.trim().length === 0) {
raw.name = inferLegacyName({
schedule: raw.schedule as never,
payload: raw.payload as never,
});
mutated = true;
} else {
raw.name = nameRaw.trim();
}
const desc = normalizeOptionalText(raw.description);
if (raw.description !== desc) {
raw.description = desc;
mutated = true;
}
const payload = raw.payload;
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
if (migrateLegacyCronPayload(payload as Record<string, unknown>)) {
mutated = true;
}
}
}
state.store = { version: 1, jobs: jobs as unknown as CronJob[] };
if (mutated) await persist(state);
}
export function warnIfDisabled(state: CronServiceState, action: string) {
if (state.deps.cronEnabled) return;
if (state.warnedDisabled) return;
state.warnedDisabled = true;
state.deps.log.warn(
{ enabled: false, action, storePath: state.deps.storePath },
"cron: scheduler disabled; jobs will not run automatically",
);
}
export async function persist(state: CronServiceState) {
if (!state.store) return;
await saveCronStore(state.deps.storePath, state.store);
}

235
src/cron/service/timer.ts Normal file
View File

@@ -0,0 +1,235 @@
import type { HeartbeatRunResult } from "../../infra/heartbeat-wake.js";
import type { CronJob } from "../types.js";
import {
computeJobNextRunAtMs,
nextWakeAtMs,
resolveJobPayloadTextForMain,
} from "./jobs.js";
import { locked } from "./locked.js";
import type { CronEvent, CronServiceState } from "./state.js";
import { ensureLoaded, persist } from "./store.js";
const MAX_TIMEOUT_MS = 2 ** 31 - 1;
export function armTimer(state: CronServiceState) {
if (state.timer) clearTimeout(state.timer);
state.timer = null;
if (!state.deps.cronEnabled) return;
const nextAt = nextWakeAtMs(state);
if (!nextAt) return;
const delay = Math.max(nextAt - state.deps.nowMs(), 0);
// Avoid TimeoutOverflowWarning when a job is far in the future.
const clampedDelay = Math.min(delay, MAX_TIMEOUT_MS);
state.timer = setTimeout(() => {
void onTimer(state).catch((err) => {
state.deps.log.error({ err: String(err) }, "cron: timer tick failed");
});
}, clampedDelay);
state.timer.unref?.();
}
export async function onTimer(state: CronServiceState) {
if (state.running) return;
state.running = true;
try {
await locked(state, async () => {
await ensureLoaded(state);
await runDueJobs(state);
await persist(state);
armTimer(state);
});
} finally {
state.running = false;
}
}
export async function runDueJobs(state: CronServiceState) {
if (!state.store) return;
const now = state.deps.nowMs();
const due = state.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 executeJob(state, job, now, { forced: false });
}
}
export async function executeJob(
state: CronServiceState,
job: CronJob,
nowMs: number,
opts: { forced: boolean },
) {
const startedAt = state.deps.nowMs();
job.state.runningAtMs = startedAt;
job.state.lastError = undefined;
emit(state, { jobId: job.id, action: "started", runAtMs: startedAt });
let deleted = false;
const finish = async (
status: "ok" | "error" | "skipped",
err?: string,
summary?: string,
) => {
const endedAt = state.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;
const shouldDelete =
job.schedule.kind === "at" &&
status === "ok" &&
job.deleteAfterRun === true;
if (!shouldDelete) {
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 = computeJobNextRunAtMs(job, endedAt);
} else {
job.state.nextRunAtMs = undefined;
}
}
emit(state, {
jobId: job.id,
action: "finished",
status,
error: err,
summary,
runAtMs: startedAt,
durationMs: job.state.lastDurationMs,
nextRunAtMs: job.state.nextRunAtMs,
});
if (shouldDelete && state.store) {
state.store.jobs = state.store.jobs.filter((j) => j.id !== job.id);
deleted = true;
emit(state, { jobId: job.id, action: "removed" });
}
if (job.sessionTarget === "isolated") {
const prefix = job.isolation?.postToMainPrefix?.trim() || "Cron";
const body = (summary ?? err ?? status).trim();
const statusPrefix = status === "ok" ? prefix : `${prefix} (${status})`;
state.deps.enqueueSystemEvent(`${statusPrefix}: ${body}`, {
agentId: job.agentId,
});
if (job.wakeMode === "now") {
state.deps.requestHeartbeatNow({ reason: `cron:${job.id}:post` });
}
}
};
try {
if (job.sessionTarget === "main") {
const text = resolveJobPayloadTextForMain(job);
if (!text) {
const kind = job.payload.kind;
await finish(
"skipped",
kind === "systemEvent"
? "main job requires non-empty systemEvent text"
: 'main job requires payload.kind="systemEvent"',
);
return;
}
state.deps.enqueueSystemEvent(text, { agentId: job.agentId });
if (job.wakeMode === "now" && state.deps.runHeartbeatOnce) {
const reason = `cron:${job.id}`;
const delay = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
const maxWaitMs = 2 * 60_000;
const waitStartedAt = state.deps.nowMs();
let heartbeatResult: HeartbeatRunResult;
for (;;) {
heartbeatResult = await state.deps.runHeartbeatOnce({ reason });
if (
heartbeatResult.status !== "skipped" ||
heartbeatResult.reason !== "requests-in-flight"
) {
break;
}
if (state.deps.nowMs() - waitStartedAt > maxWaitMs) {
heartbeatResult = {
status: "skipped",
reason: "timeout waiting for main lane to become idle",
};
break;
}
await delay(250);
}
if (heartbeatResult.status === "ran") {
await finish("ok", undefined, text);
} else if (heartbeatResult.status === "skipped") {
await finish("skipped", heartbeatResult.reason, text);
} else {
await finish("error", heartbeatResult.reason, text);
}
} else {
// wakeMode is "next-heartbeat" or runHeartbeatOnce not available
state.deps.requestHeartbeatNow({ reason: `cron:${job.id}` });
await finish("ok", undefined, text);
}
return;
}
if (job.payload.kind !== "agentTurn") {
await finish("skipped", "isolated job requires payload.kind=agentTurn");
return;
}
const res = await state.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.error ?? "cron job failed", res.summary);
} catch (err) {
await finish("error", String(err));
} finally {
job.updatedAtMs = nowMs;
if (!opts.forced && job.enabled && !deleted) {
// Keep nextRunAtMs in sync in case the schedule advanced during a long run.
job.state.nextRunAtMs = computeJobNextRunAtMs(job, state.deps.nowMs());
}
}
}
export function wake(
state: CronServiceState,
opts: { mode: "now" | "next-heartbeat"; text: string },
) {
const text = opts.text.trim();
if (!text) return { ok: false } as const;
state.deps.enqueueSystemEvent(text);
if (opts.mode === "now") {
state.deps.requestHeartbeatNow({ reason: "wake" });
}
return { ok: true } as const;
}
export function stopTimer(state: CronServiceState) {
if (state.timer) clearTimeout(state.timer);
state.timer = null;
}
export function emit(state: CronServiceState, evt: CronEvent) {
try {
state.deps.onEvent?.(evt);
} catch {
/* ignore */
}
}