fix(cron): fix timeout, add timestamp validation, enable file sync

Fixes #7667

Task 1: Fix cron operation timeouts
- Increase default gateway tool timeout from 10s to 30s
- Increase cron-specific tool timeout to 60s
- Increase CLI default timeout from 10s to 30s
- Prevents timeouts when gateway is busy with long-running jobs

Task 2: Add timestamp validation
- New validateScheduleTimestamp() function in validate-timestamp.ts
- Rejects atMs timestamps more than 1 minute in the past
- Rejects atMs timestamps more than 10 years in the future
- Applied to both cron.add and cron.update operations
- Provides helpful error messages with current time and offset

Task 3: Enable file sync for manual edits
- Track file modification time (storeFileMtimeMs) in CronServiceState
- Check file mtime in ensureLoaded() and reload if changed
- Recompute next runs after reload to maintain accuracy
- Update mtime after persist() to prevent reload loop
- Dashboard now picks up manual edits to ~/.openclaw/cron/jobs.json
This commit is contained in:
Tyler Yust
2026-02-03 06:12:07 -08:00
committed by Peter Steinberger
parent a749db9820
commit 3a03e38378
7 changed files with 128 additions and 13 deletions

View File

@@ -48,6 +48,8 @@ export type CronServiceState = {
running: boolean;
op: Promise<unknown>;
warnedDisabled: boolean;
storeLoadedAtMs: number | null;
storeFileMtimeMs: number | null;
};
export function createCronServiceState(deps: CronServiceDeps): CronServiceState {
@@ -58,6 +60,8 @@ export function createCronServiceState(deps: CronServiceDeps): CronServiceState
running: false,
op: Promise.resolve(),
warnedDisabled: false,
storeLoadedAtMs: null,
storeFileMtimeMs: null,
};
}

View File

@@ -1,20 +1,37 @@
import fs from "node:fs";
import type { CronJob } from "../types.js";
import type { CronServiceState } from "./state.js";
import { migrateLegacyCronPayload } from "../payload-migration.js";
import { loadCronStore, saveCronStore } from "../store.js";
import { recomputeNextRuns } from "./jobs.js";
import { inferLegacyName, normalizeOptionalText } from "./normalize.js";
const storeCache = new Map<string, { version: 1; jobs: CronJob[] }>();
async function getFileMtimeMs(path: string): Promise<number | null> {
try {
const stats = await fs.promises.stat(path);
return stats.mtimeMs;
} catch {
return null;
}
}
export async function ensureLoaded(state: CronServiceState) {
if (state.store) {
return;
}
const cached = storeCache.get(state.deps.storePath);
if (cached) {
state.store = cached;
const fileMtimeMs = await getFileMtimeMs(state.deps.storePath);
// Check if we need to reload:
// - No store loaded yet
// - File modification time has changed
// - File was modified after we last loaded (external edit)
const needsReload =
!state.store ||
(fileMtimeMs !== null &&
state.storeFileMtimeMs !== null &&
fileMtimeMs > state.storeFileMtimeMs);
if (!needsReload) {
return;
}
const loaded = await loadCronStore(state.deps.storePath);
const jobs = (loaded.jobs ?? []) as unknown as Array<Record<string, unknown>>;
let mutated = false;
@@ -44,7 +61,12 @@ export async function ensureLoaded(state: CronServiceState) {
}
}
state.store = { version: 1, jobs: jobs as unknown as CronJob[] };
storeCache.set(state.deps.storePath, state.store);
state.storeLoadedAtMs = state.deps.nowMs();
state.storeFileMtimeMs = fileMtimeMs;
// Recompute next runs after loading to ensure accuracy
recomputeNextRuns(state);
if (mutated) {
await persist(state);
}
@@ -69,4 +91,6 @@ export async function persist(state: CronServiceState) {
return;
}
await saveCronStore(state.deps.storePath, state.store);
// Update file mtime after save to prevent immediate reload
state.storeFileMtimeMs = await getFileMtimeMs(state.deps.storePath);
}

View File

@@ -0,0 +1,64 @@
import type { CronSchedule } from "./types.js";
const ONE_MINUTE_MS = 60 * 1000;
const TEN_YEARS_MS = 10 * 365.25 * 24 * 60 * 60 * 1000;
export type TimestampValidationError = {
ok: false;
message: string;
};
export type TimestampValidationSuccess = {
ok: true;
};
export type TimestampValidationResult = TimestampValidationSuccess | TimestampValidationError;
/**
* Validates atMs timestamps in cron schedules.
* Rejects timestamps that are:
* - More than 1 minute in the past
* - More than 10 years in the future
*/
export function validateScheduleTimestamp(
schedule: CronSchedule,
nowMs: number = Date.now(),
): TimestampValidationResult {
if (schedule.kind !== "at") {
return { ok: true };
}
const atMs = schedule.atMs;
if (typeof atMs !== "number" || !Number.isFinite(atMs)) {
return {
ok: false,
message: `Invalid atMs: must be a finite number (got ${String(atMs)})`,
};
}
const diffMs = atMs - nowMs;
// Check if timestamp is in the past (allow 1 minute grace period)
if (diffMs < -ONE_MINUTE_MS) {
const nowDate = new Date(nowMs).toISOString();
const atDate = new Date(atMs).toISOString();
const minutesAgo = Math.floor(-diffMs / ONE_MINUTE_MS);
return {
ok: false,
message: `atMs is in the past: ${atDate} (${minutesAgo} minutes ago). Current time: ${nowDate}`,
};
}
// Check if timestamp is too far in the future
if (diffMs > TEN_YEARS_MS) {
const atDate = new Date(atMs).toISOString();
const yearsAhead = Math.floor(diffMs / (365.25 * 24 * 60 * 60 * 1000));
return {
ok: false,
message: `atMs is too far in the future: ${atDate} (${yearsAhead} years ahead). Maximum allowed: 10 years`,
};
}
return { ok: true };
}