@@ -6,13 +6,15 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js";
66import {
77 loadCronQuarantineFile ,
88 loadCronJobsStoreWithConfigJobs ,
9+ loadCronJobsStoreWithConfigJobsReadOnly ,
910 resolveCronQuarantinePath ,
1011 resolveCronJobsStorePath ,
1112 saveCronQuarantineFile ,
1213 saveCronJobsStore ,
1314 saveCronJobsStoreWithMetadata ,
1415} from "../../../cron/store.js" ;
1516import type { CronJob } from "../../../cron/types.js" ;
17+ import type { HealthFinding } from "../../../flows/health-checks.js" ;
1618import { shortenHomePath } from "../../../utils.js" ;
1719import type { DoctorPrompter , DoctorOptions } from "../../doctor-prompter.js" ;
1820import {
@@ -34,6 +36,7 @@ import {
3436import {
3537 acquireLegacyCronMigrationReceipt ,
3638 hasLegacyCronMigrationReceipt ,
39+ hasLegacyCronMigrationReceiptReadOnly ,
3740 markLegacyCronMigrationSourceRemoved ,
3841} from "./migration-ledger.js" ;
3942import {
@@ -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+
102125async 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+
286445export async function repairLegacyCronStoreWithoutPrompt ( params : {
287446 cfg : OpenClawConfig ;
288447} ) : Promise < LegacyCronRepairResult > {
0 commit comments