Skip to content

Commit 219f27a

Browse files
authored
Expose legacy cron store doctor lint findings (#99211)
Adds a default-disabled Doctor lint check for legacy cron store state while keeping real repair in the existing doctor --fix path. Verification: - Focused cron/contribution Vitest passed (119 tests). - Changed-file oxfmt passed. - Changed-file oxlint with core tsconfig passed. - pnpm check:architecture passed. - SDK surface report passed. - git diff --check passed. - Source CLI proof on isolated config/state passed for doctor --lint --json --only core/doctor/legacy-cron-store. - Exact-head hosted checks are clean/skipped; only superseded auto-response/proof runs were cancelled. Maintainer notes: - ClawSweeper: proof sufficient, platinum hermit, exact-head reviewed. - No public SDK/plugin/config/CLI contract change; default doctor --lint remains unchanged.
1 parent 1cab479 commit 219f27a

8 files changed

Lines changed: 410 additions & 4 deletions

File tree

scripts/check-kysely-guardrails.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const rawSqliteAllowPathGroups = {
4949
"agent auth profile read-only bootstrap": ["src/agents/auth-profiles/sqlite.ts"],
5050
"read-only SQLite status probes": ["src/commands/status.scan.shared.ts"],
5151
"doctor legacy state migration": [
52+
"src/commands/doctor/cron/migration-ledger.ts",
5253
"src/infra/state-migrations.ts",
5354
"src/infra/state-migrations.debug-proxy.ts",
5455
],

src/commands/doctor/cron/index.test.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { runOpenClawStateWriteTransaction } from "../../../state/openclaw-state-db.js";
1717
import { withRestoredMocks } from "../../../test-utils/vitest-spies.js";
1818
import {
19+
collectLegacyCronStoreHealthFindings,
1920
collectLegacyWhatsAppCrontabHealthWarning,
2021
maybeRepairLegacyCronStore,
2122
noteLegacyWhatsAppCrontabHealthCheck,
@@ -37,6 +38,7 @@ async function makeTempStorePath() {
3738
}
3839

3940
afterEach(async () => {
41+
vi.unstubAllEnvs();
4042
noteMock.mockClear();
4143
if (tempRoot) {
4244
await fs.rm(tempRoot, { recursive: true, force: true });
@@ -209,6 +211,95 @@ function mockExdevRename(filePath: string) {
209211
});
210212
}
211213

214+
describe("collectLegacyCronStoreHealthFindings", () => {
215+
it("reports legacy cron store, run-log, and payload findings without mutating files", async () => {
216+
const storePath = await makeTempStorePath();
217+
await writeLegacyCronArrayStore(storePath, [
218+
createLegacyCronJob({
219+
jobId: "legacy-notify",
220+
payload: {
221+
kind: "systemEvent",
222+
text: "Morning brief",
223+
},
224+
}),
225+
]);
226+
const runLogPath = path.join(path.dirname(storePath), "runs", "legacy-notify.jsonl");
227+
await fs.mkdir(path.dirname(runLogPath), { recursive: true });
228+
await fs.writeFile(runLogPath, "", "utf-8");
229+
230+
const findings = await collectLegacyCronStoreHealthFindings({
231+
cfg: createCronConfig(storePath),
232+
});
233+
234+
expect(findings).toEqual(
235+
expect.arrayContaining([
236+
expect.objectContaining({
237+
checkId: "core/doctor/legacy-cron-store",
238+
severity: "warning",
239+
path: storePath,
240+
requirement: "legacy-cron-store",
241+
}),
242+
expect.objectContaining({
243+
checkId: "core/doctor/legacy-cron-store",
244+
severity: "warning",
245+
path: storePath,
246+
requirement: "legacy-notify-fallback",
247+
}),
248+
]),
249+
);
250+
expect(findings.some((finding) => finding.requirement === "legacy-cron-run-logs")).toBe(true);
251+
await expect(fs.readFile(storePath, "utf-8")).resolves.toContain("legacy-notify");
252+
await expect(fs.stat(runLogPath)).resolves.toBeDefined();
253+
});
254+
255+
it("reports quarantined cron rows while leaving the active store untouched", async () => {
256+
const storePath = await makeTempStorePath();
257+
await writeCurrentCronStore(storePath, []);
258+
await fs.mkdir(path.dirname(resolveCronQuarantinePath(storePath)), { recursive: true });
259+
await fs.writeFile(
260+
resolveCronQuarantinePath(storePath),
261+
JSON.stringify(
262+
{
263+
version: 1,
264+
jobs: [
265+
{
266+
quarantinedAtMs: Date.parse("2026-05-29T09:00:00.000Z"),
267+
sourceIndex: 1,
268+
reason: "missing-schedule",
269+
job: { id: "bad-cron", name: "Bad cron" },
270+
},
271+
],
272+
},
273+
null,
274+
2,
275+
),
276+
"utf-8",
277+
);
278+
279+
const findings = await collectLegacyCronStoreHealthFindings({
280+
cfg: createCronConfig(storePath),
281+
});
282+
283+
expect(findings).toEqual([
284+
expect.objectContaining({
285+
checkId: "core/doctor/legacy-cron-store",
286+
path: resolveCronQuarantinePath(storePath),
287+
requirement: "quarantined-cron-rows",
288+
}),
289+
]);
290+
await expect(readPersistedJobs(storePath)).resolves.toEqual([]);
291+
});
292+
293+
it("returns no findings for an already-normalized empty cron store", async () => {
294+
const storePath = await makeTempStorePath();
295+
await writeCurrentCronStore(storePath, []);
296+
297+
await expect(
298+
collectLegacyCronStoreHealthFindings({ cfg: createCronConfig(storePath) }),
299+
).resolves.toEqual([]);
300+
});
301+
});
302+
212303
describe("maybeRepairLegacyCronStore", () => {
213304
it("reports quarantined cron rows even when the active store is already sanitized", async () => {
214305
const storePath = await makeTempStorePath();
@@ -555,7 +646,9 @@ describe("maybeRepairLegacyCronStore", () => {
555646
await expect(fs.stat(storePath)).rejects.toMatchObject({ code: "ENOENT" });
556647
await expect(fs.readFile(archivePath, "utf-8")).resolves.toContain("legacy-job");
557648
const archiveStat = await fs.stat(archivePath);
558-
expect(archiveStat.mode & 0o777).toBe(0o640);
649+
if (process.platform !== "win32") {
650+
expect(archiveStat.mode & 0o777).toBe(0o640);
651+
}
559652
expect(archiveStat.mtimeMs).toBe(sourceMtime.getTime());
560653
expectNoteContaining("Cron store migrated to SQLite", "Doctor changes");
561654
expectNoNoteContaining("could not archive the legacy cron file", "Doctor warnings");

src/commands/doctor/cron/index.ts

Lines changed: 161 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js";
66
import {
77
loadCronQuarantineFile,
88
loadCronJobsStoreWithConfigJobs,
9+
loadCronJobsStoreWithConfigJobsReadOnly,
910
resolveCronQuarantinePath,
1011
resolveCronJobsStorePath,
1112
saveCronQuarantineFile,
1213
saveCronJobsStore,
1314
saveCronJobsStoreWithMetadata,
1415
} from "../../../cron/store.js";
1516
import type { CronJob } from "../../../cron/types.js";
17+
import type { HealthFinding } from "../../../flows/health-checks.js";
1618
import { shortenHomePath } from "../../../utils.js";
1719
import type { DoctorPrompter, DoctorOptions } from "../../doctor-prompter.js";
1820
import {
@@ -34,6 +36,7 @@ import {
3436
import {
3537
acquireLegacyCronMigrationReceipt,
3638
hasLegacyCronMigrationReceipt,
39+
hasLegacyCronMigrationReceiptReadOnly,
3740
markLegacyCronMigrationSourceRemoved,
3841
} from "./migration-ledger.js";
3942
import {
@@ -99,9 +102,30 @@ export type LegacyCronRepairResult = {
99102
warnings: string[];
100103
};
101104

105+
const LEGACY_CRON_STORE_CHECK_ID = "core/doctor/legacy-cron-store";
106+
107+
function legacyCronStoreFinding(params: {
108+
readonly message: string;
109+
readonly path: string;
110+
readonly requirement: string;
111+
readonly fixHint?: string;
112+
}): HealthFinding {
113+
return {
114+
checkId: LEGACY_CRON_STORE_CHECK_ID,
115+
severity: "warning",
116+
message: params.message,
117+
path: params.path,
118+
requirement: params.requirement,
119+
fixHint:
120+
params.fixHint ??
121+
`Run ${formatCliCommand("openclaw doctor --fix")} to normalize legacy cron storage.`,
122+
};
123+
}
124+
102125
async function loadLegacyCronRepairState(params: {
103126
cfg: OpenClawConfig;
104127
onlyIfLegacyDetected?: boolean;
128+
readOnly?: boolean;
105129
}): Promise<LegacyCronRepairState | null> {
106130
const storePath = resolveCronJobsStorePath(params.cfg.cron?.store);
107131
const quarantinePath = resolveCronQuarantinePath(storePath);
@@ -111,7 +135,9 @@ async function loadLegacyCronRepairState(params: {
111135
return null;
112136
}
113137

114-
const loaded = await loadCronJobsStoreWithConfigJobs(storePath);
138+
const loaded = params.readOnly
139+
? await loadCronJobsStoreWithConfigJobsReadOnly(storePath)
140+
: await loadCronJobsStoreWithConfigJobs(storePath);
115141
const currentJobs =
116142
loaded.configJobs.length > 0
117143
? loaded.configJobs.map((job, index) =>
@@ -138,7 +164,9 @@ async function loadLegacyCronRepairState(params: {
138164
const loadedLegacy = await loadLegacyCronStoreForMigration(storePath);
139165
legacyMigrationSource = loadedLegacy.migrationSource;
140166
legacyMigrationAlreadyImported = legacyMigrationSource
141-
? hasLegacyCronMigrationReceipt(legacyMigrationSource)
167+
? params.readOnly
168+
? hasLegacyCronMigrationReceiptReadOnly(legacyMigrationSource)
169+
: hasLegacyCronMigrationReceipt(legacyMigrationSource)
142170
: false;
143171
if (!legacyMigrationAlreadyImported) {
144172
const merged = mergeLegacyCronJobs({
@@ -283,6 +311,137 @@ async function applyLegacyCronStoreRepair(params: {
283311
return { changes, warnings };
284312
}
285313

314+
export async function collectLegacyCronStoreHealthFindings(params: {
315+
cfg: OpenClawConfig;
316+
}): Promise<readonly HealthFinding[]> {
317+
let state: LegacyCronRepairState | null;
318+
try {
319+
state = await loadLegacyCronRepairState({ cfg: params.cfg, readOnly: true });
320+
} catch (err) {
321+
const storePath = resolveCronJobsStorePath(params.cfg.cron?.store);
322+
return [
323+
legacyCronStoreFinding({
324+
message: `Unable to read cron job store at ${shortenHomePath(storePath)}.`,
325+
path: storePath,
326+
requirement: "cron-store-readable",
327+
fixHint: [
328+
`Fix the file's permissions or contents and re-run ${formatCliCommand("openclaw doctor")}.`,
329+
"Later health checks will continue.",
330+
`Details: ${errorMessage(err)}`,
331+
].join(" "),
332+
}),
333+
];
334+
}
335+
if (!state) {
336+
return [];
337+
}
338+
339+
const findings: HealthFinding[] = [];
340+
const {
341+
storePath,
342+
quarantinePath,
343+
legacyStoreDetected,
344+
legacyRunLogDetected,
345+
legacyImportCount,
346+
sqliteProjectionBackfillCount,
347+
rawJobs,
348+
} = state;
349+
350+
try {
351+
const quarantine = await loadCronQuarantineFile(quarantinePath);
352+
if (quarantine.jobs.length > 0) {
353+
findings.push(
354+
legacyCronStoreFinding({
355+
message: `${pluralize(quarantine.jobs.length, "quarantined cron job row")} found at ${shortenHomePath(quarantinePath)}.`,
356+
path: quarantinePath,
357+
requirement: "quarantined-cron-rows",
358+
fixHint: `Review or repair the quarantined rows manually before copying any job back into ${shortenHomePath(storePath)}.`,
359+
}),
360+
);
361+
}
362+
} catch (err) {
363+
findings.push(
364+
legacyCronStoreFinding({
365+
message: `Unable to read quarantined cron rows at ${shortenHomePath(quarantinePath)}.`,
366+
path: quarantinePath,
367+
requirement: "cron-quarantine-readable",
368+
fixHint: `Fix the quarantine file's permissions or contents. Details: ${errorMessage(err)}`,
369+
}),
370+
);
371+
}
372+
373+
if (legacyStoreDetected) {
374+
findings.push(
375+
legacyCronStoreFinding({
376+
message:
377+
legacyImportCount > 0
378+
? `${pluralize(legacyImportCount, "legacy JSON cron job")} will be imported into SQLite.`
379+
: `Legacy JSON cron store was found at ${shortenHomePath(storePath)}.`,
380+
path: storePath,
381+
requirement: "legacy-cron-store",
382+
}),
383+
);
384+
}
385+
if (legacyRunLogDetected) {
386+
findings.push(
387+
legacyCronStoreFinding({
388+
message: `Legacy JSON cron run logs will be imported into SQLite for ${shortenHomePath(storePath)}.`,
389+
path: storePath,
390+
requirement: "legacy-cron-run-logs",
391+
}),
392+
);
393+
}
394+
395+
if (rawJobs.length === 0) {
396+
return findings;
397+
}
398+
399+
const normalized = normalizeStoredCronJobs(rawJobs);
400+
for (const line of formatLegacyIssuePreview(normalized.issues)) {
401+
findings.push(
402+
legacyCronStoreFinding({
403+
message: line.replace(/^- /u, ""),
404+
path: storePath,
405+
requirement: "legacy-cron-store-shape",
406+
}),
407+
);
408+
}
409+
410+
if (sqliteProjectionBackfillCount > 0) {
411+
findings.push(
412+
legacyCronStoreFinding({
413+
message: `${pluralize(sqliteProjectionBackfillCount, "SQLite cron row")} will be backfilled from stored config JSON into split columns.`,
414+
path: storePath,
415+
requirement: "sqlite-projection-backfill",
416+
}),
417+
);
418+
}
419+
420+
const notifyCount = rawJobs.filter((job) => job.notify === true).length;
421+
if (notifyCount > 0) {
422+
findings.push(
423+
legacyCronStoreFinding({
424+
message: `${pluralize(notifyCount, "job")} still uses legacy notify webhook fallback.`,
425+
path: storePath,
426+
requirement: "legacy-notify-fallback",
427+
}),
428+
);
429+
}
430+
431+
const dreamingStaleCount = countStaleDreamingJobs(rawJobs);
432+
if (dreamingStaleCount > 0) {
433+
findings.push(
434+
legacyCronStoreFinding({
435+
message: `${pluralize(dreamingStaleCount, "managed dreaming job")} still has the legacy heartbeat-coupled shape.`,
436+
path: storePath,
437+
requirement: "legacy-dreaming-payload",
438+
}),
439+
);
440+
}
441+
442+
return findings;
443+
}
444+
286445
export async function repairLegacyCronStoreWithoutPrompt(params: {
287446
cfg: OpenClawConfig;
288447
}): Promise<LegacyCronRepairResult> {

src/commands/doctor/cron/migration-ledger.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
// Durable receipts make legacy cron migration retries independent of mutable runtime rows.
2+
import fs from "node:fs";
23
import type { DatabaseSync } from "node:sqlite";
34
import {
45
executeSqliteQuerySync,
56
executeSqliteQueryTakeFirstSync,
67
getNodeSqliteKysely,
78
} from "../../../infra/kysely-sync.js";
9+
import { requireNodeSqlite } from "../../../infra/node-sqlite.js";
810
import type { DB as OpenClawStateDatabase } from "../../../state/openclaw-state-db.generated.js";
911
import {
1012
openOpenClawStateDatabase,
1113
runOpenClawStateWriteTransaction,
1214
} from "../../../state/openclaw-state-db.js";
15+
import { resolveOpenClawStateSqlitePath } from "../../../state/openclaw-state-db.paths.js";
1316
import type { LegacyCronMigrationSource } from "./legacy-store-migration.js";
1417

1518
type CronMigrationDatabase = Pick<OpenClawStateDatabase, "migration_runs" | "migration_sources">;
@@ -36,6 +39,31 @@ export function hasLegacyCronMigrationReceipt(source: LegacyCronMigrationSource)
3639
return hasLegacyCronMigrationReceiptInDatabase(openOpenClawStateDatabase().db, source);
3740
}
3841

42+
function tableExists(db: DatabaseSync, tableName: string): boolean {
43+
return (
44+
db
45+
.prepare("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?")
46+
.get(tableName) !== undefined
47+
);
48+
}
49+
50+
export function hasLegacyCronMigrationReceiptReadOnly(source: LegacyCronMigrationSource): boolean {
51+
const statePath = resolveOpenClawStateSqlitePath(process.env);
52+
if (!fs.existsSync(statePath)) {
53+
return false;
54+
}
55+
const sqlite = requireNodeSqlite();
56+
const db = new sqlite.DatabaseSync(statePath, { readOnly: true });
57+
try {
58+
if (!tableExists(db, "migration_sources")) {
59+
return false;
60+
}
61+
return hasLegacyCronMigrationReceiptInDatabase(db, source);
62+
} finally {
63+
db.close();
64+
}
65+
}
66+
3967
export function acquireLegacyCronMigrationReceipt(
4068
db: DatabaseSync,
4169
source: LegacyCronMigrationSource,

0 commit comments

Comments
 (0)