diff --git a/README.md b/README.md index d9e2d111..f2135132 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,11 @@ English | [简体中文](README.zh-CN.md) ## Standalone monitoring, recommended companion +Cron Jobs lists OpenClaw automations through its read-only CLI API, cached for +30 seconds. Loading, partial and stale catalogs are labeled; missing legacy JSON +files no longer imply zero automations. AI Jobs is a separate optional provider: +without it the page shows "not connected", not an empty successful job catalog. + **Command Center works out of the box without Spacesuit.** It owns OpenClaw sessions, health, jobs, usage and the common dashboard. For a minimal core-only view, run `COMMAND_CENTER_MODE=core npm start`. diff --git a/lib/server.js b/lib/server.js index 5110cc74..570eb8ac 100644 --- a/lib/server.js +++ b/lib/server.js @@ -291,8 +291,15 @@ var require_jobs = __commonJS({ async function handleJobsRequest2(req, res, pathname, query, method) { const api = await getAPI(); if (!api) { - res.writeHead(500, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Jobs API not available" })); + const listing = pathname === "/api/jobs" && method === "GET"; + res.writeHead(listing ? 200 : 503, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + status: "unavailable", + jobs: null, + error: "AI Jobs provider is not configured or could not be loaded" + }) + ); return; } try { @@ -1913,6 +1920,7 @@ var require_sessions = __commonJS({ var require_cron = __commonJS({ "src/cron.js"(exports2, module2) { var fs2 = require("fs"); + var { stripVTControlCharacters } = require("node:util"); var path2 = require("path"); function cronToHuman(expr) { if (!expr || expr === "\u2014") return null; @@ -1974,59 +1982,87 @@ var require_cron = __commonJS({ } return expr; } - function getCronJobs2(getOpenClawDir2) { - try { - const cronPath = path2.join(getOpenClawDir2(), "cron", "jobs.json"); - if (fs2.existsSync(cronPath)) { - const data = JSON.parse(fs2.readFileSync(cronPath, "utf8")); - return (data.jobs || []).map((j) => { - let scheduleStr = "\u2014"; - let scheduleHuman = null; - if (j.schedule) { - if (j.schedule.kind === "cron" && j.schedule.expr) { - scheduleStr = j.schedule.expr; - scheduleHuman = cronToHuman(j.schedule.expr); - } else if (j.schedule.kind === "once") { - scheduleStr = "once"; - scheduleHuman = "One-time"; - } - } - let nextRunStr = "\u2014"; - if (j.state?.nextRunAtMs) { - const next = new Date(j.state.nextRunAtMs); - const now = /* @__PURE__ */ new Date(); - const diffMs = next - now; - const diffMins = Math.round(diffMs / 6e4); - if (diffMins < 0) { - nextRunStr = "overdue"; - } else if (diffMins < 60) { - nextRunStr = `${diffMins}m`; - } else if (diffMins < 1440) { - nextRunStr = `${Math.round(diffMins / 60)}h`; - } else { - nextRunStr = `${Math.round(diffMins / 1440)}d`; - } - } - return { - id: j.id, - name: j.name || j.id.slice(0, 8), - schedule: scheduleStr, - scheduleHuman, - nextRun: nextRunStr, - enabled: j.enabled !== false, - lastStatus: j.state?.lastStatus - }; - }); + function normalizeJobs(jobs) { + return jobs.map((j) => { + if (!j || typeof j.id !== "string") throw new Error("invalid_cron_job"); + const schedule = j.schedule || {}; + let scheduleStr = "\u2014", scheduleHuman = null; + if (schedule.kind === "cron" && typeof schedule.expr === "string") { + scheduleStr = schedule.expr; + scheduleHuman = cronToHuman(schedule.expr); + } else if (["once", "at"].includes(schedule.kind)) { + scheduleStr = "once"; + scheduleHuman = "One-time"; + } else if (schedule.kind === "every" && Number.isFinite(schedule.everyMs)) { + scheduleStr = `every ${schedule.everyMs / 1e3}s`; } - } catch (e) { - console.error("Failed to get cron:", e.message); + const nextMs = j.nextRunAtMs ?? j.state?.nextRunAtMs; + let nextRun = "\u2014"; + if (Number.isFinite(nextMs)) { + const mins = Math.round((nextMs - Date.now()) / 6e4); + nextRun = mins < 0 ? "overdue" : mins < 60 ? `${mins}m` : mins < 1440 ? `${Math.round(mins / 60)}h` : `${Math.round(mins / 1440)}d`; + } + return { + id: j.id, + name: typeof j.name === "string" ? j.name : j.id.slice(0, 8), + schedule: scheduleStr, + scheduleHuman, + nextRun, + enabled: j.enabled !== false, + lastStatus: j.lastRunStatus ?? j.state?.lastStatus + }; + }); + } + function getCronJobs(getOpenClawDir2) { + try { + const file = path2.join(getOpenClawDir2(), "cron", "jobs.json"); + if (fs2.existsSync(file)) + return normalizeJobs(JSON.parse(fs2.readFileSync(file, "utf8")).jobs || []); + } catch { } return []; } - module2.exports = { - cronToHuman, - getCronJobs: getCronJobs2 - }; + function createCronHost2({ run, now = Date.now, refreshMs = 3e4 }) { + let snapshot = { jobs: [], status: "loading", observedAt: null }, pending = null, nextCheck = 0; + function refresh() { + if (pending) return pending; + nextCheck = now() + refreshMs; + pending = Promise.resolve().then(async () => { + try { + const raw = await run( + ["cron", "list", "--agent", "main", "--all", "--json", "--timeout", "5000"], + { timeout: 8e3 } + ); + if (typeof raw !== "string" || Buffer.byteLength(raw) > 4 * 1024 * 1024) + throw new Error("invalid_cron_catalog"); + const data = JSON.parse(stripVTControlCharacters(raw).trim()); + if (!Array.isArray(data.jobs) || data.jobs.length > 1e3) + throw new Error("invalid_cron_catalog"); + snapshot = { + jobs: normalizeJobs(data.jobs), + status: data.hasMore || data.total > data.jobs.length ? "partial" : "available", + observedAt: now() + }; + } catch { + snapshot = { + ...snapshot, + status: snapshot.observedAt === null ? "unavailable" : "stale" + }; + } + return snapshot; + }).finally(() => { + pending = null; + nextCheck = now() + refreshMs; + }); + return pending; + } + function getState() { + if (now() >= nextCheck && !pending) void refresh(); + return snapshot; + } + return { getState, refresh }; + } + module2.exports = { cronToHuman, getCronJobs, normalizeJobs, createCronHost: createCronHost2 }; } }); @@ -3140,7 +3176,8 @@ var require_state = __commonJS({ getOpenClawDir: getOpenClawDir2, getSessions, getSystemVitals: getSystemVitals2, - getCronJobs: getCronJobs2, + getCronJobs, + getCronStatus = () => "available", loadOperators: loadOperators2, calculateOperatorStats: calculateOperatorStats2, getLlmUsage: getLlmUsage2, @@ -3461,7 +3498,7 @@ var require_state = __commonJS({ console.error("[State] llmUsage:", e.message); } try { - cron = getCronJobs2(); + cron = getCronJobs(); } catch (e) { console.error("[State] cron:", e.message); } @@ -3520,6 +3557,7 @@ var require_state = __commonJS({ operators, llmUsage, cron, + cronStatus: getCronStatus(), memory, cerebro, subagents, @@ -4643,7 +4681,8 @@ var { calculateOperatorStats } = require_operators(); var { createSessionsModule } = require_sessions(); -var { getCronJobs } = require_cron(); +var { createCronHost } = require_cron(); +var cronHost = createCronHost({ run: runOpenClawAsync }); var { getCerebroTopics, updateTopicStatus } = require_cerebro(); var { getDailyTokenUsage, @@ -4713,7 +4752,8 @@ var state = createStateModule({ getOpenClawDir, getSessions: (opts) => sessions.getSessions(opts), getSystemVitals, - getCronJobs: () => getCronJobs(getOpenClawDir), + getCronJobs: () => cronHost.getState().jobs, + getCronStatus: () => cronHost.getState().status, loadOperators: () => loadOperators(DATA_DIR), calculateOperatorStats, getLlmUsage: () => getLlmUsage(PATHS.state), @@ -4778,7 +4818,8 @@ function handleApi(req, res) { const tokenStats = getTokenStats(sessionsList, capacity, CONFIG); const data = { sessions: sessionsList, - cron: getCronJobs(getOpenClawDir), + cron: cronHost.getState().jobs, + cronStatus: cronHost.getState().status, system: state.getSystemStatus(), activity: state.getRecentActivity(), tokenStats, @@ -5013,9 +5054,10 @@ var server = http.createServer(async (req, res) => { ) ); } else if (pathname === "/api/cron") { - const cron = getCronJobs(getOpenClawDir); + const snapshot = cronHost.getState(); + const cron = snapshot.jobs; res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ cron }, null, 2)); + res.end(JSON.stringify({ cron, cronStatus: snapshot.status }, null, 2)); } else if (pathname === "/api/operators") { const method = req.method; const data = loadOperators(DATA_DIR); diff --git a/public/index.html b/public/index.html index 156959c3..9ab2c175 100644 --- a/public/index.html +++ b/public/index.html @@ -2218,7 +2218,7 @@