chore: migrate to oxlint and oxfmt

Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
This commit is contained in:
Peter Steinberger
2026-01-14 14:31:43 +00:00
parent 912ebffc63
commit c379191f80
1480 changed files with 28608 additions and 43547 deletions

View File

@@ -27,11 +27,7 @@ function extractCronChannels(schema: SchemaLike): string[] {
return channels;
}
const UI_FILES = [
"ui/src/ui/types.ts",
"ui/src/ui/ui-types.ts",
"ui/src/ui/views/cron.ts",
];
const UI_FILES = ["ui/src/ui/types.ts", "ui/src/ui/ui-types.ts", "ui/src/ui/views/cron.ts"];
const SWIFT_FILES = ["apps/macos/Sources/Clawdbot/GatewayConnection.swift"];
@@ -44,10 +40,7 @@ describe("cron protocol conformance", () => {
for (const relPath of UI_FILES) {
const content = await fs.readFile(path.join(cwd, relPath), "utf-8");
for (const channel of channels) {
expect(
content.includes(`"${channel}"`),
`${relPath} missing ${channel}`,
).toBe(true);
expect(content.includes(`"${channel}"`), `${relPath} missing ${channel}`).toBe(true);
}
}
@@ -55,20 +48,14 @@ describe("cron protocol conformance", () => {
const content = await fs.readFile(path.join(cwd, relPath), "utf-8");
for (const channel of channels) {
const pattern = new RegExp(`\\bcase\\s+${channel}\\b`);
expect(
pattern.test(content),
`${relPath} missing case ${channel}`,
).toBe(true);
expect(pattern.test(content), `${relPath} missing case ${channel}`).toBe(true);
}
}
});
it("cron status shape matches gateway fields in UI + Swift", async () => {
const cwd = process.cwd();
const uiTypes = await fs.readFile(
path.join(cwd, "ui/src/ui/types.ts"),
"utf-8",
);
const uiTypes = await fs.readFile(path.join(cwd, "ui/src/ui/types.ts"), "utf-8");
expect(uiTypes.includes("export type CronStatus")).toBe(true);
expect(uiTypes.includes("jobs:")).toBe(true);
expect(uiTypes.includes("jobCount")).toBe(false);

View File

@@ -11,8 +11,7 @@ import type { CronJob } from "./types.js";
vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
}));
vi.mock("../agents/model-catalog.js", () => ({
loadModelCatalog: vi.fn(),
@@ -103,9 +102,7 @@ describe("runCronIsolatedAgentTurn", () => {
};
// Media should still be delivered even if text is just HEARTBEAT_OK.
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [
{ text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" },
],
payloads: [{ text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },

View File

@@ -11,8 +11,7 @@ import type { CronJob } from "./types.js";
vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
}));
vi.mock("../agents/model-catalog.js", () => ({
loadModelCatalog: vi.fn(),

View File

@@ -1,4 +1 @@
export {
type RunCronAgentTurnResult,
runCronIsolatedAgentTurn,
} from "./isolated-agent/run.js";
export { type RunCronAgentTurnResult, runCronIsolatedAgentTurn } from "./isolated-agent/run.js";

View File

@@ -11,8 +11,7 @@ import type { CronJob } from "./types.js";
vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
resolveEmbeddedSessionLane: (key: string) =>
`session:${key.trim() || "main"}`,
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
}));
vi.mock("../agents/model-catalog.js", () => ({
loadModelCatalog: vi.fn(),
@@ -140,14 +139,7 @@ describe("runCronIsolatedAgentTurn", () => {
const cfg = makeCfg(
home,
path.join(
home,
".clawdbot",
"agents",
"{agentId}",
"sessions",
"sessions.json",
),
path.join(home, ".clawdbot", "agents", "{agentId}", "sessions", "sessions.json"),
{
agents: {
defaults: { workspace: path.join(home, "default-workspace") },

View File

@@ -10,10 +10,7 @@ import {
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
import type { OutboundChannel } from "../../infra/outbound/targets.js";
import { resolveOutboundTarget } from "../../infra/outbound/targets.js";
import {
INTERNAL_MESSAGE_CHANNEL,
normalizeMessageChannel,
} from "../../utils/message-channel.js";
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js";
export async function resolveDeliveryTarget(
cfg: ClawdbotConfig,
@@ -29,14 +26,10 @@ export async function resolveDeliveryTarget(
mode: "explicit" | "implicit";
error?: Error;
}> {
const requestedRaw =
typeof jobPayload.channel === "string" ? jobPayload.channel : "last";
const requestedChannelHint =
normalizeMessageChannel(requestedRaw) ?? requestedRaw;
const requestedRaw = typeof jobPayload.channel === "string" ? jobPayload.channel : "last";
const requestedChannelHint = normalizeMessageChannel(requestedRaw) ?? requestedRaw;
const explicitTo =
typeof jobPayload.to === "string" && jobPayload.to.trim()
? jobPayload.to.trim()
: undefined;
typeof jobPayload.to === "string" && jobPayload.to.trim() ? jobPayload.to.trim() : undefined;
const sessionCfg = cfg.session;
const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId });

View File

@@ -17,9 +17,7 @@ export function pickSummaryFromOutput(text: string | undefined) {
return clean.length > limit ? `${truncateUtf16Safe(clean, limit)}` : clean;
}
export function pickSummaryFromPayloads(
payloads: Array<{ text?: string | undefined }>,
) {
export function pickSummaryFromPayloads(payloads: Array<{ text?: string | undefined }>) {
for (let i = payloads.length - 1; i >= 0; i--) {
const summary = pickSummaryFromOutput(payloads[i]?.text);
if (summary) return summary;
@@ -31,15 +29,11 @@ export function pickSummaryFromPayloads(
* Check if all payloads are just heartbeat ack responses (HEARTBEAT_OK).
* Returns true if delivery should be skipped because there's no real content.
*/
export function isHeartbeatOnlyResponse(
payloads: DeliveryPayload[],
ackMaxChars: number,
) {
export function isHeartbeatOnlyResponse(payloads: DeliveryPayload[], ackMaxChars: number) {
if (payloads.length === 0) return true;
return payloads.every((payload) => {
// If there's media, we should deliver regardless of text content.
const hasMedia =
(payload.mediaUrls?.length ?? 0) > 0 || Boolean(payload.mediaUrl);
const hasMedia = (payload.mediaUrls?.length ?? 0) > 0 || Boolean(payload.mediaUrl);
if (hasMedia) return false;
// Use heartbeat mode to check if text is just HEARTBEAT_OK or short ack.
const result = stripHeartbeatToken(payload.text, {
@@ -50,10 +44,7 @@ export function isHeartbeatOnlyResponse(
});
}
export function resolveHeartbeatAckMaxChars(agentCfg?: {
heartbeat?: { ackMaxChars?: number };
}) {
const raw =
agentCfg?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS;
export function resolveHeartbeatAckMaxChars(agentCfg?: { heartbeat?: { ackMaxChars?: number } }) {
const raw = agentCfg?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS;
return Math.max(0, raw);
}

View File

@@ -7,11 +7,7 @@ import {
import { runCliAgent } from "../../agents/cli-runner.js";
import { getCliSessionId, setCliSessionId } from "../../agents/cli-session.js";
import { lookupContextTokens } from "../../agents/context.js";
import {
DEFAULT_CONTEXT_TOKENS,
DEFAULT_MODEL,
DEFAULT_PROVIDER,
} from "../../agents/defaults.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js";
import { loadModelCatalog } from "../../agents/model-catalog.js";
import { runWithModelFallback } from "../../agents/model-fallback.js";
import {
@@ -34,17 +30,11 @@ import {
} from "../../auto-reply/thinking.js";
import type { CliDeps } from "../../cli/deps.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
resolveSessionTranscriptPath,
saveSessionStore,
} from "../../config/sessions.js";
import { resolveSessionTranscriptPath, saveSessionStore } from "../../config/sessions.js";
import type { AgentDefaultsConfig } from "../../config/types.js";
import { registerAgentRunContext } from "../../infra/agent-events.js";
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
import {
buildAgentMainSessionKey,
normalizeAgentId,
} from "../../routing/session-key.js";
import { buildAgentMainSessionKey, normalizeAgentId } from "../../routing/session-key.js";
import type { CronJob } from "../types.js";
import { resolveDeliveryTarget } from "./delivery-target.js";
import {
@@ -77,17 +67,12 @@ export async function runCronIsolatedAgentTurn(params: {
: typeof params.job.agentId === "string" && params.job.agentId.trim()
? params.job.agentId
: undefined;
const normalizedRequested = requestedAgentId
? normalizeAgentId(requestedAgentId)
: undefined;
const normalizedRequested = requestedAgentId ? normalizeAgentId(requestedAgentId) : undefined;
const agentConfigOverride = normalizedRequested
? resolveAgentConfig(params.cfg, normalizedRequested)
: undefined;
const { model: overrideModel, ...agentOverrideRest } =
agentConfigOverride ?? {};
const agentId = agentConfigOverride
? (normalizedRequested ?? defaultAgentId)
: defaultAgentId;
const { model: overrideModel, ...agentOverrideRest } = agentConfigOverride ?? {};
const agentId = agentConfigOverride ? (normalizedRequested ?? defaultAgentId) : defaultAgentId;
const agentCfg: AgentDefaultsConfig = Object.assign(
{},
params.cfg.agents?.defaults,
@@ -103,9 +88,7 @@ export async function runCronIsolatedAgentTurn(params: {
agents: Object.assign({}, params.cfg.agents, { defaults: agentCfg }),
};
const baseSessionKey = (
params.sessionKey?.trim() || `cron:${params.job.id}`
).trim();
const baseSessionKey = (params.sessionKey?.trim() || `cron:${params.job.id}`).trim();
const agentSessionKey = buildAgentMainSessionKey({
agentId,
mainKey: baseSessionKey,
@@ -154,9 +137,7 @@ export async function runCronIsolatedAgentTurn(params: {
}
}
const modelOverrideRaw =
params.job.payload.kind === "agentTurn"
? params.job.payload.model
: undefined;
params.job.payload.kind === "agentTurn" ? params.job.payload.model : undefined;
if (modelOverrideRaw !== undefined) {
if (typeof modelOverrideRaw !== "string") {
return { status: "error", error: "invalid model: expected string" };
@@ -188,9 +169,8 @@ export async function runCronIsolatedAgentTurn(params: {
: undefined;
const thinkOverride = normalizeThinkLevel(agentCfg?.thinkingDefault);
const jobThink = normalizeThinkLevel(
(params.job.payload.kind === "agentTurn"
? params.job.payload.thinking
: undefined) ?? undefined,
(params.job.payload.kind === "agentTurn" ? params.job.payload.thinking : undefined) ??
undefined,
);
let thinkLevel = jobThink ?? hooksGmailThinking ?? thinkOverride;
if (!thinkLevel) {
@@ -202,47 +182,29 @@ export async function runCronIsolatedAgentTurn(params: {
});
}
if (thinkLevel === "xhigh" && !supportsXHighThinking(provider, model)) {
throw new Error(
`Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`,
);
throw new Error(`Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`);
}
const timeoutMs = resolveAgentTimeoutMs({
cfg: cfgWithAgentDefaults,
overrideSeconds:
params.job.payload.kind === "agentTurn"
? params.job.payload.timeoutSeconds
: undefined,
params.job.payload.kind === "agentTurn" ? params.job.payload.timeoutSeconds : undefined,
});
const delivery =
params.job.payload.kind === "agentTurn" &&
params.job.payload.deliver === true;
const delivery = params.job.payload.kind === "agentTurn" && params.job.payload.deliver === true;
const bestEffortDeliver =
params.job.payload.kind === "agentTurn" &&
params.job.payload.bestEffortDeliver === true;
params.job.payload.kind === "agentTurn" && params.job.payload.bestEffortDeliver === true;
const resolvedDelivery = await resolveDeliveryTarget(
cfgWithAgentDefaults,
agentId,
{
channel:
params.job.payload.kind === "agentTurn"
? (params.job.payload.channel ?? "last")
: "last",
to:
params.job.payload.kind === "agentTurn"
? params.job.payload.to
: undefined,
},
);
const resolvedDelivery = await resolveDeliveryTarget(cfgWithAgentDefaults, agentId, {
channel:
params.job.payload.kind === "agentTurn" ? (params.job.payload.channel ?? "last") : "last",
to: params.job.payload.kind === "agentTurn" ? params.job.payload.to : undefined,
});
const base =
`[cron:${params.job.id} ${params.job.name}] ${params.message}`.trim();
const base = `[cron:${params.job.id} ${params.job.name}] ${params.message}`.trim();
const commandBody = base;
const needsSkillsSnapshot =
cronSession.isNewSession || !cronSession.sessionEntry.skillsSnapshot;
const needsSkillsSnapshot = cronSession.isNewSession || !cronSession.sessionEntry.skillsSnapshot;
const skillsSnapshot = needsSkillsSnapshot
? buildWorkspaceSkillSnapshot(workspaceDir, {
config: cfgWithAgentDefaults,
@@ -267,10 +229,7 @@ export async function runCronIsolatedAgentTurn(params: {
let fallbackProvider = provider;
let fallbackModel = model;
try {
const sessionFile = resolveSessionTranscriptPath(
cronSession.sessionEntry.sessionId,
agentId,
);
const sessionFile = resolveSessionTranscriptPath(cronSession.sessionEntry.sessionId, agentId);
const resolvedVerboseLevel =
(cronSession.sessionEntry.verboseLevel as "on" | "off" | undefined) ??
(agentCfg?.verboseDefault as "on" | "off" | undefined);
@@ -283,16 +242,10 @@ export async function runCronIsolatedAgentTurn(params: {
cfg: cfgWithAgentDefaults,
provider,
model,
fallbacksOverride: resolveAgentModelFallbacksOverride(
params.cfg,
agentId,
),
fallbacksOverride: resolveAgentModelFallbacksOverride(params.cfg, agentId),
run: (providerOverride, modelOverride) => {
if (isCliProvider(providerOverride, cfgWithAgentDefaults)) {
const cliSessionId = getCliSessionId(
cronSession.sessionEntry,
providerOverride,
);
const cliSessionId = getCliSessionId(cronSession.sessionEntry, providerOverride);
return runCliAgent({
sessionId: cronSession.sessionEntry.sessionId,
sessionKey: agentSessionKey,
@@ -340,12 +293,9 @@ export async function runCronIsolatedAgentTurn(params: {
{
const usage = runResult.meta.agentMeta?.usage;
const modelUsed = runResult.meta.agentMeta?.model ?? fallbackModel ?? model;
const providerUsed =
runResult.meta.agentMeta?.provider ?? fallbackProvider ?? provider;
const providerUsed = runResult.meta.agentMeta?.provider ?? fallbackProvider ?? provider;
const contextTokens =
agentCfg?.contextTokens ??
lookupContextTokens(modelUsed) ??
DEFAULT_CONTEXT_TOKENS;
agentCfg?.contextTokens ?? lookupContextTokens(modelUsed) ?? DEFAULT_CONTEXT_TOKENS;
cronSession.sessionEntry.modelProvider = providerUsed;
cronSession.sessionEntry.model = modelUsed;
@@ -359,8 +309,7 @@ export async function runCronIsolatedAgentTurn(params: {
if (hasNonzeroUsage(usage)) {
const input = usage.input ?? 0;
const output = usage.output ?? 0;
const promptTokens =
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
const promptTokens = input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
cronSession.sessionEntry.inputTokens = input;
cronSession.sessionEntry.outputTokens = output;
cronSession.sessionEntry.totalTokens =
@@ -370,19 +319,16 @@ export async function runCronIsolatedAgentTurn(params: {
await saveSessionStore(cronSession.storePath, cronSession.store);
}
const firstText = payloads[0]?.text ?? "";
const summary =
pickSummaryFromPayloads(payloads) ?? pickSummaryFromOutput(firstText);
const summary = pickSummaryFromPayloads(payloads) ?? pickSummaryFromOutput(firstText);
// Skip delivery for heartbeat-only responses (HEARTBEAT_OK with no real content).
const ackMaxChars = resolveHeartbeatAckMaxChars(agentCfg);
const skipHeartbeatDelivery =
delivery && isHeartbeatOnlyResponse(payloads, ackMaxChars);
const skipHeartbeatDelivery = delivery && isHeartbeatOnlyResponse(payloads, ackMaxChars);
if (delivery && !skipHeartbeatDelivery) {
if (!resolvedDelivery.to) {
const reason =
resolvedDelivery.error?.message ??
"Cron delivery requires a recipient (--to).";
resolvedDelivery.error?.message ?? "Cron delivery requires a recipient (--to).";
if (!bestEffortDeliver) {
return {
status: "error",

View File

@@ -15,10 +15,7 @@ export function resolveCronSession(params: {
agentId: string;
}) {
const sessionCfg = params.cfg.session;
const idleMinutes = Math.max(
sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES,
1,
);
const idleMinutes = Math.max(sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES, 1);
const idleMs = idleMinutes * 60_000;
const storePath = resolveStorePath(sessionCfg?.store, {
agentId: params.agentId,

View File

@@ -109,8 +109,7 @@ export function normalizeCronJobInput(
if (options.applyDefaults) {
if (!next.wakeMode) next.wakeMode = "next-heartbeat";
if (!next.sessionTarget && isRecord(next.payload)) {
const kind =
typeof next.payload.kind === "string" ? next.payload.kind : "";
const kind = typeof next.payload.kind === "string" ? next.payload.kind : "";
if (kind === "systemEvent") next.sessionTarget = "main";
if (kind === "agentTurn") next.sessionTarget = "isolated";
}

View File

@@ -4,19 +4,13 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import {
appendCronRunLog,
readCronRunLogEntries,
resolveCronRunLogPath,
} from "./run-log.js";
import { appendCronRunLog, readCronRunLogEntries, resolveCronRunLogPath } from "./run-log.js";
describe("cron run log", () => {
it("resolves store path 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);
expect(p.endsWith(path.join(os.tmpdir(), "cron", "runs", "job-1.jsonl"))).toBe(true);
});
it("appends JSONL and prunes by line count", async () => {
@@ -50,9 +44,7 @@ describe("cron run log", () => {
});
it("reads newest entries and filters by jobId", async () => {
const dir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-cron-log-read-"),
);
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-cron-log-read-"));
const logPathA = path.join(dir, "runs", "a.jsonl");
const logPathB = path.join(dir, "runs", "b.jsonl");

View File

@@ -13,10 +13,7 @@ export type CronRunLogEntry = {
nextRunAtMs?: number;
};
export function resolveCronRunLogPath(params: {
storePath: string;
jobId: string;
}) {
export function resolveCronRunLogPath(params: { storePath: string; jobId: string }) {
const storePath = path.resolve(params.storePath);
const dir = path.dirname(storePath);
return path.join(dir, "runs", `${params.jobId}.jsonl`);
@@ -24,10 +21,7 @@ export function resolveCronRunLogPath(params: {
const writesByPath = new Map<string, Promise<void>>();
async function pruneIfNeeded(
filePath: string,
opts: { maxBytes: number; keepLines: number },
) {
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;
@@ -69,9 +63,7 @@ export async function readCronRunLogEntries(
): 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(() => "");
const raw = await fs.readFile(path.resolve(filePath), "utf-8").catch(() => "");
if (!raw.trim()) return [];
const parsed: CronRunLogEntry[] = [];
const lines = raw.split("\n");
@@ -82,8 +74,7 @@ export async function readCronRunLogEntries(
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.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);

View File

@@ -17,10 +17,7 @@ describe("cron schedule", () => {
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,
);
const next = computeNextRunAtMs({ kind: "every", everyMs: 30_000, anchorMs: anchor }, now);
expect(next).toBe(anchor + 30_000);
});
@@ -34,10 +31,7 @@ describe("cron schedule", () => {
it("advances when now matches anchor for every schedule", () => {
const anchor = Date.parse("2025-12-13T00:00:00.000Z");
const next = computeNextRunAtMs(
{ kind: "every", everyMs: 30_000, anchorMs: anchor },
anchor,
);
const next = computeNextRunAtMs({ kind: "every", everyMs: 30_000, anchorMs: anchor }, anchor);
expect(next).toBe(anchor + 30_000);
});
});

View File

@@ -1,10 +1,7 @@
import { Cron } from "croner";
import type { CronSchedule } from "./types.js";
export function computeNextRunAtMs(
schedule: CronSchedule,
nowMs: number,
): number | undefined {
export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): number | undefined {
if (schedule.kind === "at") {
return schedule.atMs > nowMs ? schedule.atMs : undefined;
}

View File

@@ -362,10 +362,9 @@ describe("CronService", () => {
await vi.runOnlyPendingTimersAsync();
await cron.list({ includeDisabled: true });
expect(enqueueSystemEvent).toHaveBeenCalledWith(
"Cron (error): last output",
{ agentId: undefined },
);
expect(enqueueSystemEvent).toHaveBeenCalledWith("Cron (error): last output", {
agentId: undefined,
});
expect(requestHeartbeatNow).toHaveBeenCalled();
cron.stop();
await store.cleanup();

View File

@@ -1,8 +1,5 @@
import * as ops from "./service/ops.js";
import {
type CronServiceDeps,
createCronServiceState,
} from "./service/state.js";
import { type CronServiceDeps, createCronServiceState } from "./service/state.js";
import type { CronJobCreate, CronJobPatch } from "./types.js";
export type { CronEvent, CronServiceDeps } from "./service/state.js";

View File

@@ -12,9 +12,7 @@ import type { CronServiceState } from "./state.js";
const STUCK_RUN_MS = 2 * 60 * 60 * 1000;
export function assertSupportedJobSpec(
job: Pick<CronJob, "sessionTarget" | "payload">,
) {
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"');
}
@@ -29,15 +27,11 @@ export function findJobOrThrow(state: CronServiceState, id: string) {
return job;
}
export function computeJobNextRunAtMs(
job: CronJob,
nowMs: number,
): number | undefined {
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;
if (job.state.lastStatus === "ok" && job.state.lastRunAtMs) return undefined;
return job.schedule.atMs;
}
return computeNextRunAtMs(job.schedule, nowMs);
@@ -67,9 +61,7 @@ export function recomputeNextRuns(state: CronServiceState) {
export function nextWakeAtMs(state: CronServiceState) {
const jobs = state.store?.jobs ?? [];
const enabled = jobs.filter(
(j) => j.enabled && typeof j.state.nextRunAtMs === "number",
);
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),
@@ -77,10 +69,7 @@ export function nextWakeAtMs(state: CronServiceState) {
);
}
export function createJob(
state: CronServiceState,
input: CronJobCreate,
): CronJob {
export function createJob(state: CronServiceState, input: CronJobCreate): CronJob {
const now = state.deps.nowMs();
const id = crypto.randomUUID();
const job: CronJob = {
@@ -108,11 +97,9 @@ export function createJob(
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 ("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 (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;
@@ -120,24 +107,14 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
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,
);
job.agentId = normalizeOptionalAgentId((patch as { agentId?: unknown }).agentId);
}
assertSupportedJobSpec(job);
}
export function isJobDue(
job: CronJob,
nowMs: number,
opts: { forced: boolean },
) {
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
);
return job.enabled && typeof job.state.nextRunAtMs === "number" && nowMs >= job.state.nextRunAtMs;
}
export function resolveJobPayloadTextForMain(job: CronJob): string | undefined {

View File

@@ -1,9 +1,6 @@
import type { CronServiceState } from "./state.js";
export async function locked<T>(
state: CronServiceState,
fn: () => Promise<T>,
): Promise<T> {
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(

View File

@@ -34,8 +34,7 @@ export function inferLegacyName(job: {
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?.kind === "agentTurn" && typeof job.payload.message === "string"
? job.payload.message
: "";
const firstLine =

View File

@@ -45,25 +45,17 @@ export async function status(state: CronServiceState) {
enabled: state.deps.cronEnabled,
storePath: state.deps.storePath,
jobs: state.store?.jobs.length ?? 0,
nextWakeAtMs:
state.deps.cronEnabled === true ? (nextWakeAtMs(state) ?? null) : null,
nextWakeAtMs: state.deps.cronEnabled === true ? (nextWakeAtMs(state) ?? null) : null,
};
});
}
export async function list(
state: CronServiceState,
opts?: { includeDisabled?: boolean },
) {
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),
);
const jobs = (state.store?.jobs ?? []).filter((j) => includeDisabled || j.enabled);
return jobs.sort((a, b) => (a.state.nextRunAtMs ?? 0) - (b.state.nextRunAtMs ?? 0));
});
}
@@ -84,11 +76,7 @@ export async function add(state: CronServiceState, input: CronJobCreate) {
});
}
export async function update(
state: CronServiceState,
id: string,
patch: CronJobPatch,
) {
export async function update(state: CronServiceState, id: string, patch: CronJobPatch) {
return await locked(state, async () => {
warnIfDisabled(state, "update");
await ensureLoaded(state);
@@ -129,11 +117,7 @@ export async function remove(state: CronServiceState, id: string) {
});
}
export async function run(
state: CronServiceState,
id: string,
mode?: "due" | "force",
) {
export async function run(state: CronServiceState, id: string, mode?: "due" | "force") {
return await locked(state, async () => {
warnIfDisabled(state, "run");
await ensureLoaded(state);

View File

@@ -1,10 +1,5 @@
import type { HeartbeatRunResult } from "../../infra/heartbeat-wake.js";
import type {
CronJob,
CronJobCreate,
CronJobPatch,
CronStoreFile,
} from "../types.js";
import type { CronJob, CronJobCreate, CronJobPatch, CronStoreFile } from "../types.js";
export type CronEvent = {
jobId: string;
@@ -31,9 +26,7 @@ export type CronServiceDeps = {
cronEnabled: boolean;
enqueueSystemEvent: (text: string, opts?: { agentId?: string }) => void;
requestHeartbeatNow: (opts?: { reason?: string }) => void;
runHeartbeatOnce?: (opts?: {
reason?: string;
}) => Promise<HeartbeatRunResult>;
runHeartbeatOnce?: (opts?: { reason?: string }) => Promise<HeartbeatRunResult>;
runIsolatedAgentJob: (params: { job: CronJob; message: string }) => Promise<{
status: "ok" | "error" | "skipped";
summary?: string;
@@ -55,9 +48,7 @@ export type CronServiceState = {
warnedDisabled: boolean;
};
export function createCronServiceState(
deps: CronServiceDeps,
): CronServiceState {
export function createCronServiceState(deps: CronServiceDeps): CronServiceState {
return {
deps: { ...deps, nowMs: deps.nowMs ?? (() => Date.now()) },
store: null,
@@ -83,9 +74,7 @@ export type CronRunResult =
| { ok: true; ran: false; reason: "not-due" }
| { ok: false };
export type CronRemoveResult =
| { ok: true; removed: boolean }
| { ok: false; removed: false };
export type CronRemoveResult = { ok: true; removed: boolean } | { ok: false; removed: false };
export type CronAddResult = CronJob;
export type CronUpdateResult = CronJob;

View File

@@ -1,10 +1,6 @@
import type { HeartbeatRunResult } from "../../infra/heartbeat-wake.js";
import type { CronJob } from "../types.js";
import {
computeJobNextRunAtMs,
nextWakeAtMs,
resolveJobPayloadTextForMain,
} from "./jobs.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";
@@ -70,11 +66,7 @@ export async function executeJob(
let deleted = false;
const finish = async (
status: "ok" | "error" | "skipped",
err?: string,
summary?: string,
) => {
const finish = async (status: "ok" | "error" | "skipped", err?: string, summary?: string) => {
const endedAt = state.deps.nowMs();
job.state.runningAtMs = undefined;
job.state.lastRunAtMs = startedAt;
@@ -83,9 +75,7 @@ export async function executeJob(
job.state.lastError = err;
const shouldDelete =
job.schedule.kind === "at" &&
status === "ok" &&
job.deleteAfterRun === true;
job.schedule.kind === "at" && status === "ok" && job.deleteAfterRun === true;
if (!shouldDelete) {
if (job.schedule.kind === "at" && status === "ok") {
@@ -145,8 +135,7 @@ export async function executeJob(
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 delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const maxWaitMs = 2 * 60_000;
const waitStartedAt = state.deps.nowMs();
@@ -194,8 +183,7 @@ export async function executeJob(
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 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));

View File

@@ -12,8 +12,7 @@ export const DEFAULT_CRON_STORE_PATH = path.join(DEFAULT_CRON_DIR, "jobs.json");
export function resolveCronStorePath(storePath?: string) {
if (storePath?.trim()) {
const raw = storePath.trim();
if (raw.startsWith("~"))
return path.resolve(raw.replace("~", os.homedir()));
if (raw.startsWith("~")) return path.resolve(raw.replace("~", os.homedir()));
return path.resolve(raw);
}
return DEFAULT_CRON_STORE_PATH;

View File

@@ -60,10 +60,7 @@ export type CronStoreFile = {
jobs: CronJob[];
};
export type CronJobCreate = Omit<
CronJob,
"id" | "createdAtMs" | "updatedAtMs" | "state"
> & {
export type CronJobCreate = Omit<CronJob, "id" | "createdAtMs" | "updatedAtMs" | "state"> & {
state?: Partial<CronJobState>;
};