chore: migrate to oxlint and oxfmt
Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
export {
|
||||
type RunCronAgentTurnResult,
|
||||
runCronIsolatedAgentTurn,
|
||||
} from "./isolated-agent/run.js";
|
||||
export { type RunCronAgentTurnResult, runCronIsolatedAgentTurn } from "./isolated-agent/run.js";
|
||||
|
||||
@@ -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") },
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user