mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 02:11:23 +00:00
Cron: route reminders by session namespace
This commit is contained in:
committed by
Peter Steinberger
parent
f452a7a60b
commit
f988abf202
@@ -79,6 +79,30 @@ describe("normalizeCronJobCreate", () => {
|
||||
expect(cleared.agentId).toBeNull();
|
||||
});
|
||||
|
||||
it("trims sessionKey and drops blanks", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
name: "session-key",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "* * * * *" },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
sessionKey: " agent:main:discord:channel:ops ",
|
||||
payload: { kind: "systemEvent", text: "hi" },
|
||||
}) as unknown as Record<string, unknown>;
|
||||
expect(normalized.sessionKey).toBe("agent:main:discord:channel:ops");
|
||||
|
||||
const cleared = normalizeCronJobCreate({
|
||||
name: "session-key-clear",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "* * * * *" },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
sessionKey: " ",
|
||||
payload: { kind: "systemEvent", text: "hi" },
|
||||
}) as unknown as Record<string, unknown>;
|
||||
expect("sessionKey" in cleared).toBe(false);
|
||||
});
|
||||
|
||||
it("canonicalizes payload.channel casing", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
name: "legacy provider",
|
||||
@@ -329,4 +353,16 @@ describe("normalizeCronJobPatch", () => {
|
||||
expect(payload.channel).toBe("telegram");
|
||||
expect(payload.to).toBe("+15550001111");
|
||||
});
|
||||
|
||||
it("preserves null sessionKey patches and trims string values", () => {
|
||||
const trimmed = normalizeCronJobPatch({
|
||||
sessionKey: " agent:main:telegram:group:-100123 ",
|
||||
}) as unknown as Record<string, unknown>;
|
||||
expect(trimmed.sessionKey).toBe("agent:main:telegram:group:-100123");
|
||||
|
||||
const cleared = normalizeCronJobPatch({
|
||||
sessionKey: null,
|
||||
}) as unknown as Record<string, unknown>;
|
||||
expect(cleared.sessionKey).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -301,6 +301,20 @@ export function normalizeCronJobInput(
|
||||
}
|
||||
}
|
||||
|
||||
if ("sessionKey" in base) {
|
||||
const sessionKey = base.sessionKey;
|
||||
if (sessionKey === null) {
|
||||
next.sessionKey = null;
|
||||
} else if (typeof sessionKey === "string") {
|
||||
const trimmed = sessionKey.trim();
|
||||
if (trimmed) {
|
||||
next.sessionKey = trimmed;
|
||||
} else {
|
||||
delete next.sessionKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ("enabled" in base) {
|
||||
const enabled = base.enabled;
|
||||
if (typeof enabled === "boolean") {
|
||||
|
||||
@@ -339,11 +339,12 @@ async function runIsolatedAnnounceJobAndWait(params: {
|
||||
|
||||
async function addWakeModeNowMainSystemEventJob(
|
||||
cron: CronService,
|
||||
options?: { name?: string; agentId?: string },
|
||||
options?: { name?: string; agentId?: string; sessionKey?: string },
|
||||
) {
|
||||
return cron.add({
|
||||
name: options?.name ?? "wakeMode now",
|
||||
...(options?.agentId ? { agentId: options.agentId } : {}),
|
||||
...(options?.sessionKey ? { sessionKey: options.sessionKey } : {}),
|
||||
enabled: true,
|
||||
schedule: { kind: "at", at: new Date(1).toISOString() },
|
||||
sessionTarget: "main",
|
||||
@@ -508,7 +509,7 @@ describe("CronService", () => {
|
||||
await store.cleanup();
|
||||
});
|
||||
|
||||
it("passes agentId to runHeartbeatOnce for main-session wakeMode now jobs", async () => {
|
||||
it("passes agentId + sessionKey to runHeartbeatOnce for main-session wakeMode now jobs", async () => {
|
||||
const runHeartbeatOnce = vi.fn(async () => ({ status: "ran" as const, durationMs: 1 }));
|
||||
|
||||
const { store, cron, enqueueSystemEvent, requestHeartbeatNow } =
|
||||
@@ -519,9 +520,11 @@ describe("CronService", () => {
|
||||
wakeNowHeartbeatBusyRetryDelayMs: 2,
|
||||
});
|
||||
|
||||
const sessionKey = "agent:ops:discord:channel:alerts";
|
||||
const job = await addWakeModeNowMainSystemEventJob(cron, {
|
||||
name: "wakeMode now with agent",
|
||||
agentId: "ops",
|
||||
sessionKey,
|
||||
});
|
||||
|
||||
await cron.run(job.id, "force");
|
||||
@@ -531,12 +534,13 @@ describe("CronService", () => {
|
||||
expect.objectContaining({
|
||||
reason: `cron:${job.id}`,
|
||||
agentId: "ops",
|
||||
sessionKey,
|
||||
}),
|
||||
);
|
||||
expect(requestHeartbeatNow).not.toHaveBeenCalled();
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
expect.objectContaining({ agentId: "ops" }),
|
||||
expect.objectContaining({ agentId: "ops", sessionKey }),
|
||||
);
|
||||
|
||||
cron.stop();
|
||||
@@ -562,12 +566,21 @@ describe("CronService", () => {
|
||||
wakeNowHeartbeatBusyRetryDelayMs: 2,
|
||||
});
|
||||
|
||||
const job = await addWakeModeNowMainSystemEventJob(cron, { name: "wakeMode now fallback" });
|
||||
const sessionKey = "agent:main:discord:channel:ops";
|
||||
const job = await addWakeModeNowMainSystemEventJob(cron, {
|
||||
name: "wakeMode now fallback",
|
||||
sessionKey,
|
||||
});
|
||||
|
||||
await cron.run(job.id, "force");
|
||||
|
||||
expect(runHeartbeatOnce).toHaveBeenCalled();
|
||||
expect(requestHeartbeatNow).toHaveBeenCalled();
|
||||
expect(requestHeartbeatNow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
reason: `cron:${job.id}`,
|
||||
sessionKey,
|
||||
}),
|
||||
);
|
||||
expect(job.state.lastStatus).toBe("ok");
|
||||
expect(job.state.lastError).toBeUndefined();
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ describe("cron store migration", () => {
|
||||
const legacyJob = {
|
||||
id: "job-1",
|
||||
agentId: undefined,
|
||||
sessionKey: " agent:main:discord:channel:ops ",
|
||||
name: "Legacy job",
|
||||
description: null,
|
||||
enabled: true,
|
||||
@@ -82,6 +83,7 @@ describe("cron store migration", () => {
|
||||
await fs.writeFile(store.storePath, JSON.stringify({ version: 1, jobs: [legacyJob] }, null, 2));
|
||||
|
||||
const migrated = await migrateAndLoadFirstJob(store.storePath);
|
||||
expect(migrated.sessionKey).toBe("agent:main:discord:channel:ops");
|
||||
expect(migrated.delivery).toEqual({
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import { normalizeHttpWebhookUrl } from "../webhook-url.js";
|
||||
import {
|
||||
normalizeOptionalAgentId,
|
||||
normalizeOptionalSessionKey,
|
||||
normalizeOptionalText,
|
||||
normalizePayloadToSystemText,
|
||||
normalizeRequiredName,
|
||||
@@ -298,6 +299,7 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
|
||||
const job: CronJob = {
|
||||
id,
|
||||
agentId: normalizeOptionalAgentId(input.agentId),
|
||||
sessionKey: normalizeOptionalSessionKey((input as { sessionKey?: unknown }).sessionKey),
|
||||
name: normalizeRequiredName(input.name),
|
||||
description: normalizeOptionalText(input.description),
|
||||
enabled,
|
||||
@@ -367,6 +369,9 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
|
||||
if ("agentId" in patch) {
|
||||
job.agentId = normalizeOptionalAgentId((patch as { agentId?: unknown }).agentId);
|
||||
}
|
||||
if ("sessionKey" in patch) {
|
||||
job.sessionKey = normalizeOptionalSessionKey((patch as { sessionKey?: unknown }).sessionKey);
|
||||
}
|
||||
assertSupportedJobSpec(job);
|
||||
assertDeliverySupport(job);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,14 @@ export function normalizeOptionalAgentId(raw: unknown) {
|
||||
return normalizeAgentId(trimmed);
|
||||
}
|
||||
|
||||
export function normalizeOptionalSessionKey(raw: unknown) {
|
||||
if (typeof raw !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
export function inferLegacyName(job: {
|
||||
schedule?: { kind?: unknown; everyMs?: unknown; expr?: unknown };
|
||||
payload?: { kind?: unknown; text?: unknown; message?: unknown };
|
||||
|
||||
@@ -43,9 +43,16 @@ export type CronServiceDeps = {
|
||||
resolveSessionStorePath?: (agentId?: string) => string;
|
||||
/** Path to the session store (sessions.json) for reaper use. */
|
||||
sessionStorePath?: string;
|
||||
enqueueSystemEvent: (text: string, opts?: { agentId?: string; contextKey?: string }) => void;
|
||||
requestHeartbeatNow: (opts?: { reason?: string }) => void;
|
||||
runHeartbeatOnce?: (opts?: { reason?: string; agentId?: string }) => Promise<HeartbeatRunResult>;
|
||||
enqueueSystemEvent: (
|
||||
text: string,
|
||||
opts?: { agentId?: string; sessionKey?: string; contextKey?: string },
|
||||
) => void;
|
||||
requestHeartbeatNow: (opts?: { reason?: string; agentId?: string; sessionKey?: string }) => void;
|
||||
runHeartbeatOnce?: (opts?: {
|
||||
reason?: string;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
}) => Promise<HeartbeatRunResult>;
|
||||
/**
|
||||
* WakeMode=now: max time to wait for runHeartbeatOnce to stop returning
|
||||
* { status:"skipped", reason:"requests-in-flight" } before falling back to
|
||||
|
||||
@@ -264,6 +264,15 @@ export async function ensureLoaded(
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
if ("sessionKey" in raw) {
|
||||
const sessionKey =
|
||||
typeof raw.sessionKey === "string" ? normalizeOptionalText(raw.sessionKey) : undefined;
|
||||
if (raw.sessionKey !== sessionKey) {
|
||||
raw.sessionKey = sessionKey;
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof raw.enabled !== "boolean") {
|
||||
raw.enabled = true;
|
||||
mutated = true;
|
||||
|
||||
@@ -453,6 +453,7 @@ async function executeJobCore(
|
||||
}
|
||||
state.deps.enqueueSystemEvent(text, {
|
||||
agentId: job.agentId,
|
||||
sessionKey: job.sessionKey,
|
||||
contextKey: `cron:${job.id}`,
|
||||
});
|
||||
if (job.wakeMode === "now" && state.deps.runHeartbeatOnce) {
|
||||
@@ -464,7 +465,11 @@ async function executeJobCore(
|
||||
|
||||
let heartbeatResult: HeartbeatRunResult;
|
||||
for (;;) {
|
||||
heartbeatResult = await state.deps.runHeartbeatOnce({ reason, agentId: job.agentId });
|
||||
heartbeatResult = await state.deps.runHeartbeatOnce({
|
||||
reason,
|
||||
agentId: job.agentId,
|
||||
sessionKey: job.sessionKey,
|
||||
});
|
||||
if (
|
||||
heartbeatResult.status !== "skipped" ||
|
||||
heartbeatResult.reason !== "requests-in-flight"
|
||||
@@ -472,7 +477,11 @@ async function executeJobCore(
|
||||
break;
|
||||
}
|
||||
if (state.deps.nowMs() - waitStartedAt > maxWaitMs) {
|
||||
state.deps.requestHeartbeatNow({ reason });
|
||||
state.deps.requestHeartbeatNow({
|
||||
reason,
|
||||
agentId: job.agentId,
|
||||
sessionKey: job.sessionKey,
|
||||
});
|
||||
return { status: "ok", summary: text };
|
||||
}
|
||||
await delay(retryDelayMs);
|
||||
@@ -486,7 +495,11 @@ async function executeJobCore(
|
||||
return { status: "error", error: heartbeatResult.reason, summary: text };
|
||||
}
|
||||
} else {
|
||||
state.deps.requestHeartbeatNow({ reason: `cron:${job.id}` });
|
||||
state.deps.requestHeartbeatNow({
|
||||
reason: `cron:${job.id}`,
|
||||
agentId: job.agentId,
|
||||
sessionKey: job.sessionKey,
|
||||
});
|
||||
return { status: "ok", summary: text };
|
||||
}
|
||||
}
|
||||
@@ -514,10 +527,15 @@ async function executeJobCore(
|
||||
res.status === "error" ? `${prefix} (error): ${summaryText}` : `${prefix}: ${summaryText}`;
|
||||
state.deps.enqueueSystemEvent(label, {
|
||||
agentId: job.agentId,
|
||||
sessionKey: job.sessionKey,
|
||||
contextKey: `cron:${job.id}`,
|
||||
});
|
||||
if (job.wakeMode === "now") {
|
||||
state.deps.requestHeartbeatNow({ reason: `cron:${job.id}` });
|
||||
state.deps.requestHeartbeatNow({
|
||||
reason: `cron:${job.id}`,
|
||||
agentId: job.agentId,
|
||||
sessionKey: job.sessionKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,8 @@ export type CronJobState = {
|
||||
export type CronJob = {
|
||||
id: string;
|
||||
agentId?: string;
|
||||
/** Origin session namespace for reminder delivery and wake routing. */
|
||||
sessionKey?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
|
||||
Reference in New Issue
Block a user