mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 12:31:23 +00:00
feat(cron): enhance delivery modes and job configuration
- Updated isolated cron jobs to support new delivery modes: `announce` and `none`, improving output management. - Refactored job configuration to remove legacy fields and streamline delivery settings. - Enhanced the `CronJobEditor` UI to reflect changes in delivery options, including a new segmented control for delivery mode selection. - Updated documentation to clarify the new delivery configurations and their implications for job execution. - Improved tests to validate the new delivery behavior and ensure backward compatibility with legacy settings. This update provides users with greater flexibility in managing how isolated jobs deliver their outputs, enhancing overall usability and clarity in job configurations.
This commit is contained in:
committed by
Peter Steinberger
parent
ab9f06f4ff
commit
3f82daefd8
@@ -4,10 +4,8 @@ export type CronDeliveryPlan = {
|
||||
mode: CronDeliveryMode;
|
||||
channel: CronMessageChannel;
|
||||
to?: string;
|
||||
bestEffort: boolean;
|
||||
source: "delivery" | "payload";
|
||||
requested: boolean;
|
||||
legacyMode?: "explicit" | "auto" | "off";
|
||||
};
|
||||
|
||||
function normalizeChannel(value: unknown): CronMessageChannel | undefined {
|
||||
@@ -35,19 +33,20 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan {
|
||||
const hasDelivery = delivery && typeof delivery === "object";
|
||||
const rawMode = hasDelivery ? (delivery as { mode?: unknown }).mode : undefined;
|
||||
const mode =
|
||||
rawMode === "none" || rawMode === "announce" || rawMode === "deliver" ? rawMode : undefined;
|
||||
rawMode === "announce"
|
||||
? "announce"
|
||||
: rawMode === "none"
|
||||
? "none"
|
||||
: rawMode === "deliver"
|
||||
? "announce"
|
||||
: undefined;
|
||||
|
||||
const payloadChannel = normalizeChannel(payload?.channel);
|
||||
const payloadTo = normalizeTo(payload?.to);
|
||||
const payloadBestEffort = payload?.bestEffortDeliver === true;
|
||||
|
||||
const deliveryChannel = normalizeChannel(
|
||||
(delivery as { channel?: unknown } | undefined)?.channel,
|
||||
);
|
||||
const deliveryTo = normalizeTo((delivery as { to?: unknown } | undefined)?.to);
|
||||
const deliveryBestEffortRaw = (delivery as { bestEffort?: unknown } | undefined)?.bestEffort;
|
||||
const deliveryBestEffort =
|
||||
typeof deliveryBestEffortRaw === "boolean" ? deliveryBestEffortRaw : undefined;
|
||||
|
||||
const channel = (deliveryChannel ?? payloadChannel ?? "last") as CronMessageChannel;
|
||||
const to = deliveryTo ?? payloadTo;
|
||||
@@ -57,9 +56,8 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan {
|
||||
mode: resolvedMode,
|
||||
channel,
|
||||
to,
|
||||
bestEffort: deliveryBestEffort ?? false,
|
||||
source: "delivery",
|
||||
requested: resolvedMode !== "none",
|
||||
requested: resolvedMode === "announce",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,12 +67,10 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan {
|
||||
const requested = legacyMode === "explicit" || (legacyMode === "auto" && hasExplicitTarget);
|
||||
|
||||
return {
|
||||
mode: requested ? "deliver" : "none",
|
||||
mode: requested ? "announce" : "none",
|
||||
channel,
|
||||
to,
|
||||
bestEffort: payloadBestEffort,
|
||||
source: "payload",
|
||||
requested,
|
||||
legacyMode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,9 +14,13 @@ vi.mock("../agents/pi-embedded.js", () => ({
|
||||
vi.mock("../agents/model-catalog.js", () => ({
|
||||
loadModelCatalog: vi.fn(),
|
||||
}));
|
||||
vi.mock("../agents/subagent-announce.js", () => ({
|
||||
runSubagentAnnounceFlow: vi.fn(),
|
||||
}));
|
||||
|
||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
|
||||
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
@@ -67,6 +71,7 @@ function makeJob(payload: CronJob["payload"]): CronJob {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: "job-1",
|
||||
name: "job-1",
|
||||
enabled: true,
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
@@ -75,7 +80,6 @@ function makeJob(payload: CronJob["payload"]): CronJob {
|
||||
wakeMode: "now",
|
||||
payload,
|
||||
state: {},
|
||||
isolation: { postToMainPrefix: "Cron" },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,6 +87,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
vi.mocked(loadModelCatalog).mockResolvedValue([]);
|
||||
vi.mocked(runSubagentAnnounceFlow).mockReset().mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("delivers when response has HEARTBEAT_OK but includes media", async () => {
|
||||
@@ -110,24 +115,20 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
}),
|
||||
job: {
|
||||
...makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
}),
|
||||
delivery: { mode: "announce", channel: "telegram", to: "123" },
|
||||
},
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
"HEARTBEAT_OK",
|
||||
expect.objectContaining({ mediaUrl: "https://example.com/img.png" }),
|
||||
);
|
||||
expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,20 +165,20 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg,
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
}),
|
||||
job: {
|
||||
...makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
}),
|
||||
delivery: { mode: "announce", channel: "telegram", to: "123" },
|
||||
},
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalled();
|
||||
expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,9 +23,13 @@ vi.mock("../agents/pi-embedded.js", () => ({
|
||||
vi.mock("../agents/model-catalog.js", () => ({
|
||||
loadModelCatalog: vi.fn(),
|
||||
}));
|
||||
vi.mock("../agents/subagent-announce.js", () => ({
|
||||
runSubagentAnnounceFlow: vi.fn(),
|
||||
}));
|
||||
|
||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
|
||||
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
@@ -76,6 +80,7 @@ function makeJob(payload: CronJob["payload"]): CronJob {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: "job-1",
|
||||
name: "job-1",
|
||||
enabled: true,
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
@@ -84,7 +89,6 @@ function makeJob(payload: CronJob["payload"]): CronJob {
|
||||
wakeMode: "now",
|
||||
payload,
|
||||
state: {},
|
||||
isolation: { postToMainPrefix: "Cron" },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,6 +96,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
vi.mocked(loadModelCatalog).mockResolvedValue([]);
|
||||
vi.mocked(runSubagentAnnounceFlow).mockReset().mockResolvedValue(true);
|
||||
const runtime = createPluginRuntime();
|
||||
setDiscordRuntime(runtime);
|
||||
setTelegramRuntime(runtime);
|
||||
@@ -105,7 +110,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("skips delivery without a WhatsApp recipient when bestEffortDeliver=true", async () => {
|
||||
it("announces when delivery is requested", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
@@ -116,7 +121,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "hello" }],
|
||||
payloads: [{ text: "hello from cron" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
@@ -124,148 +129,32 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "whatsapp",
|
||||
bestEffortDeliver: true,
|
||||
cfg: makeCfg(home, storePath, {
|
||||
channels: { telegram: { botToken: "t-1" } },
|
||||
}),
|
||||
deps,
|
||||
job: {
|
||||
...makeJob({ kind: "agentTurn", message: "do it" }),
|
||||
delivery: { mode: "announce", channel: "telegram", to: "123" },
|
||||
},
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("skipped");
|
||||
expect(String(res.summary ?? "")).toMatch(/delivery skipped/i);
|
||||
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
||||
expect(res.status).toBe("ok");
|
||||
expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(runSubagentAnnounceFlow).mock.calls[0]?.[0];
|
||||
expect(call?.label).toBe("Cron: job-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers telegram via channel send", async () => {
|
||||
it("skips announce when messaging tool already sent to target", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "123",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "hello from cron" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
process.env.TELEGRAM_BOT_TOKEN = "";
|
||||
try {
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath, {
|
||||
channels: { telegram: { botToken: "t-1" } },
|
||||
}),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
"hello from cron",
|
||||
expect.objectContaining({ verbose: false }),
|
||||
);
|
||||
} finally {
|
||||
if (prevTelegramToken === undefined) {
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
} else {
|
||||
process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-delivers when explicit target is set without deliver flag", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "123",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "hello from cron" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
process.env.TELEGRAM_BOT_TOKEN = "";
|
||||
try {
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath, {
|
||||
channels: { telegram: { botToken: "t-1" } },
|
||||
}),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
"hello from cron",
|
||||
expect.objectContaining({ verbose: false }),
|
||||
);
|
||||
} finally {
|
||||
if (prevTelegramToken === undefined) {
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
} else {
|
||||
process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("skips auto-delivery when messaging tool already sent to the target", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "123",
|
||||
}),
|
||||
sendMessageTelegram: vi.fn(),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
@@ -280,181 +169,31 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
messagingToolSentTargets: [{ tool: "message", provider: "telegram", to: "123" }],
|
||||
});
|
||||
|
||||
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
process.env.TELEGRAM_BOT_TOKEN = "";
|
||||
try {
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath, {
|
||||
channels: { telegram: { botToken: "t-1" } },
|
||||
}),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (prevTelegramToken === undefined) {
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
} else {
|
||||
process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers telegram topic targets via channel send", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "-1001234567890",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "hello from cron" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "telegram:group:-1001234567890:topic:321",
|
||||
cfg: makeCfg(home, storePath, {
|
||||
channels: { telegram: { botToken: "t-1" } },
|
||||
}),
|
||||
deps,
|
||||
job: {
|
||||
...makeJob({ kind: "agentTurn", message: "do it" }),
|
||||
delivery: { mode: "announce", channel: "telegram", to: "123" },
|
||||
},
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"telegram:group:-1001234567890:topic:321",
|
||||
"hello from cron",
|
||||
expect.objectContaining({ verbose: false }),
|
||||
);
|
||||
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers telegram shorthand topic suffixes via channel send", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "-1001234567890",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "hello from cron" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "-1001234567890:321",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"-1001234567890:321",
|
||||
"hello from cron",
|
||||
expect.objectContaining({ verbose: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers via discord when configured", async () => {
|
||||
it("skips announce for heartbeat-only output", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn(),
|
||||
sendMessageDiscord: vi.fn().mockResolvedValue({
|
||||
messageId: "d1",
|
||||
channelId: "chan",
|
||||
}),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "hello from cron" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "discord",
|
||||
to: "channel:1122",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageDiscord).toHaveBeenCalledWith(
|
||||
"channel:1122",
|
||||
"hello from cron",
|
||||
expect.objectContaining({ verbose: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("skips delivery when response is exactly HEARTBEAT_OK", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "123",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
@@ -467,112 +206,22 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
// Job still succeeds, but no delivery happens.
|
||||
expect(res.status).toBe("ok");
|
||||
expect(res.summary).toBe("HEARTBEAT_OK");
|
||||
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("skips delivery when response has HEARTBEAT_OK with short padding", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn().mockResolvedValue({
|
||||
messageId: "w1",
|
||||
chatId: "+1234",
|
||||
}),
|
||||
sendMessageTelegram: vi.fn(),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
// Short junk around HEARTBEAT_OK (<=30 chars) should still skip delivery.
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "HEARTBEAT_OK 🦞" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath, {
|
||||
channels: { whatsapp: { allowFrom: ["+1234"] } },
|
||||
channels: { telegram: { botToken: "t-1" } },
|
||||
}),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "whatsapp",
|
||||
to: "+1234",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers when response has HEARTBEAT_OK but also substantial content", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "123",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
// Long content after HEARTBEAT_OK should still be delivered.
|
||||
const longContent = `Important alert: ${"a".repeat(500)}`;
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: `HEARTBEAT_OK ${longContent}` }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
job: {
|
||||
...makeJob({ kind: "agentTurn", message: "do it" }),
|
||||
delivery: { mode: "announce", channel: "telegram", to: "123" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalled();
|
||||
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,7 +81,6 @@ function makeJob(payload: CronJob["payload"]): CronJob {
|
||||
wakeMode: "now",
|
||||
payload,
|
||||
state: {},
|
||||
isolation: { postToMainPrefix: "Cron" },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -542,46 +541,6 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fails delivery without a WhatsApp recipient when bestEffortDeliver=false", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn(),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "hello" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "whatsapp",
|
||||
bestEffortDeliver: false,
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("error");
|
||||
expect(res.summary).toBe("hello");
|
||||
expect(String(res.error ?? "")).toMatch(/requires a recipient/i);
|
||||
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("starts a fresh session id for each cron run", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
|
||||
@@ -44,14 +44,13 @@ import {
|
||||
normalizeVerboseLevel,
|
||||
supportsXHighThinking,
|
||||
} from "../../auto-reply/thinking.js";
|
||||
import { createOutboundSendDeps, type CliDeps } from "../../cli/outbound-send-deps.js";
|
||||
import { type CliDeps } from "../../cli/outbound-send-deps.js";
|
||||
import {
|
||||
resolveAgentMainSessionKey,
|
||||
resolveSessionTranscriptPath,
|
||||
updateSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
|
||||
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
|
||||
import { logWarn } from "../../logger.js";
|
||||
import { buildAgentMainSessionKey, normalizeAgentId } from "../../routing/session-key.js";
|
||||
@@ -242,9 +241,6 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
const agentPayload = params.job.payload.kind === "agentTurn" ? params.job.payload : null;
|
||||
const deliveryPlan = resolveCronDeliveryPlan(params.job);
|
||||
const deliveryRequested = deliveryPlan.requested;
|
||||
const bestEffortDeliver = deliveryPlan.bestEffort;
|
||||
const legacyDeliveryMode =
|
||||
deliveryPlan.source === "payload" ? deliveryPlan.legacyMode : undefined;
|
||||
|
||||
const resolvedDelivery = await resolveDeliveryTarget(cfgWithAgentDefaults, agentId, {
|
||||
channel: deliveryPlan.channel ?? "last",
|
||||
@@ -294,6 +290,10 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
// Internal/trusted source - use original format
|
||||
commandBody = `${base}\n${timeLine}`.trim();
|
||||
}
|
||||
if (deliveryRequested) {
|
||||
commandBody =
|
||||
`${commandBody}\n\nDo not send messages via messaging tools. Return your summary as plain text; delivery is handled automatically. If the task explicitly calls for messaging a specific external recipient, note who/where it should go instead of sending it yourself.`.trim();
|
||||
}
|
||||
|
||||
const existingSnapshot = cronSession.sessionEntry.skillsSnapshot;
|
||||
const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir);
|
||||
@@ -380,6 +380,8 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
verboseLevel: resolvedVerboseLevel,
|
||||
timeoutMs,
|
||||
runId: cronSession.sessionEntry.sessionId,
|
||||
requireExplicitMessageTarget: true,
|
||||
disableMessageTool: deliveryRequested,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -432,7 +434,6 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
const skipHeartbeatDelivery = deliveryRequested && isHeartbeatOnlyResponse(payloads, ackMaxChars);
|
||||
const skipMessagingToolDelivery =
|
||||
deliveryRequested &&
|
||||
legacyDeliveryMode === "auto" &&
|
||||
runResult.didSendViaMessagingTool === true &&
|
||||
(runResult.messagingToolSentTargets ?? []).some((target) =>
|
||||
matchesMessagingToolDeliveryTarget(target, {
|
||||
@@ -443,71 +444,35 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
);
|
||||
|
||||
if (deliveryRequested && !skipHeartbeatDelivery && !skipMessagingToolDelivery) {
|
||||
if (deliveryPlan.mode === "announce") {
|
||||
const requesterSessionKey = resolveAgentMainSessionKey({
|
||||
cfg: cfgWithAgentDefaults,
|
||||
agentId,
|
||||
});
|
||||
const useExplicitOrigin = deliveryPlan.channel !== "last" || Boolean(deliveryPlan.to?.trim());
|
||||
const requesterOrigin = useExplicitOrigin
|
||||
? {
|
||||
channel: resolvedDelivery.channel,
|
||||
to: resolvedDelivery.to,
|
||||
accountId: resolvedDelivery.accountId,
|
||||
threadId: resolvedDelivery.threadId,
|
||||
}
|
||||
: undefined;
|
||||
const outcome: SubagentRunOutcome = { status: "ok" };
|
||||
const taskLabel = params.job.name?.trim() || "cron job";
|
||||
await runSubagentAnnounceFlow({
|
||||
childSessionKey: agentSessionKey,
|
||||
childRunId: cronSession.sessionEntry.sessionId,
|
||||
requesterSessionKey,
|
||||
requesterOrigin,
|
||||
requesterDisplayKey: requesterSessionKey,
|
||||
task: taskLabel,
|
||||
timeoutMs: 30_000,
|
||||
cleanup: "keep",
|
||||
roundOneReply: outputText ?? summary,
|
||||
waitForCompletion: false,
|
||||
label: `Cron: ${taskLabel}`,
|
||||
outcome,
|
||||
});
|
||||
} else {
|
||||
if (!resolvedDelivery.to) {
|
||||
const reason =
|
||||
resolvedDelivery.error?.message ?? "Cron delivery requires a recipient (--to).";
|
||||
if (!bestEffortDeliver) {
|
||||
return {
|
||||
status: "error",
|
||||
summary,
|
||||
outputText,
|
||||
error: reason,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "skipped",
|
||||
summary: `Delivery skipped (${reason}).`,
|
||||
outputText,
|
||||
};
|
||||
}
|
||||
try {
|
||||
await deliverOutboundPayloads({
|
||||
cfg: cfgWithAgentDefaults,
|
||||
const requesterSessionKey = resolveAgentMainSessionKey({
|
||||
cfg: cfgWithAgentDefaults,
|
||||
agentId,
|
||||
});
|
||||
const useExplicitOrigin = deliveryPlan.channel !== "last" || Boolean(deliveryPlan.to?.trim());
|
||||
const requesterOrigin = useExplicitOrigin
|
||||
? {
|
||||
channel: resolvedDelivery.channel,
|
||||
to: resolvedDelivery.to,
|
||||
accountId: resolvedDelivery.accountId,
|
||||
payloads,
|
||||
bestEffort: bestEffortDeliver,
|
||||
deps: createOutboundSendDeps(params.deps),
|
||||
});
|
||||
} catch (err) {
|
||||
if (!bestEffortDeliver) {
|
||||
return { status: "error", summary, outputText, error: String(err) };
|
||||
threadId: resolvedDelivery.threadId,
|
||||
}
|
||||
return { status: "ok", summary, outputText };
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
const outcome: SubagentRunOutcome = { status: "ok" };
|
||||
const taskLabel = params.job.name?.trim() || "cron job";
|
||||
await runSubagentAnnounceFlow({
|
||||
childSessionKey: agentSessionKey,
|
||||
childRunId: cronSession.sessionEntry.sessionId,
|
||||
requesterSessionKey,
|
||||
requesterOrigin,
|
||||
requesterDisplayKey: requesterSessionKey,
|
||||
task: taskLabel,
|
||||
timeoutMs: 30_000,
|
||||
cleanup: "keep",
|
||||
roundOneReply: outputText ?? summary,
|
||||
waitForCompletion: false,
|
||||
label: `Cron: ${taskLabel}`,
|
||||
outcome,
|
||||
});
|
||||
}
|
||||
|
||||
return { status: "ok", summary, outputText };
|
||||
|
||||
@@ -75,7 +75,7 @@ describe("normalizeCronJobCreate", () => {
|
||||
expect(payload.channel).toBe("telegram");
|
||||
});
|
||||
|
||||
it("coerces ISO schedule.at to atMs (UTC)", () => {
|
||||
it("coerces ISO schedule.at to normalized ISO (UTC)", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
name: "iso at",
|
||||
enabled: true,
|
||||
@@ -90,10 +90,10 @@ describe("normalizeCronJobCreate", () => {
|
||||
|
||||
const schedule = normalized.schedule as Record<string, unknown>;
|
||||
expect(schedule.kind).toBe("at");
|
||||
expect(schedule.atMs).toBe(Date.parse("2026-01-12T18:00:00Z"));
|
||||
expect(schedule.at).toBe(new Date(Date.parse("2026-01-12T18:00:00Z")).toISOString());
|
||||
});
|
||||
|
||||
it("coerces ISO schedule.atMs string to atMs (UTC)", () => {
|
||||
it("coerces schedule.atMs string to schedule.at (UTC)", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
name: "iso atMs",
|
||||
enabled: true,
|
||||
@@ -108,7 +108,7 @@ describe("normalizeCronJobCreate", () => {
|
||||
|
||||
const schedule = normalized.schedule as Record<string, unknown>;
|
||||
expect(schedule.kind).toBe("at");
|
||||
expect(schedule.atMs).toBe(Date.parse("2026-01-12T18:00:00Z"));
|
||||
expect(schedule.at).toBe(new Date(Date.parse("2026-01-12T18:00:00Z")).toISOString());
|
||||
});
|
||||
|
||||
it("defaults deleteAfterRun for one-shot schedules", () => {
|
||||
@@ -166,7 +166,7 @@ describe("normalizeCronJobCreate", () => {
|
||||
expect(delivery.mode).toBe("announce");
|
||||
});
|
||||
|
||||
it("does not override explicit legacy delivery fields", () => {
|
||||
it("migrates legacy delivery fields to delivery", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
name: "legacy deliver",
|
||||
enabled: true,
|
||||
@@ -175,14 +175,38 @@ describe("normalizeCronJobCreate", () => {
|
||||
kind: "agentTurn",
|
||||
message: "hi",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "7200373102",
|
||||
bestEffortDeliver: true,
|
||||
},
|
||||
}) 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");
|
||||
expect(delivery.bestEffort).toBe(true);
|
||||
});
|
||||
|
||||
it("maps legacy deliver=false to delivery none", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
name: "legacy off",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "* * * * *" },
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "hi",
|
||||
deliver: false,
|
||||
channel: "telegram",
|
||||
to: "7200373102",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
|
||||
expect(normalized.delivery).toBeUndefined();
|
||||
const delivery = normalized.delivery as Record<string, unknown>;
|
||||
expect(delivery.mode).toBe("none");
|
||||
});
|
||||
|
||||
it("does not override legacy isolation settings", () => {
|
||||
it("migrates legacy isolation settings to announce delivery", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
name: "legacy isolation",
|
||||
enabled: true,
|
||||
@@ -194,6 +218,8 @@ describe("normalizeCronJobCreate", () => {
|
||||
isolation: { postToMainPrefix: "Cron" },
|
||||
}) as unknown as Record<string, unknown>;
|
||||
|
||||
expect(normalized.delivery).toBeUndefined();
|
||||
const delivery = normalized.delivery as Record<string, unknown>;
|
||||
expect(delivery.mode).toBe("announce");
|
||||
expect((normalized as { isolation?: unknown }).isolation).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,12 +22,15 @@ function coerceSchedule(schedule: UnknownRecord) {
|
||||
const kind = typeof schedule.kind === "string" ? schedule.kind : undefined;
|
||||
const atMsRaw = schedule.atMs;
|
||||
const atRaw = schedule.at;
|
||||
const atString = typeof atRaw === "string" ? atRaw.trim() : "";
|
||||
const parsedAtMs =
|
||||
typeof atMsRaw === "string"
|
||||
? parseAbsoluteTimeMs(atMsRaw)
|
||||
: typeof atRaw === "string"
|
||||
? parseAbsoluteTimeMs(atRaw)
|
||||
: null;
|
||||
typeof atMsRaw === "number"
|
||||
? atMsRaw
|
||||
: typeof atMsRaw === "string"
|
||||
? parseAbsoluteTimeMs(atMsRaw)
|
||||
: atString
|
||||
? parseAbsoluteTimeMs(atString)
|
||||
: null;
|
||||
|
||||
if (!kind) {
|
||||
if (
|
||||
@@ -43,12 +46,13 @@ function coerceSchedule(schedule: UnknownRecord) {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof schedule.atMs !== "number" && parsedAtMs !== null) {
|
||||
next.atMs = parsedAtMs;
|
||||
if (atString) {
|
||||
next.at = parsedAtMs ? new Date(parsedAtMs).toISOString() : atString;
|
||||
} else if (parsedAtMs !== null) {
|
||||
next.at = new Date(parsedAtMs).toISOString();
|
||||
}
|
||||
|
||||
if ("at" in next) {
|
||||
delete next.at;
|
||||
if ("atMs" in next) {
|
||||
delete next.atMs;
|
||||
}
|
||||
|
||||
return next;
|
||||
@@ -64,7 +68,8 @@ function coercePayload(payload: UnknownRecord) {
|
||||
function coerceDelivery(delivery: UnknownRecord) {
|
||||
const next: UnknownRecord = { ...delivery };
|
||||
if (typeof delivery.mode === "string") {
|
||||
next.mode = delivery.mode.trim().toLowerCase();
|
||||
const mode = delivery.mode.trim().toLowerCase();
|
||||
next.mode = mode === "deliver" ? "announce" : mode;
|
||||
}
|
||||
if (typeof delivery.channel === "string") {
|
||||
const trimmed = delivery.channel.trim().toLowerCase();
|
||||
@@ -98,6 +103,40 @@ function hasLegacyDeliveryHints(payload: UnknownRecord) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildDeliveryFromLegacyPayload(payload: UnknownRecord): UnknownRecord {
|
||||
const deliver = payload.deliver;
|
||||
const mode = deliver === false ? "none" : "announce";
|
||||
const channelRaw =
|
||||
typeof payload.channel === "string" ? payload.channel.trim().toLowerCase() : "";
|
||||
const toRaw = typeof payload.to === "string" ? payload.to.trim() : "";
|
||||
const next: UnknownRecord = { mode };
|
||||
if (channelRaw) {
|
||||
next.channel = channelRaw;
|
||||
}
|
||||
if (toRaw) {
|
||||
next.to = toRaw;
|
||||
}
|
||||
if (typeof payload.bestEffortDeliver === "boolean") {
|
||||
next.bestEffort = payload.bestEffortDeliver;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function stripLegacyDeliveryFields(payload: UnknownRecord) {
|
||||
if ("deliver" in payload) {
|
||||
delete payload.deliver;
|
||||
}
|
||||
if ("channel" in payload) {
|
||||
delete payload.channel;
|
||||
}
|
||||
if ("to" in payload) {
|
||||
delete payload.to;
|
||||
}
|
||||
if ("bestEffortDeliver" in payload) {
|
||||
delete payload.bestEffortDeliver;
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapJob(raw: UnknownRecord) {
|
||||
if (isRecord(raw.data)) {
|
||||
return raw.data;
|
||||
@@ -159,6 +198,10 @@ export function normalizeCronJobInput(
|
||||
next.delivery = coerceDelivery(base.delivery);
|
||||
}
|
||||
|
||||
if (isRecord(base.isolation)) {
|
||||
delete next.isolation;
|
||||
}
|
||||
|
||||
if (options.applyDefaults) {
|
||||
if (!next.wakeMode) {
|
||||
next.wakeMode = "next-heartbeat";
|
||||
@@ -180,20 +223,20 @@ export function normalizeCronJobInput(
|
||||
) {
|
||||
next.deleteAfterRun = true;
|
||||
}
|
||||
const hasDelivery = "delivery" in next && next.delivery !== undefined;
|
||||
const payload = isRecord(next.payload) ? next.payload : null;
|
||||
const payloadKind = payload && typeof payload.kind === "string" ? payload.kind : "";
|
||||
const sessionTarget = typeof next.sessionTarget === "string" ? next.sessionTarget : "";
|
||||
const hasLegacyIsolation = isRecord(next.isolation);
|
||||
const isIsolatedAgentTurn =
|
||||
sessionTarget === "isolated" || (sessionTarget === "" && payloadKind === "agentTurn");
|
||||
const hasDelivery = "delivery" in next && next.delivery !== undefined;
|
||||
const hasLegacyDelivery = payload ? hasLegacyDeliveryHints(payload) : false;
|
||||
if (
|
||||
!hasDelivery &&
|
||||
!hasLegacyIsolation &&
|
||||
!hasLegacyDelivery &&
|
||||
sessionTarget === "isolated" &&
|
||||
payloadKind === "agentTurn"
|
||||
) {
|
||||
next.delivery = { mode: "announce" };
|
||||
if (!hasDelivery && isIsolatedAgentTurn && payloadKind === "agentTurn") {
|
||||
if (payload && hasLegacyDelivery) {
|
||||
next.delivery = buildDeliveryFromLegacyPayload(payload);
|
||||
stripLegacyDeliveryFields(payload);
|
||||
} else {
|
||||
next.delivery = { mode: "announce" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Cron } from "croner";
|
||||
import type { CronSchedule } from "./types.js";
|
||||
import { parseAbsoluteTimeMs } from "./parse.js";
|
||||
|
||||
export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): number | undefined {
|
||||
if (schedule.kind === "at") {
|
||||
return schedule.atMs > nowMs ? schedule.atMs : undefined;
|
||||
const atMs = parseAbsoluteTimeMs(schedule.at);
|
||||
if (atMs === null) {
|
||||
return undefined;
|
||||
}
|
||||
return atMs > nowMs ? atMs : undefined;
|
||||
}
|
||||
|
||||
if (schedule.kind === "every") {
|
||||
|
||||
@@ -55,7 +55,7 @@ describe("CronService", () => {
|
||||
await cronA.add({
|
||||
name: "shared store job",
|
||||
enabled: true,
|
||||
schedule: { kind: "at", atMs },
|
||||
schedule: { kind: "at", at: new Date(atMs).toISOString() },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("CronService", () => {
|
||||
name: "one-shot hello",
|
||||
enabled: true,
|
||||
deleteAfterRun: false,
|
||||
schedule: { kind: "at", atMs },
|
||||
schedule: { kind: "at", at: new Date(atMs).toISOString() },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
@@ -99,7 +99,7 @@ describe("CronService", () => {
|
||||
const job = await cron.add({
|
||||
name: "one-shot delete",
|
||||
enabled: true,
|
||||
schedule: { kind: "at", atMs },
|
||||
schedule: { kind: "at", at: new Date(atMs).toISOString() },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
@@ -153,7 +153,7 @@ describe("CronService", () => {
|
||||
const job = await cron.add({
|
||||
name: "wakeMode now waits",
|
||||
enabled: true,
|
||||
schedule: { kind: "at", atMs: 1 },
|
||||
schedule: { kind: "at", at: new Date(1).toISOString() },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
@@ -208,7 +208,7 @@ describe("CronService", () => {
|
||||
await cron.add({
|
||||
enabled: true,
|
||||
name: "weekly",
|
||||
schedule: { kind: "at", atMs },
|
||||
schedule: { kind: "at", at: new Date(atMs).toISOString() },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "do it", deliver: false },
|
||||
@@ -352,7 +352,7 @@ describe("CronService", () => {
|
||||
await cron.add({
|
||||
name: "isolated error test",
|
||||
enabled: true,
|
||||
schedule: { kind: "at", atMs },
|
||||
schedule: { kind: "at", at: new Date(atMs).toISOString() },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "do it", deliver: false },
|
||||
@@ -427,7 +427,7 @@ describe("CronService", () => {
|
||||
enabled: true,
|
||||
createdAtMs: Date.parse("2025-12-13T00:00:00.000Z"),
|
||||
updatedAtMs: Date.parse("2025-12-13T00:00:00.000Z"),
|
||||
schedule: { kind: "at", atMs },
|
||||
schedule: { kind: "at", at: new Date(atMs).toISOString() },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "bad" },
|
||||
|
||||
@@ -54,7 +54,7 @@ describe("CronService", () => {
|
||||
await cron.add({
|
||||
name: "empty systemEvent test",
|
||||
enabled: true,
|
||||
schedule: { kind: "at", atMs },
|
||||
schedule: { kind: "at", at: new Date(atMs).toISOString() },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: " " },
|
||||
@@ -93,7 +93,7 @@ describe("CronService", () => {
|
||||
await cron.add({
|
||||
name: "disabled cron job",
|
||||
enabled: true,
|
||||
schedule: { kind: "at", atMs },
|
||||
schedule: { kind: "at", at: new Date(atMs).toISOString() },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
@@ -133,7 +133,7 @@ describe("CronService", () => {
|
||||
await cron.add({
|
||||
name: "status next wake",
|
||||
enabled: true,
|
||||
schedule: { kind: "at", atMs },
|
||||
schedule: { kind: "at", at: new Date(atMs).toISOString() },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
|
||||
101
src/cron/service.store.migration.test.ts
Normal file
101
src/cron/service.store.migration.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadCronStore } from "../store.js";
|
||||
import { CronService } from "./service.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
async function makeStorePath() {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-migrate-"));
|
||||
return {
|
||||
dir,
|
||||
storePath: path.join(dir, "cron", "jobs.json"),
|
||||
cleanup: async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("cron store migration", () => {
|
||||
beforeEach(() => {
|
||||
noopLogger.debug.mockClear();
|
||||
noopLogger.info.mockClear();
|
||||
noopLogger.warn.mockClear();
|
||||
noopLogger.error.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("migrates isolated jobs to announce delivery and drops isolation", async () => {
|
||||
const store = await makeStorePath();
|
||||
const atMs = 1_700_000_000_000;
|
||||
const legacyJob = {
|
||||
id: "job-1",
|
||||
agentId: undefined,
|
||||
name: "Legacy job",
|
||||
description: null,
|
||||
enabled: true,
|
||||
deleteAfterRun: false,
|
||||
createdAtMs: 1_700_000_000_000,
|
||||
updatedAtMs: 1_700_000_000_000,
|
||||
schedule: { kind: "at", atMs },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "hi",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "7200373102",
|
||||
bestEffortDeliver: true,
|
||||
},
|
||||
isolation: { postToMainPrefix: "Cron" },
|
||||
state: {},
|
||||
};
|
||||
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
|
||||
await fs.writeFile(store.storePath, JSON.stringify({ version: 1, jobs: [legacyJob] }, null, 2));
|
||||
|
||||
const cron = new CronService({
|
||||
storePath: store.storePath,
|
||||
cronEnabled: true,
|
||||
log: noopLogger,
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
requestHeartbeatNow: vi.fn(),
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
|
||||
});
|
||||
|
||||
await cron.start();
|
||||
cron.stop();
|
||||
|
||||
const loaded = await loadCronStore(store.storePath);
|
||||
const migrated = loaded.jobs[0] as Record<string, unknown>;
|
||||
expect(migrated.delivery).toEqual({
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
to: "7200373102",
|
||||
bestEffort: true,
|
||||
});
|
||||
expect("isolation" in migrated).toBe(false);
|
||||
|
||||
const payload = migrated.payload as Record<string, unknown>;
|
||||
expect(payload.deliver).toBeUndefined();
|
||||
expect(payload.channel).toBeUndefined();
|
||||
expect(payload.to).toBeUndefined();
|
||||
expect(payload.bestEffortDeliver).toBeUndefined();
|
||||
|
||||
const schedule = migrated.schedule as Record<string, unknown>;
|
||||
expect(schedule.kind).toBe("at");
|
||||
expect(schedule.at).toBe(new Date(atMs).toISOString());
|
||||
|
||||
await store.cleanup();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
CronPayloadPatch,
|
||||
} from "../types.js";
|
||||
import type { CronServiceState } from "./state.js";
|
||||
import { parseAbsoluteTimeMs } from "../parse.js";
|
||||
import { computeNextRunAtMs } from "../schedule.js";
|
||||
import {
|
||||
normalizeOptionalAgentId,
|
||||
@@ -51,7 +52,8 @@ export function computeJobNextRunAtMs(job: CronJob, nowMs: number): number | und
|
||||
if (job.state.lastStatus === "ok" && job.state.lastRunAtMs) {
|
||||
return undefined;
|
||||
}
|
||||
return job.schedule.atMs;
|
||||
const atMs = parseAbsoluteTimeMs(job.schedule.at);
|
||||
return atMs !== null ? atMs : undefined;
|
||||
}
|
||||
return computeNextRunAtMs(job.schedule, nowMs);
|
||||
}
|
||||
@@ -117,7 +119,6 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
|
||||
wakeMode: input.wakeMode,
|
||||
payload: input.payload,
|
||||
delivery: input.delivery,
|
||||
isolation: input.isolation,
|
||||
state: {
|
||||
...input.state,
|
||||
},
|
||||
@@ -156,9 +157,6 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
|
||||
if (patch.delivery) {
|
||||
job.delivery = mergeCronDelivery(job.delivery, patch.delivery);
|
||||
}
|
||||
if (patch.isolation) {
|
||||
job.isolation = patch.isolation;
|
||||
}
|
||||
if (patch.state) {
|
||||
job.state = { ...job.state, ...patch.state };
|
||||
}
|
||||
@@ -251,7 +249,7 @@ function mergeCronDelivery(
|
||||
};
|
||||
|
||||
if (typeof patch.mode === "string") {
|
||||
next.mode = patch.mode;
|
||||
next.mode = patch.mode === "deliver" ? "announce" : patch.mode;
|
||||
}
|
||||
if ("channel" in patch) {
|
||||
const channel = typeof patch.channel === "string" ? patch.channel.trim() : "";
|
||||
|
||||
@@ -1,11 +1,59 @@
|
||||
import fs from "node:fs";
|
||||
import type { CronJob } from "../types.js";
|
||||
import type { CronServiceState } from "./state.js";
|
||||
import { parseAbsoluteTimeMs } from "../parse.js";
|
||||
import { migrateLegacyCronPayload } from "../payload-migration.js";
|
||||
import { loadCronStore, saveCronStore } from "../store.js";
|
||||
import { recomputeNextRuns } from "./jobs.js";
|
||||
import { inferLegacyName, normalizeOptionalText } from "./normalize.js";
|
||||
|
||||
function hasLegacyDeliveryHints(payload: Record<string, unknown>) {
|
||||
if (typeof payload.deliver === "boolean") {
|
||||
return true;
|
||||
}
|
||||
if (typeof payload.bestEffortDeliver === "boolean") {
|
||||
return true;
|
||||
}
|
||||
if (typeof payload.to === "string" && payload.to.trim()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildDeliveryFromLegacyPayload(payload: Record<string, unknown>) {
|
||||
const deliver = payload.deliver;
|
||||
const mode = deliver === false ? "none" : "announce";
|
||||
const channelRaw =
|
||||
typeof payload.channel === "string" ? payload.channel.trim().toLowerCase() : "";
|
||||
const toRaw = typeof payload.to === "string" ? payload.to.trim() : "";
|
||||
const next: Record<string, unknown> = { mode };
|
||||
if (channelRaw) {
|
||||
next.channel = channelRaw;
|
||||
}
|
||||
if (toRaw) {
|
||||
next.to = toRaw;
|
||||
}
|
||||
if (typeof payload.bestEffortDeliver === "boolean") {
|
||||
next.bestEffort = payload.bestEffortDeliver;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function stripLegacyDeliveryFields(payload: Record<string, unknown>) {
|
||||
if ("deliver" in payload) {
|
||||
delete payload.deliver;
|
||||
}
|
||||
if ("channel" in payload) {
|
||||
delete payload.channel;
|
||||
}
|
||||
if ("to" in payload) {
|
||||
delete payload.to;
|
||||
}
|
||||
if ("bestEffortDeliver" in payload) {
|
||||
delete payload.bestEffortDeliver;
|
||||
}
|
||||
}
|
||||
|
||||
async function getFileMtimeMs(path: string): Promise<number | null> {
|
||||
try {
|
||||
const stats = await fs.promises.stat(path);
|
||||
@@ -59,6 +107,78 @@ export async function ensureLoaded(state: CronServiceState) {
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
|
||||
const schedule = raw.schedule;
|
||||
if (schedule && typeof schedule === "object" && !Array.isArray(schedule)) {
|
||||
const sched = schedule as Record<string, unknown>;
|
||||
const kind = typeof sched.kind === "string" ? sched.kind.trim().toLowerCase() : "";
|
||||
if (!kind && ("at" in sched || "atMs" in sched)) {
|
||||
sched.kind = "at";
|
||||
mutated = true;
|
||||
}
|
||||
const atRaw = typeof sched.at === "string" ? sched.at.trim() : "";
|
||||
const atMsRaw = sched.atMs;
|
||||
const parsedAtMs =
|
||||
typeof atMsRaw === "number"
|
||||
? atMsRaw
|
||||
: typeof atMsRaw === "string"
|
||||
? parseAbsoluteTimeMs(atMsRaw)
|
||||
: atRaw
|
||||
? parseAbsoluteTimeMs(atRaw)
|
||||
: null;
|
||||
if (parsedAtMs !== null) {
|
||||
sched.at = new Date(parsedAtMs).toISOString();
|
||||
if ("atMs" in sched) {
|
||||
delete sched.atMs;
|
||||
}
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
|
||||
const delivery = raw.delivery;
|
||||
if (delivery && typeof delivery === "object" && !Array.isArray(delivery)) {
|
||||
const modeRaw = (delivery as { mode?: unknown }).mode;
|
||||
if (typeof modeRaw === "string") {
|
||||
const lowered = modeRaw.trim().toLowerCase();
|
||||
if (lowered === "deliver") {
|
||||
(delivery as { mode?: unknown }).mode = "announce";
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isolation = raw.isolation;
|
||||
if (isolation && typeof isolation === "object" && !Array.isArray(isolation)) {
|
||||
delete raw.isolation;
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
const payloadRecord =
|
||||
payload && typeof payload === "object" && !Array.isArray(payload)
|
||||
? (payload as Record<string, unknown>)
|
||||
: null;
|
||||
const payloadKind =
|
||||
payloadRecord && typeof payloadRecord.kind === "string" ? payloadRecord.kind : "";
|
||||
const sessionTarget =
|
||||
typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim().toLowerCase() : "";
|
||||
const isIsolatedAgentTurn =
|
||||
sessionTarget === "isolated" || (sessionTarget === "" && payloadKind === "agentTurn");
|
||||
const hasDelivery = delivery && typeof delivery === "object" && !Array.isArray(delivery);
|
||||
const hasLegacyDelivery = payloadRecord ? hasLegacyDeliveryHints(payloadRecord) : false;
|
||||
|
||||
if (isIsolatedAgentTurn && payloadKind === "agentTurn") {
|
||||
if (!hasDelivery) {
|
||||
raw.delivery =
|
||||
payloadRecord && hasLegacyDelivery
|
||||
? buildDeliveryFromLegacyPayload(payloadRecord)
|
||||
: { mode: "announce" };
|
||||
mutated = true;
|
||||
}
|
||||
if (payloadRecord && hasLegacyDelivery) {
|
||||
stripLegacyDeliveryFields(payloadRecord);
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
state.store = { version: 1, jobs: jobs as unknown as CronJob[] };
|
||||
state.storeLoadedAtMs = state.deps.nowMs();
|
||||
|
||||
@@ -80,12 +80,7 @@ export async function executeJob(
|
||||
|
||||
let deleted = false;
|
||||
|
||||
const finish = async (
|
||||
status: "ok" | "error" | "skipped",
|
||||
err?: string,
|
||||
summary?: string,
|
||||
outputText?: 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;
|
||||
@@ -124,30 +119,6 @@ export async function executeJob(
|
||||
deleted = true;
|
||||
emit(state, { jobId: job.id, action: "removed" });
|
||||
}
|
||||
|
||||
if (job.sessionTarget === "isolated" && !job.delivery) {
|
||||
const prefix = job.isolation?.postToMainPrefix?.trim() || "Cron";
|
||||
const mode = job.isolation?.postToMainMode ?? "summary";
|
||||
|
||||
let body = (summary ?? err ?? status).trim();
|
||||
if (mode === "full") {
|
||||
// Prefer full agent output if available; fall back to summary.
|
||||
const maxCharsRaw = job.isolation?.postToMainMaxChars;
|
||||
const maxChars = Number.isFinite(maxCharsRaw) ? Math.max(0, maxCharsRaw as number) : 8000;
|
||||
const fullText = (outputText ?? "").trim();
|
||||
if (fullText) {
|
||||
body = fullText.length > maxChars ? `${fullText.slice(0, maxChars)}…` : fullText;
|
||||
}
|
||||
}
|
||||
|
||||
const statusPrefix = status === "ok" ? prefix : `${prefix} (${status})`;
|
||||
state.deps.enqueueSystemEvent(`${statusPrefix}: ${body}`, {
|
||||
agentId: job.agentId,
|
||||
});
|
||||
if (job.wakeMode === "now") {
|
||||
state.deps.requestHeartbeatNow({ reason: `cron:${job.id}:post` });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -214,11 +185,11 @@ export async function executeJob(
|
||||
message: job.payload.message,
|
||||
});
|
||||
if (res.status === "ok") {
|
||||
await finish("ok", undefined, res.summary, res.outputText);
|
||||
await finish("ok", undefined, res.summary);
|
||||
} else if (res.status === "skipped") {
|
||||
await finish("skipped", undefined, res.summary, res.outputText);
|
||||
await finish("skipped", undefined, res.summary);
|
||||
} else {
|
||||
await finish("error", res.error ?? "cron job failed", res.summary, res.outputText);
|
||||
await finish("error", res.error ?? "cron job failed", res.summary);
|
||||
}
|
||||
} catch (err) {
|
||||
await finish("error", String(err));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
|
||||
export type CronSchedule =
|
||||
| { kind: "at"; atMs: number }
|
||||
| { kind: "at"; at: string }
|
||||
| { kind: "every"; everyMs: number; anchorMs?: number }
|
||||
| { kind: "cron"; expr: string; tz?: string };
|
||||
|
||||
@@ -10,7 +10,7 @@ export type CronWakeMode = "next-heartbeat" | "now";
|
||||
|
||||
export type CronMessageChannel = ChannelId | "last";
|
||||
|
||||
export type CronDeliveryMode = "none" | "announce" | "deliver";
|
||||
export type CronDeliveryMode = "none" | "announce";
|
||||
|
||||
export type CronDelivery = {
|
||||
mode: CronDeliveryMode;
|
||||
@@ -52,18 +52,6 @@ export type CronPayloadPatch =
|
||||
bestEffortDeliver?: boolean;
|
||||
};
|
||||
|
||||
export type CronIsolation = {
|
||||
postToMainPrefix?: string;
|
||||
/**
|
||||
* What to post back into the main session after an isolated run.
|
||||
* - summary: small status/summary line (default)
|
||||
* - full: the agent's final text output (optionally truncated)
|
||||
*/
|
||||
postToMainMode?: "summary" | "full";
|
||||
/** Max chars when postToMainMode="full". Default: 8000. */
|
||||
postToMainMaxChars?: number;
|
||||
};
|
||||
|
||||
export type CronJobState = {
|
||||
nextRunAtMs?: number;
|
||||
runningAtMs?: number;
|
||||
@@ -87,7 +75,6 @@ export type CronJob = {
|
||||
wakeMode: CronWakeMode;
|
||||
payload: CronPayload;
|
||||
delivery?: CronDelivery;
|
||||
isolation?: CronIsolation;
|
||||
state: CronJobState;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CronSchedule } from "./types.js";
|
||||
import { parseAbsoluteTimeMs } from "./parse.js";
|
||||
|
||||
const ONE_MINUTE_MS = 60 * 1000;
|
||||
const TEN_YEARS_MS = 10 * 365.25 * 24 * 60 * 60 * 1000;
|
||||
@@ -15,7 +16,7 @@ export type TimestampValidationSuccess = {
|
||||
export type TimestampValidationResult = TimestampValidationSuccess | TimestampValidationError;
|
||||
|
||||
/**
|
||||
* Validates atMs timestamps in cron schedules.
|
||||
* Validates at timestamps in cron schedules.
|
||||
* Rejects timestamps that are:
|
||||
* - More than 1 minute in the past
|
||||
* - More than 10 years in the future
|
||||
@@ -28,12 +29,13 @@ export function validateScheduleTimestamp(
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const atMs = schedule.atMs;
|
||||
const atRaw = typeof schedule.at === "string" ? schedule.at.trim() : "";
|
||||
const atMs = atRaw ? parseAbsoluteTimeMs(atRaw) : null;
|
||||
|
||||
if (typeof atMs !== "number" || !Number.isFinite(atMs)) {
|
||||
if (atMs === null || !Number.isFinite(atMs)) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Invalid atMs: must be a finite number (got ${String(atMs)})`,
|
||||
message: `Invalid schedule.at: expected ISO-8601 timestamp (got ${String(schedule.at)})`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,7 +48,7 @@ export function validateScheduleTimestamp(
|
||||
const minutesAgo = Math.floor(-diffMs / ONE_MINUTE_MS);
|
||||
return {
|
||||
ok: false,
|
||||
message: `atMs is in the past: ${atDate} (${minutesAgo} minutes ago). Current time: ${nowDate}`,
|
||||
message: `schedule.at is in the past: ${atDate} (${minutesAgo} minutes ago). Current time: ${nowDate}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,7 +58,7 @@ export function validateScheduleTimestamp(
|
||||
const yearsAhead = Math.floor(diffMs / (365.25 * 24 * 60 * 60 * 1000));
|
||||
return {
|
||||
ok: false,
|
||||
message: `atMs is too far in the future: ${atDate} (${yearsAhead} years ahead). Maximum allowed: 10 years`,
|
||||
message: `schedule.at is too far in the future: ${atDate} (${yearsAhead} years ahead). Maximum allowed: 10 years`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user