mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:20:43 +00:00
cron: harden split state loading
This commit is contained in:
@@ -35,6 +35,7 @@ openclaw cron runs --id <job-id>
|
||||
- Cron runs **inside the Gateway** process (not inside the model).
|
||||
- Job definitions persist at `~/.openclaw/cron/jobs.json` so restarts do not lose schedules.
|
||||
- Runtime execution state persists next to it in `~/.openclaw/cron/jobs-state.json`. If you track cron definitions in git, track `jobs.json` and gitignore `jobs-state.json`.
|
||||
- After the split, older OpenClaw versions can read `jobs.json` but may treat jobs as fresh because runtime fields now live in `jobs-state.json`.
|
||||
- All cron executions create [background task](/automation/tasks) records.
|
||||
- One-shot jobs (`--at`) auto-delete after success by default.
|
||||
- Isolated cron runs best-effort close tracked browser tabs/processes for their `cron:<jobId>` session when the run completes, so detached browser automation does not leave orphaned processes behind.
|
||||
|
||||
@@ -186,6 +186,41 @@ describe("cron store", () => {
|
||||
expect(loaded.jobs[0]?.state.nextRunAtMs).toBe(first.jobs[0].createdAtMs + 60_000);
|
||||
});
|
||||
|
||||
it("sanitizes invalid updatedAtMs values from the state sidecar", async () => {
|
||||
const store = await makeStorePath();
|
||||
const job = makeStore("job-1", true).jobs[0];
|
||||
const config = {
|
||||
version: 1,
|
||||
jobs: [{ ...job, state: {}, updatedAtMs: undefined }],
|
||||
};
|
||||
const statePath = store.storePath.replace(/\.json$/, "-state.json");
|
||||
|
||||
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
|
||||
await fs.writeFile(store.storePath, JSON.stringify(config, null, 2), "utf-8");
|
||||
await fs.writeFile(
|
||||
statePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
jobs: {
|
||||
[job.id]: {
|
||||
updatedAtMs: "invalid",
|
||||
state: { nextRunAtMs: job.createdAtMs + 60_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const loaded = await loadCronStore(store.storePath);
|
||||
|
||||
expect(loaded.jobs[0]?.updatedAtMs).toBe(job.createdAtMs);
|
||||
expect(loaded.jobs[0]?.state.nextRunAtMs).toBe(job.createdAtMs + 60_000);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"writes store and backup files with secure permissions",
|
||||
async () => {
|
||||
|
||||
@@ -110,6 +110,18 @@ function backfillMissingRuntimeFields(job: CronStoreFile["jobs"][number]): void
|
||||
}
|
||||
}
|
||||
|
||||
function resolveUpdatedAtMs(job: CronStoreFile["jobs"][number], updatedAtMs: unknown): number {
|
||||
if (typeof updatedAtMs === "number" && Number.isFinite(updatedAtMs)) {
|
||||
return updatedAtMs;
|
||||
}
|
||||
if (typeof job.updatedAtMs === "number" && Number.isFinite(job.updatedAtMs)) {
|
||||
return job.updatedAtMs;
|
||||
}
|
||||
return typeof job.createdAtMs === "number" && Number.isFinite(job.createdAtMs)
|
||||
? job.createdAtMs
|
||||
: Date.now();
|
||||
}
|
||||
|
||||
export async function loadCronStore(storePath: string): Promise<CronStoreFile> {
|
||||
try {
|
||||
const raw = await fs.promises.readFile(storePath, "utf-8");
|
||||
@@ -140,7 +152,7 @@ export async function loadCronStore(storePath: string): Promise<CronStoreFile> {
|
||||
for (const job of store.jobs) {
|
||||
const entry = stateFile.jobs[job.id];
|
||||
if (entry) {
|
||||
job.updatedAtMs = entry.updatedAtMs ?? job.updatedAtMs;
|
||||
job.updatedAtMs = resolveUpdatedAtMs(job, entry.updatedAtMs);
|
||||
job.state = (entry.state ?? {}) as never;
|
||||
} else {
|
||||
backfillMissingRuntimeFields(job);
|
||||
|
||||
Reference in New Issue
Block a user