2026-02-19 14:59:34 -08:00

100 lines
3.1 KiB
TypeScript

import { readFileSync, existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { resolveOpenClawStateDir } from "@/lib/workspace";
export const dynamic = "force-dynamic";
const CRON_DIR = join(resolveOpenClawStateDir(), "cron");
const JOBS_FILE = join(CRON_DIR, "jobs.json");
type CronStoreFile = {
version: 1;
jobs: Array<Record<string, unknown>>;
};
/** Read cron jobs.json, returning empty array if missing or invalid. */
function readJobsFile(): Array<Record<string, unknown>> {
if (!existsSync(JOBS_FILE)) {return [];}
try {
const raw = readFileSync(JOBS_FILE, "utf-8");
const parsed = JSON.parse(raw) as CronStoreFile;
if (parsed && Array.isArray(parsed.jobs)) {return parsed.jobs;}
return [];
} catch {
return [];
}
}
/** Compute next wake time from job states (minimum nextRunAtMs among enabled jobs). */
function computeNextWakeAtMs(jobs: Array<Record<string, unknown>>): number | null {
let min: number | null = null;
for (const job of jobs) {
if (job.enabled !== true) {continue;}
const state = job.state as Record<string, unknown> | undefined;
if (!state) {continue;}
const next = state.nextRunAtMs;
if (typeof next === "number" && Number.isFinite(next)) {
if (min === null || next < min) {min = next;}
}
}
return min;
}
/** Read heartbeat config from ~/.openclaw/config.yaml (best-effort). */
function readHeartbeatInfo(): { intervalMs: number; nextDueEstimateMs: number | null } {
const defaults = { intervalMs: 30 * 60_000, nextDueEstimateMs: null as number | null };
// Try to read agent session stores to estimate next heartbeat from lastRunMs
try {
const agentsDir = join(resolveOpenClawStateDir(), "agents");
if (!existsSync(agentsDir)) {return defaults;}
const agentDirs = readdirSync(agentsDir, { withFileTypes: true });
let latestHeartbeat: number | null = null;
for (const d of agentDirs) {
if (!d.isDirectory()) {continue;}
const storePath = join(agentsDir, d.name, "sessions", "sessions.json");
if (!existsSync(storePath)) {continue;}
try {
const raw = readFileSync(storePath, "utf-8");
const store = JSON.parse(raw) as Record<string, { updatedAt?: number }>;
// Look for the main agent session (shortest key, most recently updated)
for (const [key, entry] of Object.entries(store)) {
if (key.startsWith("agent:") && !key.includes(":cron:") && entry.updatedAt) {
if (latestHeartbeat === null || entry.updatedAt > latestHeartbeat) {
latestHeartbeat = entry.updatedAt;
}
}
}
} catch {
// skip
}
}
if (latestHeartbeat) {
defaults.nextDueEstimateMs = latestHeartbeat + defaults.intervalMs;
}
} catch {
// ignore
}
return defaults;
}
/** GET /api/cron/jobs -- list all cron jobs with heartbeat & status info */
export async function GET() {
const jobs = readJobsFile();
const heartbeat = readHeartbeatInfo();
const nextWakeAtMs = computeNextWakeAtMs(jobs);
return Response.json({
jobs,
heartbeat,
cronStatus: {
enabled: jobs.length > 0,
nextWakeAtMs,
},
});
}