mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-28 08:52:45 +00:00
refactor: dedupe gateway config and infra flows
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { vi } from "vitest";
|
||||
import { expect, vi } from "vitest";
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
|
||||
@@ -30,6 +30,20 @@ export function mockAgentPayloads(
|
||||
});
|
||||
}
|
||||
|
||||
export function expectDirectTelegramDelivery(
|
||||
deps: CliDeps,
|
||||
params: { chatId: string; text: string; messageThreadId?: number },
|
||||
) {
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1);
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
params.chatId,
|
||||
params.text,
|
||||
expect.objectContaining(
|
||||
params.messageThreadId === undefined ? {} : { messageThreadId: params.messageThreadId },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function runTelegramAnnounceTurn(params: {
|
||||
home: string;
|
||||
storePath: string;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
|
||||
import {
|
||||
createCliDeps,
|
||||
expectDirectTelegramDelivery,
|
||||
mockAgentPayloads,
|
||||
runTelegramAnnounceTurn,
|
||||
} from "./isolated-agent.delivery.test-helpers.js";
|
||||
@@ -30,14 +31,11 @@ describe("runCronIsolatedAgentTurn forum topic delivery", () => {
|
||||
expect(res.status).toBe("ok");
|
||||
expect(res.delivered).toBe(true);
|
||||
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1);
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
"forum message",
|
||||
expect.objectContaining({
|
||||
messageThreadId: 42,
|
||||
}),
|
||||
);
|
||||
expectDirectTelegramDelivery(deps, {
|
||||
chatId: "123",
|
||||
text: "forum message",
|
||||
messageThreadId: 42,
|
||||
});
|
||||
|
||||
vi.clearAllMocks();
|
||||
mockAgentPayloads([{ text: "plain message" }]);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import {
|
||||
createCliDeps,
|
||||
expectDirectTelegramDelivery,
|
||||
mockAgentPayloads,
|
||||
runTelegramAnnounceTurn,
|
||||
} from "./isolated-agent.delivery.test-helpers.js";
|
||||
@@ -262,14 +263,11 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
expect(res.status).toBe("ok");
|
||||
expect(res.delivered).toBe(true);
|
||||
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1);
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
"Final weather summary",
|
||||
expect.objectContaining({
|
||||
messageThreadId: 42,
|
||||
}),
|
||||
);
|
||||
expectDirectTelegramDelivery(deps, {
|
||||
chatId: "123",
|
||||
text: "Final weather summary",
|
||||
messageThreadId: 42,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import "./isolated-agent.mocks.js";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import { withTempHome as withTempHomeHelper } from "../../test/helpers/temp-home.js";
|
||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
@@ -11,7 +11,7 @@ import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
|
||||
import type { CronJob } from "./types.js";
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
return withTempHomeBase(fn, { prefix: "openclaw-cron-submodel-" });
|
||||
return withTempHomeHelper(fn, { prefix: "openclaw-cron-submodel-" });
|
||||
}
|
||||
|
||||
async function writeSessionStore(home: string) {
|
||||
|
||||
@@ -20,32 +20,74 @@ function expectNormalizedAtSchedule(scheduleInput: Record<string, unknown>) {
|
||||
expect(schedule.at).toBe(new Date(Date.parse("2026-01-12T18:00:00Z")).toISOString());
|
||||
}
|
||||
|
||||
function expectAnnounceDeliveryTarget(
|
||||
delivery: Record<string, unknown>,
|
||||
params: { channel: string; to: string },
|
||||
): void {
|
||||
expect(delivery.mode).toBe("announce");
|
||||
expect(delivery.channel).toBe(params.channel);
|
||||
expect(delivery.to).toBe(params.to);
|
||||
}
|
||||
|
||||
function expectPayloadDeliveryHintsCleared(payload: Record<string, unknown>): void {
|
||||
expect(payload.channel).toBeUndefined();
|
||||
expect(payload.deliver).toBeUndefined();
|
||||
}
|
||||
|
||||
function normalizeIsolatedAgentTurnCreateJob(params: {
|
||||
name: string;
|
||||
payload?: Record<string, unknown>;
|
||||
delivery?: Record<string, unknown>;
|
||||
}): Record<string, unknown> {
|
||||
return normalizeCronJobCreate({
|
||||
name: params.name,
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "* * * * *" },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "hi",
|
||||
...params.payload,
|
||||
},
|
||||
...(params.delivery ? { delivery: params.delivery } : {}),
|
||||
}) as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeMainSystemEventCreateJob(params: {
|
||||
name: string;
|
||||
schedule: Record<string, unknown>;
|
||||
}): Record<string, unknown> {
|
||||
return normalizeCronJobCreate({
|
||||
name: params.name,
|
||||
enabled: true,
|
||||
schedule: params.schedule,
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: {
|
||||
kind: "systemEvent",
|
||||
text: "tick",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("normalizeCronJobCreate", () => {
|
||||
it("maps legacy payload.provider to payload.channel and strips provider", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
const normalized = normalizeIsolatedAgentTurnCreateJob({
|
||||
name: "legacy",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "* * * * *" },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "hi",
|
||||
deliver: true,
|
||||
provider: " TeLeGrAm ",
|
||||
to: "7200373102",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
});
|
||||
|
||||
const payload = normalized.payload as Record<string, unknown>;
|
||||
expect(payload.channel).toBeUndefined();
|
||||
expect(payload.deliver).toBeUndefined();
|
||||
expectPayloadDeliveryHintsCleared(payload);
|
||||
expect("provider" in payload).toBe(false);
|
||||
|
||||
const delivery = normalized.delivery as Record<string, unknown>;
|
||||
expect(delivery.mode).toBe("announce");
|
||||
expect(delivery.channel).toBe("telegram");
|
||||
expect(delivery.to).toBe("7200373102");
|
||||
expectAnnounceDeliveryTarget(delivery, { channel: "telegram", to: "7200373102" });
|
||||
});
|
||||
|
||||
it("trims agentId and drops null", () => {
|
||||
@@ -105,29 +147,20 @@ describe("normalizeCronJobCreate", () => {
|
||||
});
|
||||
|
||||
it("canonicalizes payload.channel casing", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
const normalized = normalizeIsolatedAgentTurnCreateJob({
|
||||
name: "legacy provider",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "* * * * *" },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "hi",
|
||||
deliver: true,
|
||||
channel: "Telegram",
|
||||
to: "7200373102",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
});
|
||||
|
||||
const payload = normalized.payload as Record<string, unknown>;
|
||||
expect(payload.channel).toBeUndefined();
|
||||
expect(payload.deliver).toBeUndefined();
|
||||
expectPayloadDeliveryHintsCleared(payload);
|
||||
|
||||
const delivery = normalized.delivery as Record<string, unknown>;
|
||||
expect(delivery.mode).toBe("announce");
|
||||
expect(delivery.channel).toBe("telegram");
|
||||
expect(delivery.to).toBe("7200373102");
|
||||
expectAnnounceDeliveryTarget(delivery, { channel: "telegram", to: "7200373102" });
|
||||
});
|
||||
|
||||
it("coerces ISO schedule.at to normalized ISO (UTC)", () => {
|
||||
@@ -139,17 +172,10 @@ describe("normalizeCronJobCreate", () => {
|
||||
});
|
||||
|
||||
it("migrates legacy schedule.cron into schedule.expr", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
const normalized = normalizeMainSystemEventCreateJob({
|
||||
name: "legacy-cron-field",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", cron: "*/10 * * * *", tz: "UTC" },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: {
|
||||
kind: "systemEvent",
|
||||
text: "tick",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
});
|
||||
|
||||
const schedule = normalized.schedule as Record<string, unknown>;
|
||||
expect(schedule.kind).toBe("cron");
|
||||
@@ -158,34 +184,20 @@ describe("normalizeCronJobCreate", () => {
|
||||
});
|
||||
|
||||
it("defaults cron stagger for recurring top-of-hour schedules", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
const normalized = normalizeMainSystemEventCreateJob({
|
||||
name: "hourly",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "0 * * * *", tz: "UTC" },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: {
|
||||
kind: "systemEvent",
|
||||
text: "tick",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
});
|
||||
|
||||
const schedule = normalized.schedule as Record<string, unknown>;
|
||||
expect(schedule.staggerMs).toBe(DEFAULT_TOP_OF_HOUR_STAGGER_MS);
|
||||
});
|
||||
|
||||
it("preserves explicit exact cron schedule", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
const normalized = normalizeMainSystemEventCreateJob({
|
||||
name: "exact",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "0 * * * *", tz: "UTC", staggerMs: 0 },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: {
|
||||
kind: "systemEvent",
|
||||
text: "tick",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
});
|
||||
|
||||
const schedule = normalized.schedule as Record<string, unknown>;
|
||||
expect(schedule.staggerMs).toBe(0);
|
||||
@@ -208,69 +220,43 @@ describe("normalizeCronJobCreate", () => {
|
||||
});
|
||||
|
||||
it("normalizes delivery mode and channel", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
const normalized = normalizeIsolatedAgentTurnCreateJob({
|
||||
name: "delivery",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "* * * * *" },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "hi",
|
||||
},
|
||||
delivery: {
|
||||
mode: " ANNOUNCE ",
|
||||
channel: " TeLeGrAm ",
|
||||
to: " 7200373102 ",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
});
|
||||
|
||||
const delivery = normalized.delivery as Record<string, unknown>;
|
||||
expect(delivery.mode).toBe("announce");
|
||||
expect(delivery.channel).toBe("telegram");
|
||||
expect(delivery.to).toBe("7200373102");
|
||||
expectAnnounceDeliveryTarget(delivery, { channel: "telegram", to: "7200373102" });
|
||||
});
|
||||
|
||||
it("normalizes delivery accountId and strips blanks", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
const normalized = normalizeIsolatedAgentTurnCreateJob({
|
||||
name: "delivery account",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "* * * * *" },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "hi",
|
||||
},
|
||||
delivery: {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
to: "-1003816714067",
|
||||
accountId: " coordinator ",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
});
|
||||
|
||||
const delivery = normalized.delivery as Record<string, unknown>;
|
||||
expect(delivery.accountId).toBe("coordinator");
|
||||
});
|
||||
|
||||
it("strips empty accountId from delivery", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
const normalized = normalizeIsolatedAgentTurnCreateJob({
|
||||
name: "empty account",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "* * * * *" },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "hi",
|
||||
},
|
||||
delivery: {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
accountId: " ",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
});
|
||||
|
||||
const delivery = normalized.delivery as Record<string, unknown>;
|
||||
expect("accountId" in delivery).toBe(false);
|
||||
@@ -296,15 +282,9 @@ describe("normalizeCronJobCreate", () => {
|
||||
});
|
||||
|
||||
it("defaults isolated agentTurn delivery to announce", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
const normalized = normalizeIsolatedAgentTurnCreateJob({
|
||||
name: "default-announce",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "* * * * *" },
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "hi",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
});
|
||||
|
||||
const delivery = normalized.delivery as Record<string, unknown>;
|
||||
expect(delivery.mode).toBe("announce");
|
||||
@@ -326,9 +306,7 @@ describe("normalizeCronJobCreate", () => {
|
||||
}) as unknown as Record<string, unknown>;
|
||||
|
||||
const delivery = normalized.delivery as Record<string, unknown>;
|
||||
expect(delivery.mode).toBe("announce");
|
||||
expect(delivery.channel).toBe("telegram");
|
||||
expect(delivery.to).toBe("7200373102");
|
||||
expectAnnounceDeliveryTarget(delivery, { channel: "telegram", to: "7200373102" });
|
||||
expect(delivery.bestEffort).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@ import type { CronServiceState } from "./service/state.js";
|
||||
import { DEFAULT_TOP_OF_HOUR_STAGGER_MS } from "./stagger.js";
|
||||
import type { CronJob, CronJobPatch } from "./types.js";
|
||||
|
||||
function expectCronStaggerMs(job: CronJob, expected: number): void {
|
||||
expect(job.schedule.kind).toBe("cron");
|
||||
if (job.schedule.kind === "cron") {
|
||||
expect(job.schedule.staggerMs).toBe(expected);
|
||||
}
|
||||
}
|
||||
|
||||
describe("applyJobPatch", () => {
|
||||
const createIsolatedAgentTurnJob = (
|
||||
id: string,
|
||||
@@ -481,10 +488,7 @@ describe("cron stagger defaults", () => {
|
||||
payload: { kind: "systemEvent", text: "tick" },
|
||||
});
|
||||
|
||||
expect(job.schedule.kind).toBe("cron");
|
||||
if (job.schedule.kind === "cron") {
|
||||
expect(job.schedule.staggerMs).toBe(DEFAULT_TOP_OF_HOUR_STAGGER_MS);
|
||||
}
|
||||
expectCronStaggerMs(job, DEFAULT_TOP_OF_HOUR_STAGGER_MS);
|
||||
});
|
||||
|
||||
it("keeps exact schedules when staggerMs is explicitly 0", () => {
|
||||
@@ -500,10 +504,7 @@ describe("cron stagger defaults", () => {
|
||||
payload: { kind: "systemEvent", text: "tick" },
|
||||
});
|
||||
|
||||
expect(job.schedule.kind).toBe("cron");
|
||||
if (job.schedule.kind === "cron") {
|
||||
expect(job.schedule.staggerMs).toBe(0);
|
||||
}
|
||||
expectCronStaggerMs(job, 0);
|
||||
});
|
||||
|
||||
it("preserves existing stagger when editing cron expression without stagger", () => {
|
||||
|
||||
@@ -333,6 +333,20 @@ async function runIsolatedAnnounceJobAndWait(params: {
|
||||
return job;
|
||||
}
|
||||
|
||||
async function runIsolatedAnnounceScenario(params: {
|
||||
cron: CronService;
|
||||
events: ReturnType<typeof createCronEventHarness>;
|
||||
name: string;
|
||||
status?: "ok" | "error";
|
||||
}) {
|
||||
await runIsolatedAnnounceJobAndWait({
|
||||
cron: params.cron,
|
||||
events: params.events,
|
||||
name: params.name,
|
||||
status: params.status ?? "ok",
|
||||
});
|
||||
}
|
||||
|
||||
async function addWakeModeNowMainSystemEventJob(
|
||||
cron: CronService,
|
||||
options?: { name?: string; agentId?: string; sessionKey?: string },
|
||||
@@ -349,6 +363,82 @@ async function addWakeModeNowMainSystemEventJob(
|
||||
});
|
||||
}
|
||||
|
||||
async function addMainOneShotHelloJob(
|
||||
cron: CronService,
|
||||
params: { atMs: number; name: string; deleteAfterRun?: boolean },
|
||||
) {
|
||||
return cron.add({
|
||||
name: params.name,
|
||||
enabled: true,
|
||||
...(params.deleteAfterRun === undefined ? {} : { deleteAfterRun: params.deleteAfterRun }),
|
||||
schedule: { kind: "at", at: new Date(params.atMs).toISOString() },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
});
|
||||
}
|
||||
|
||||
function expectMainSystemEventPosted(enqueueSystemEvent: unknown, text: string) {
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledWith(
|
||||
text,
|
||||
expect.objectContaining({ agentId: undefined }),
|
||||
);
|
||||
}
|
||||
|
||||
async function stopCronAndCleanup(cron: CronService, store: { cleanup: () => Promise<void> }) {
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
}
|
||||
|
||||
function createStartedCronService(
|
||||
storePath: string,
|
||||
runIsolatedAgentJob?: CronServiceDeps["runIsolatedAgentJob"],
|
||||
) {
|
||||
return new CronService({
|
||||
storePath,
|
||||
cronEnabled: true,
|
||||
log: noopLogger,
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
requestHeartbeatNow: vi.fn(),
|
||||
runIsolatedAgentJob: runIsolatedAgentJob ?? vi.fn(async () => ({ status: "ok" as const })),
|
||||
});
|
||||
}
|
||||
|
||||
async function createMainOneShotJobHarness(params: { name: string; deleteAfterRun?: boolean }) {
|
||||
const harness = await createMainOneShotHarness();
|
||||
const atMs = Date.parse("2025-12-13T00:00:02.000Z");
|
||||
const job = await addMainOneShotHelloJob(harness.cron, {
|
||||
atMs,
|
||||
name: params.name,
|
||||
deleteAfterRun: params.deleteAfterRun,
|
||||
});
|
||||
return { ...harness, atMs, job };
|
||||
}
|
||||
|
||||
async function loadLegacyDeliveryMigrationByPayload(params: {
|
||||
id: string;
|
||||
payload: { provider?: string; channel?: string };
|
||||
}) {
|
||||
const rawJob = createLegacyDeliveryMigrationJob(params);
|
||||
return loadLegacyDeliveryMigration(rawJob);
|
||||
}
|
||||
|
||||
async function expectNoMainSummaryForIsolatedRun(params: {
|
||||
runIsolatedAgentJob: CronServiceDeps["runIsolatedAgentJob"];
|
||||
name: string;
|
||||
}) {
|
||||
const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } =
|
||||
await createIsolatedAnnounceHarness(params.runIsolatedAgentJob);
|
||||
await runIsolatedAnnounceScenario({
|
||||
cron,
|
||||
events,
|
||||
name: params.name,
|
||||
});
|
||||
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
expect(requestHeartbeatNow).not.toHaveBeenCalled();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
}
|
||||
|
||||
function createLegacyDeliveryMigrationJob(options: {
|
||||
id: string;
|
||||
payload: { provider?: string; channel?: string };
|
||||
@@ -378,14 +468,7 @@ async function loadLegacyDeliveryMigration(rawJob: Record<string, unknown>) {
|
||||
const store = await makeStorePath();
|
||||
writeStoreFile(store.storePath, { version: 1, jobs: [rawJob] });
|
||||
|
||||
const cron = new CronService({
|
||||
storePath: store.storePath,
|
||||
cronEnabled: true,
|
||||
log: noopLogger,
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
requestHeartbeatNow: vi.fn(),
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
|
||||
});
|
||||
const cron = createStartedCronService(store.storePath);
|
||||
await cron.start();
|
||||
const jobs = await cron.list({ includeDisabled: true });
|
||||
const job = jobs.find((j) => j.id === rawJob.id);
|
||||
@@ -394,18 +477,11 @@ async function loadLegacyDeliveryMigration(rawJob: Record<string, unknown>) {
|
||||
|
||||
describe("CronService", () => {
|
||||
it("runs a one-shot main job and disables it after success when requested", async () => {
|
||||
const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } =
|
||||
await createMainOneShotHarness();
|
||||
const atMs = Date.parse("2025-12-13T00:00:02.000Z");
|
||||
const job = await cron.add({
|
||||
name: "one-shot hello",
|
||||
enabled: true,
|
||||
deleteAfterRun: false,
|
||||
schedule: { kind: "at", at: new Date(atMs).toISOString() },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
});
|
||||
const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events, atMs, job } =
|
||||
await createMainOneShotJobHarness({
|
||||
name: "one-shot hello",
|
||||
deleteAfterRun: false,
|
||||
});
|
||||
|
||||
expect(job.state.nextRunAtMs).toBe(atMs);
|
||||
|
||||
@@ -416,29 +492,18 @@ describe("CronService", () => {
|
||||
const jobs = await cron.list({ includeDisabled: true });
|
||||
const updated = jobs.find((j) => j.id === job.id);
|
||||
expect(updated?.enabled).toBe(false);
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
expect.objectContaining({ agentId: undefined }),
|
||||
);
|
||||
expectMainSystemEventPosted(enqueueSystemEvent, "hello");
|
||||
expect(requestHeartbeatNow).toHaveBeenCalled();
|
||||
|
||||
await cron.list({ includeDisabled: true });
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
|
||||
it("runs a one-shot job and deletes it after success by default", async () => {
|
||||
const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } =
|
||||
await createMainOneShotHarness();
|
||||
const atMs = Date.parse("2025-12-13T00:00:02.000Z");
|
||||
const job = await cron.add({
|
||||
name: "one-shot delete",
|
||||
enabled: true,
|
||||
schedule: { kind: "at", at: new Date(atMs).toISOString() },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
});
|
||||
const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events, job } =
|
||||
await createMainOneShotJobHarness({
|
||||
name: "one-shot delete",
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date("2025-12-13T00:00:02.000Z"));
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
@@ -446,14 +511,10 @@ describe("CronService", () => {
|
||||
|
||||
const jobs = await cron.list({ includeDisabled: true });
|
||||
expect(jobs.find((j) => j.id === job.id)).toBeUndefined();
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
expect.objectContaining({ agentId: undefined }),
|
||||
);
|
||||
expectMainSystemEventPosted(enqueueSystemEvent, "hello");
|
||||
expect(requestHeartbeatNow).toHaveBeenCalled();
|
||||
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
|
||||
it("wakeMode now waits for heartbeat completion when available", async () => {
|
||||
@@ -491,10 +552,7 @@ describe("CronService", () => {
|
||||
|
||||
expect(runHeartbeatOnce).toHaveBeenCalledTimes(1);
|
||||
expect(requestHeartbeatNow).not.toHaveBeenCalled();
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
expect.objectContaining({ agentId: undefined }),
|
||||
);
|
||||
expectMainSystemEventPosted(enqueueSystemEvent, "hello");
|
||||
expect(job.state.runningAtMs).toBeTypeOf("number");
|
||||
|
||||
if (typeof resolveHeartbeat === "function") {
|
||||
@@ -505,8 +563,7 @@ describe("CronService", () => {
|
||||
expect(job.state.lastStatus).toBe("ok");
|
||||
expect(job.state.lastDurationMs).toBeGreaterThan(0);
|
||||
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
|
||||
it("rejects sessionTarget main for non-default agents at creation time", async () => {
|
||||
@@ -525,8 +582,7 @@ describe("CronService", () => {
|
||||
}),
|
||||
).rejects.toThrow('cron: sessionTarget "main" is only valid for the default agent');
|
||||
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
|
||||
it("wakeMode now falls back to queued heartbeat when main lane stays busy", async () => {
|
||||
@@ -567,23 +623,18 @@ describe("CronService", () => {
|
||||
expect(job.state.lastError).toBeUndefined();
|
||||
|
||||
await cron.list({ includeDisabled: true });
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
|
||||
it("runs an isolated job and posts summary to main", async () => {
|
||||
const runIsolatedAgentJob = vi.fn(async () => ({ status: "ok" as const, summary: "done" }));
|
||||
const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } =
|
||||
await createIsolatedAnnounceHarness(runIsolatedAgentJob);
|
||||
await runIsolatedAnnounceJobAndWait({ cron, events, name: "weekly", status: "ok" });
|
||||
await runIsolatedAnnounceScenario({ cron, events, name: "weekly" });
|
||||
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledWith(
|
||||
"Cron: done",
|
||||
expect.objectContaining({ agentId: undefined }),
|
||||
);
|
||||
expectMainSystemEventPosted(enqueueSystemEvent, "Cron: done");
|
||||
expect(requestHeartbeatNow).toHaveBeenCalled();
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
|
||||
it("does not post isolated summary to main when run already delivered output", async () => {
|
||||
@@ -592,19 +643,11 @@ describe("CronService", () => {
|
||||
summary: "done",
|
||||
delivered: true,
|
||||
}));
|
||||
const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } =
|
||||
await createIsolatedAnnounceHarness(runIsolatedAgentJob);
|
||||
await runIsolatedAnnounceJobAndWait({
|
||||
cron,
|
||||
events,
|
||||
await expectNoMainSummaryForIsolatedRun({
|
||||
runIsolatedAgentJob,
|
||||
name: "weekly delivered",
|
||||
status: "ok",
|
||||
});
|
||||
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
|
||||
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
expect(requestHeartbeatNow).not.toHaveBeenCalled();
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
});
|
||||
|
||||
it("does not post isolated summary to main when announce delivery was attempted", async () => {
|
||||
@@ -614,27 +657,18 @@ describe("CronService", () => {
|
||||
delivered: false,
|
||||
deliveryAttempted: true,
|
||||
}));
|
||||
const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } =
|
||||
await createIsolatedAnnounceHarness(runIsolatedAgentJob);
|
||||
await runIsolatedAnnounceJobAndWait({
|
||||
cron,
|
||||
events,
|
||||
await expectNoMainSummaryForIsolatedRun({
|
||||
runIsolatedAgentJob,
|
||||
name: "weekly attempted",
|
||||
status: "ok",
|
||||
});
|
||||
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
|
||||
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
expect(requestHeartbeatNow).not.toHaveBeenCalled();
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
});
|
||||
|
||||
it("migrates legacy payload.provider to payload.channel on load", async () => {
|
||||
const rawJob = createLegacyDeliveryMigrationJob({
|
||||
const { store, cron, job } = await loadLegacyDeliveryMigrationByPayload({
|
||||
id: "legacy-1",
|
||||
payload: { provider: " TeLeGrAm " },
|
||||
});
|
||||
const { store, cron, job } = await loadLegacyDeliveryMigration(rawJob);
|
||||
// Legacy delivery fields are migrated to the top-level delivery object
|
||||
const delivery = job?.delivery as unknown as Record<string, unknown>;
|
||||
expect(delivery?.channel).toBe("telegram");
|
||||
@@ -642,22 +676,19 @@ describe("CronService", () => {
|
||||
expect("provider" in payload).toBe(false);
|
||||
expect("channel" in payload).toBe(false);
|
||||
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
|
||||
it("canonicalizes payload.channel casing on load", async () => {
|
||||
const rawJob = createLegacyDeliveryMigrationJob({
|
||||
const { store, cron, job } = await loadLegacyDeliveryMigrationByPayload({
|
||||
id: "legacy-2",
|
||||
payload: { channel: "Telegram" },
|
||||
});
|
||||
const { store, cron, job } = await loadLegacyDeliveryMigration(rawJob);
|
||||
// Legacy delivery fields are migrated to the top-level delivery object
|
||||
const delivery = job?.delivery as unknown as Record<string, unknown>;
|
||||
expect(delivery?.channel).toBe("telegram");
|
||||
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
|
||||
it("posts last output to main even when isolated job errors", async () => {
|
||||
@@ -675,13 +706,9 @@ describe("CronService", () => {
|
||||
status: "error",
|
||||
});
|
||||
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledWith(
|
||||
"Cron (error): last output",
|
||||
expect.objectContaining({ agentId: undefined }),
|
||||
);
|
||||
expectMainSystemEventPosted(enqueueSystemEvent, "Cron (error): last output");
|
||||
expect(requestHeartbeatNow).toHaveBeenCalled();
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
|
||||
it("does not post fallback main summary for isolated delivery-target errors", async () => {
|
||||
@@ -702,24 +729,19 @@ describe("CronService", () => {
|
||||
|
||||
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
expect(requestHeartbeatNow).not.toHaveBeenCalled();
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
|
||||
it("rejects unsupported session/payload combinations", async () => {
|
||||
ensureDir(fixturesRoot);
|
||||
const store = await makeStorePath();
|
||||
|
||||
const cron = new CronService({
|
||||
storePath: store.storePath,
|
||||
cronEnabled: true,
|
||||
log: noopLogger,
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
requestHeartbeatNow: vi.fn(),
|
||||
runIsolatedAgentJob: vi.fn(async (_params: { job: unknown; message: string }) => ({
|
||||
status: "ok",
|
||||
const cron = createStartedCronService(
|
||||
store.storePath,
|
||||
vi.fn(async (_params: { job: unknown; message: string }) => ({
|
||||
status: "ok" as const,
|
||||
})) as unknown as CronServiceDeps["runIsolatedAgentJob"],
|
||||
});
|
||||
);
|
||||
|
||||
await cron.start();
|
||||
|
||||
|
||||
@@ -32,44 +32,61 @@ async function listJobById(cron: CronService, jobId: string) {
|
||||
return jobs.find((entry) => entry.id === jobId);
|
||||
}
|
||||
|
||||
async function startCronWithStoredJobs(jobs: Array<Record<string, unknown>>) {
|
||||
const store = await makeStorePath();
|
||||
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
store.storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
jobs,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
const cron = await createStartedCron(store.storePath).start();
|
||||
return { store, cron };
|
||||
}
|
||||
|
||||
async function stopCronAndCleanup(cron: CronService, store: { cleanup: () => Promise<void> }) {
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
}
|
||||
|
||||
function createLegacyIsolatedAgentTurnJob(
|
||||
overrides: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
enabled: true,
|
||||
createdAtMs: Date.parse("2026-02-01T12:00:00.000Z"),
|
||||
updatedAtMs: Date.parse("2026-02-05T12:00:00.000Z"),
|
||||
schedule: { kind: "cron", expr: "0 23 * * *", tz: "UTC" },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "agentTurn", message: "legacy payload fields" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CronService store migrations", () => {
|
||||
it("migrates legacy top-level agentTurn fields and initializes missing state", async () => {
|
||||
const store = await makeStorePath();
|
||||
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
store.storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
jobs: [
|
||||
{
|
||||
id: "legacy-agentturn-job",
|
||||
name: "legacy agentturn",
|
||||
enabled: true,
|
||||
createdAtMs: Date.parse("2026-02-01T12:00:00.000Z"),
|
||||
updatedAtMs: Date.parse("2026-02-05T12:00:00.000Z"),
|
||||
schedule: { kind: "cron", expr: "0 23 * * *", tz: "UTC" },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "next-heartbeat",
|
||||
model: "openrouter/deepseek/deepseek-r1",
|
||||
thinking: "high",
|
||||
timeoutSeconds: 120,
|
||||
allowUnsafeExternalContent: true,
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "12345",
|
||||
bestEffortDeliver: true,
|
||||
payload: { kind: "agentTurn", message: "legacy payload fields" },
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const cron = await createStartedCron(store.storePath).start();
|
||||
const { store, cron } = await startCronWithStoredJobs([
|
||||
createLegacyIsolatedAgentTurnJob({
|
||||
id: "legacy-agentturn-job",
|
||||
name: "legacy agentturn",
|
||||
model: "openrouter/deepseek/deepseek-r1",
|
||||
thinking: "high",
|
||||
timeoutSeconds: 120,
|
||||
allowUnsafeExternalContent: true,
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "12345",
|
||||
bestEffortDeliver: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const status = await cron.status();
|
||||
expect(status.enabled).toBe(true);
|
||||
@@ -106,40 +123,17 @@ describe("CronService store migrations", () => {
|
||||
expect(persistedJob?.to).toBeUndefined();
|
||||
expect(persistedJob?.bestEffortDeliver).toBeUndefined();
|
||||
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
|
||||
it("preserves legacy timeoutSeconds=0 during top-level agentTurn field migration", async () => {
|
||||
const store = await makeStorePath();
|
||||
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
store.storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
jobs: [
|
||||
{
|
||||
id: "legacy-agentturn-no-timeout",
|
||||
name: "legacy no-timeout",
|
||||
enabled: true,
|
||||
createdAtMs: Date.parse("2026-02-01T12:00:00.000Z"),
|
||||
updatedAtMs: Date.parse("2026-02-05T12:00:00.000Z"),
|
||||
schedule: { kind: "cron", expr: "0 23 * * *", tz: "UTC" },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "next-heartbeat",
|
||||
timeoutSeconds: 0,
|
||||
payload: { kind: "agentTurn", message: "legacy payload fields" },
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const cron = await createStartedCron(store.storePath).start();
|
||||
const { store, cron } = await startCronWithStoredJobs([
|
||||
createLegacyIsolatedAgentTurnJob({
|
||||
id: "legacy-agentturn-no-timeout",
|
||||
name: "legacy no-timeout",
|
||||
timeoutSeconds: 0,
|
||||
}),
|
||||
]);
|
||||
|
||||
const job = await listJobById(cron, "legacy-agentturn-no-timeout");
|
||||
expect(job).toBeDefined();
|
||||
@@ -148,38 +142,22 @@ describe("CronService store migrations", () => {
|
||||
expect(job.payload.timeoutSeconds).toBe(0);
|
||||
}
|
||||
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
|
||||
it("migrates legacy cron fields (jobId + schedule.cron) and defaults wakeMode", async () => {
|
||||
const store = await makeStorePath();
|
||||
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
store.storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
jobs: [
|
||||
{
|
||||
jobId: "legacy-cron-field-job",
|
||||
name: "legacy cron field",
|
||||
enabled: true,
|
||||
createdAtMs: Date.parse("2026-02-01T12:00:00.000Z"),
|
||||
updatedAtMs: Date.parse("2026-02-05T12:00:00.000Z"),
|
||||
schedule: { kind: "cron", cron: "*/5 * * * *", tz: "UTC" },
|
||||
payload: { kind: "systemEvent", text: "tick" },
|
||||
state: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const cron = await createStartedCron(store.storePath).start();
|
||||
const { store, cron } = await startCronWithStoredJobs([
|
||||
{
|
||||
jobId: "legacy-cron-field-job",
|
||||
name: "legacy cron field",
|
||||
enabled: true,
|
||||
createdAtMs: Date.parse("2026-02-01T12:00:00.000Z"),
|
||||
updatedAtMs: Date.parse("2026-02-05T12:00:00.000Z"),
|
||||
schedule: { kind: "cron", cron: "*/5 * * * *", tz: "UTC" },
|
||||
payload: { kind: "systemEvent", text: "tick" },
|
||||
state: {},
|
||||
},
|
||||
]);
|
||||
const job = await listJobById(cron, "legacy-cron-field-job");
|
||||
expect(job).toBeDefined();
|
||||
expect(job?.wakeMode).toBe("now");
|
||||
@@ -202,7 +180,6 @@ describe("CronService store migrations", () => {
|
||||
expect(persistedSchedule?.cron).toBeUndefined();
|
||||
expect(persistedSchedule?.expr).toBe("*/5 * * * *");
|
||||
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user