import fs from
"node:fs/promises" ;
import os from
"node:os" ;
import path from
"node:path" ;
import { afterEach, describe, expect, it, vi } from
"vitest" ;
import type { OpenClawConfig } from
"../config/config.js" ;
import { maybeRepairLegacyCronStore } from
"./doctor-cron.js" ;
type TerminalNote = (message: string, title?: string) =>
void ;
const noteMock = vi.hoisted(() => vi.fn<TerminalNote>());
vi.mock(
"../terminal/note.js" , () => ({
note: noteMock,
}));
let tempRoot: string |
null =
null ;
async
function makeTempStorePath() {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(),
"openclaw-doctor-cron-" ));
return path.join(tempRoot,
"cron" ,
"jobs.json" );
}
afterEach(async () => {
noteMock.mockClear();
if (tempRoot) {
await fs.rm(tempRoot, { recursive:
true , force:
true });
tempRoot =
null ;
}
});
function makePrompter(confirmResult =
true ) {
return {
confirm: vi.fn().mockResolvedValue(confirmResult),
};
}
function createCronConfig(storePath: string): OpenClawConfig {
return {
cron: {
store: storePath,
webhook:
"https://example.invalid/cron-finished ",
},
};
}
function createLegacyCronJob(overrides: Record<string, unknown> = {}) {
return {
jobId:
"legacy-job" ,
name:
"Legacy job" ,
notify:
true ,
createdAtMs: Date.parse(
"2026-02-01T00:00:00.000Z" ),
updatedAtMs: Date.parse(
"2026-02-02T00:00:00.000Z" ),
schedule: { kind:
"cron" , cron:
"0 7 * * *" , tz:
"UTC" },
payload: {
kind:
"systemEvent" ,
text:
"Morning brief" ,
},
state: {},
...overrides,
};
}
async
function writeCronStore(storePath: string, jobs: Array<Record<string, unknown>>) {
await fs.mkdir(path.dirname(storePath), { recursive:
true });
await fs.writeFile(
storePath,
JSON.stringify(
{
version:
1 ,
jobs,
},
null ,
2 ,
),
"utf-8" ,
);
}
describe(
"maybeRepairLegacyCronStore" , () => {
it(
"repairs legacy cron store fields and migrates notify fallback to webhook delivery" , asyn
c () => {
const storePath = await makeTempStorePath();
await writeCronStore(storePath, [createLegacyCronJob()]);
const noteSpy = noteMock;
const cfg = createCronConfig(storePath);
await maybeRepairLegacyCronStore({
cfg,
options: {},
prompter: makePrompter(true ),
});
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8" )) as {
jobs: Array<Record<string, unknown>>;
};
const [job] = persisted.jobs;
expect(job?.jobId).toBeUndefined();
expect(job?.id).toBe("legacy-job" );
expect(job?.notify).toBeUndefined();
expect(job?.schedule).toMatchObject({
kind: "cron" ,
expr: "0 7 * * *" ,
tz: "UTC" ,
});
expect(job?.delivery).toMatchObject({
mode: "webhook" ,
to: "https://example.invalid/cron-finished ",
});
expect(job?.payload).toMatchObject({
kind: "systemEvent" ,
text: "Morning brief" ,
});
expect(noteSpy).toHaveBeenCalledWith(
expect.stringContaining("Legacy cron job storage detected" ),
"Cron" ,
);
expect(noteSpy).toHaveBeenCalledWith(
expect.stringContaining("Cron store normalized" ),
"Doctor changes" ,
);
});
it("repairs malformed persisted cron ids before list rendering sees them" , async () => {
const storePath = await makeTempStorePath();
await writeCronStore(storePath, [
createLegacyCronJob({
id: 42 ,
jobId: undefined,
notify: false ,
}),
createLegacyCronJob({
id: undefined,
jobId: undefined,
name: "Missing id" ,
notify: false ,
}),
]);
await maybeRepairLegacyCronStore({
cfg: createCronConfig(storePath),
options: {},
prompter: makePrompter(true ),
});
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8" )) as {
jobs: Array<Record<string, unknown>>;
};
expect(persisted.jobs[0 ]?.id).toBe("42" );
expect(typeof persisted.jobs[1 ]?.id).toBe("string" );
expect(String(persisted.jobs[1 ]?.id)).toMatch(/^cron-/);
expect(noteMock).toHaveBeenCalledWith(
expect.stringContaining("stores `id` as a non-string value" ),
"Cron" ,
);
expect(noteMock).toHaveBeenCalledWith(
expect.stringContaining("missing a canonical string `id`" ),
"Cron" ,
);
});
it("warns instead of replacing announce delivery for notify fallback jobs" , async () => {
const storePath = await makeTempStorePath();
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(
storePath,
JSON.stringify(
{
version: 1 ,
jobs: [
{
id: "notify-and-announce" ,
name: "Notify and announce" ,
notify: true ,
createdAtMs: Date.parse("2026-02-01T00:00:00.000Z" ),
updatedAtMs: Date.parse("2026-02-02T00:00:00.000Z" ),
schedule: { kind: "every" , everyMs: 60 _000 },
sessionTarget: "isolated" ,
wakeMode: "now" ,
payload: { kind: "agentTurn" , message: "Status" },
delivery: { mode: "announce" , channel: "telegram" , to: "123" },
state: {},
},
],
},
null ,
2 ,
),
"utf-8" ,
);
const noteSpy = noteMock;
await maybeRepairLegacyCronStore({
cfg: {
cron: {
store: storePath,
webhook: "https://example.invalid/cron-finished ",
},
},
options: { nonInteractive: true },
prompter: makePrompter(true ),
});
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8" )) as {
jobs: Array<Record<string, unknown>>;
};
expect(persisted.jobs[0 ]?.notify).toBe(true );
expect(noteSpy).toHaveBeenCalledWith(
expect.stringContaining('uses legacy notify fallback alongside delivery mode "announce"' ),
"Doctor warnings" ,
);
});
it("does not auto-repair in non-interactive mode without explicit repair approval" , async () => {
const storePath = await makeTempStorePath();
await writeCronStore(storePath, [createLegacyCronJob()]);
const noteSpy = noteMock;
const prompter = makePrompter(false );
await maybeRepairLegacyCronStore({
cfg: createCronConfig(storePath),
options: { nonInteractive: true },
prompter,
});
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8" )) as {
jobs: Array<Record<string, unknown>>;
};
expect(prompter.confirm).toHaveBeenCalledWith({
message: "Repair legacy cron jobs now?" ,
initialValue: true ,
});
expect(persisted.jobs[0 ]?.jobId).toBe("legacy-job" );
expect(persisted.jobs[0 ]?.notify).toBe(true );
expect(noteSpy).not.toHaveBeenCalledWith(
expect.stringContaining("Cron store normalized" ),
"Doctor changes" ,
);
});
it("migrates notify fallback none delivery jobs to cron.webhook" , async () => {
const storePath = await makeTempStorePath();
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(
storePath,
JSON.stringify(
{
version: 1 ,
jobs: [
{
id: "notify-none" ,
name: "Notify none" ,
notify: true ,
createdAtMs: Date.parse("2026-02-01T00:00:00.000Z" ),
updatedAtMs: Date.parse("2026-02-02T00:00:00.000Z" ),
schedule: { kind: "every" , everyMs: 60 _000 },
payload: {
kind: "systemEvent" ,
text: "Status" ,
},
delivery: { mode: "none" , to: "123456789" },
state: {},
},
],
},
null ,
2 ,
),
"utf-8" ,
);
await maybeRepairLegacyCronStore({
cfg: {
cron: {
store: storePath,
webhook: "https://example.invalid/cron-finished ",
},
},
options: {},
prompter: makePrompter(true ),
});
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8" )) as {
jobs: Array<Record<string, unknown>>;
};
expect(persisted.jobs[0 ]?.notify).toBeUndefined();
expect(persisted.jobs[0 ]?.delivery).toMatchObject({
mode: "webhook" ,
to: "https://example.invalid/cron-finished ",
});
});
it("repairs legacy root delivery threadId hints into delivery" , async () => {
const storePath = await makeTempStorePath();
await writeCronStore(storePath, [
{
id: "legacy-thread-hint" ,
name: "Legacy thread hint" ,
enabled: true ,
createdAtMs: Date.parse("2026-02-01T00:00:00.000Z" ),
updatedAtMs: Date.parse("2026-02-02T00:00:00.000Z" ),
schedule: { kind: "cron" , cron: "0 7 * * *" , tz: "UTC" },
sessionTarget: "isolated" ,
wakeMode: "now" ,
payload: {
kind: "agentTurn" ,
message: "Morning brief" ,
},
channel: " telegram " ,
to: "-1001234567890" ,
threadId: " 99 " ,
state: {},
},
]);
await maybeRepairLegacyCronStore({
cfg: createCronConfig(storePath),
options: {},
prompter: makePrompter(true ),
});
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8" )) as {
jobs: Array<Record<string, unknown>>;
};
expect(persisted.jobs[0 ]?.channel).toBeUndefined();
expect(persisted.jobs[0 ]?.to).toBeUndefined();
expect(persisted.jobs[0 ]?.threadId).toBeUndefined();
expect(persisted.jobs[0 ]?.delivery).toMatchObject({
mode: "announce" ,
channel: "telegram" ,
to: "-1001234567890" ,
threadId: "99" ,
});
});
it("rewrites stale managed dreaming jobs to the isolated agentTurn shape" , async () => {
const storePath = await makeTempStorePath();
await writeCronStore(storePath, [
{
id: "memory-dreaming" ,
name: "Memory Dreaming Promotion" ,
description:
"[managed-by=memory-core.short-term-promotion] Promote weighted short-term recalls." ,
enabled: true ,
createdAtMs: Date.parse("2026-04-01T00:00:00.000Z" ),
updatedAtMs: Date.parse("2026-04-01T00:00:00.000Z" ),
schedule: { kind: "cron" , expr: "0 3 * * *" , tz: "UTC" },
sessionTarget: "main" ,
wakeMode: "now" ,
payload: {
kind: "systemEvent" ,
text: "__openclaw_memory_core_short_term_promotion_dream__" ,
},
state: {},
},
]);
const noteSpy = noteMock;
await maybeRepairLegacyCronStore({
cfg: createCronConfig(storePath),
options: {},
prompter: makePrompter(true ),
});
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8" )) as {
jobs: Array<Record<string, unknown>>;
};
const [job] = persisted.jobs;
expect(job).toMatchObject({
sessionTarget: "isolated" ,
payload: {
kind: "agentTurn" ,
message: "__openclaw_memory_core_short_term_promotion_dream__" ,
lightContext: true ,
},
delivery: { mode: "none" },
});
expect(noteSpy).toHaveBeenCalledWith(expect.stringContaining("managed dreaming job" ), "Cron" );
expect(noteSpy).toHaveBeenCalledWith(
expect.stringContaining("Rewrote 1 managed dreaming job" ),
"Doctor changes" ,
);
});
});
Messung V0.5 in Prozent C=99 H=100 G=99
¤ Dauer der Verarbeitung: 0.11 Sekunden
(vorverarbeitet am 2026-06-10)
¤
*© Formatika GbR, Deutschland