From 07b9be5197141e1a13737ce2d519b4d5f329c510 Mon Sep 17 00:00:00 2001 From: "jonts.ai" <324354193+jonts-ai@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:48:51 -0700 Subject: [PATCH] fix: report dashboard capabilities and migrated cron state Co-authored-by: Jonathan Tsai --- README.md | 5 ++ lib/server.js | 158 ++++++++++++++++++++++++-------------- public/index.html | 30 ++++++-- public/jobs.html | 33 +++++++- public/js/sidebar.js | 14 +++- public/locales/en.json | 4 + public/locales/zh-CN.json | 2 + src/cron.js | 144 +++++++++++++++++++++------------- src/index.js | 14 ++-- src/jobs.js | 11 ++- src/state.js | 2 + tests/cron-host.test.js | 75 ++++++++++++++++++ tests/jobs.test.js | 12 +-- 13 files changed, 371 insertions(+), 133 deletions(-) create mode 100644 tests/cron-host.test.js 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 @@

🔒 Privacy Settings

if (state.capacity) renderCapacity(state.capacity); if (state.operators) renderOperators(state.operators); if (state.llmUsage) renderLlmUsage(state.llmUsage); - if (state.cron) renderCron(state.cron); + if (state.cron) renderCron(state.cron, state.cronStatus); if (state.memory) renderMemory(state.memory); if (state.cerebro) renderCerebro(state.cerebro); if (state.subagents) renderSubagents(state.subagents); @@ -2269,7 +2269,7 @@

🔒 Privacy Settings

if (sessions?.tokenStats) renderTokenStats(sessions.tokenStats); if (operators) renderOperators(operators); if (llm) renderLlmUsage(llm); - if (cron?.cron) renderCron(cron.cron); + if (cron?.cron) renderCron(cron.cron, cron.cronStatus); if (memory?.memory) renderMemory(memory.memory); if (cerebro) renderCerebro(cerebro); if (subagents?.subagents) renderSubagents(subagents.subagents); @@ -2475,7 +2475,21 @@

🔒 Privacy Settings

} // Render just cron jobs (for progressive loading) - function renderCron(cron) { + function renderCron( + cron, + status = document.getElementById("cron-source-status")?.dataset.status || "available", + ) { + const notice = document.getElementById("cron-source-status") || document.createElement("p"); + notice.id = "cron-source-status"; + notice.dataset.status = status; + notice.setAttribute("data-i18n", "cron.sourceUnavailable"); + notice.textContent = (window.I18N?.t || ((_key, _params, fallback) => fallback))( + "cron.sourceUnavailable", + {}, + "Scheduled-job data is loading, incomplete, or stale. Counts are not authoritative.", + ); + notice.hidden = status === "available"; + document.getElementById("cron-jobs").before(notice); const cronEl = document.getElementById("cron-jobs"); cronData = cron; // Store for re-rendering after privacy changes @@ -2491,7 +2505,11 @@

🔒 Privacy Settings

$set("cron-count", "textContent", cronCountText); $set("nav-cron-count", "textContent", cron.length); - if (visibleCrons.length === 0) { + if (visibleCrons.length === 0 && status !== "available") { + cronEl.replaceChildren(); + $set("cron-count", "textContent", "—"); + $set("nav-cron-count", "textContent", "—"); + } else if (visibleCrons.length === 0) { smartUpdate( cronEl, ` @@ -2514,7 +2532,7 @@

🔒 Privacy Settings

⏰
${escapeHtml(c.name)}
-
${c.schedule}
+
${escapeHtml(c.schedule)}
${humanSchedule}
@@ -3273,7 +3291,7 @@

🔒 Privacy Settings

// Use the split render functions (avoids code duplication) if (data.vitals) renderVitals(data.vitals); if (data.sessions) renderSessions(data.sessions, data.pagination, data.statusCounts); - if (data.cron) renderCron(data.cron); + if (data.cron) renderCron(data.cron, data.cronStatus); if (data.tokenStats) { renderTokenStats(data.tokenStats); // Also update the detailed stats diff --git a/public/jobs.html b/public/jobs.html index 60b35afd..19e3ccc8 100644 --- a/public/jobs.html +++ b/public/jobs.html @@ -935,6 +935,9 @@

🤖 AI Jobs Dashboard

+
@@ -1185,6 +1188,7 @@

🤖 AI Jobs Dashboard

// Set filter function setFilter(filter) { + if (!jobsAvailable) return; currentFilter = filter; document.querySelectorAll(".filter-btn").forEach((btn) => { btn.classList.toggle("active", btn.dataset.filter === filter); @@ -1192,12 +1196,38 @@

🤖 AI Jobs Dashboard

renderJobs(); } + let jobsAvailable = false; + function showJobsUnavailable() { + jobsAvailable = false; + jobs = []; + document.getElementById("jobs-unavailable").hidden = false; + for (const id of ["jobs-loading", "jobs-empty", "jobs-grid"]) + document.getElementById(id).style.display = "none"; + for (const id of [ + "stat-total", + "stat-active", + "stat-paused", + "stat-running", + "stat-success-rate", + "stat-failures", + "nav-jobs-count", + ]) { + const element = document.getElementById(id); + if (element) element.textContent = "—"; + } + } // Fetch jobs from API async function fetchJobs() { try { const res = await fetch("api/jobs"); const data = await res.json(); - jobs = data.jobs || []; + if (!res.ok || data.status === "unavailable" || !Array.isArray(data.jobs)) { + showJobsUnavailable(); + return; + } + jobsAvailable = true; + document.getElementById("jobs-unavailable").hidden = true; + jobs = data.jobs; renderJobs(); updateStats(); document.getElementById("sidebar-updated").textContent = t( @@ -1206,6 +1236,7 @@

🤖 AI Jobs Dashboard

"Updated: " + new Date().toLocaleTimeString(), ); } catch (e) { + showJobsUnavailable(); console.error("Failed to fetch jobs:", e); showToast(t("jobs.toastLoadFailed", {}, "Failed to load jobs"), "error"); } diff --git a/public/js/sidebar.js b/public/js/sidebar.js index 3939632f..38a3cd0e 100644 --- a/public/js/sidebar.js +++ b/public/js/sidebar.js @@ -210,7 +210,12 @@ // Update cron count if (data.cron) { - sidebarState.cron = Array.isArray(data.cron) ? data.cron.length : 0; + sidebarState.cron = + data.cronStatus && data.cronStatus !== "available" + ? "—" + : Array.isArray(data.cron) + ? data.cron.length + : "—"; } // Update jobs count (from jobs API if available) @@ -225,7 +230,8 @@ // Update cerebro count if (data.cerebro) { - sidebarState.cerebro = data.cerebro.topicCount || data.cerebro.totalTopics || 0; + sidebarState.cerebro = + data.cerebro.topics?.total ?? data.cerebro.topicCount ?? data.cerebro.totalTopics ?? "—"; } // Update intel count @@ -357,10 +363,12 @@ try { const response = await fetch("api/jobs"); const data = await response.json(); - sidebarState.jobs = data.jobs?.length || 0; + sidebarState.jobs = response.ok && Array.isArray(data.jobs) ? data.jobs.length : "—"; updateBadges(); } catch (error) { // Jobs API may not be available + sidebarState.jobs = "—"; + updateBadges(); } } diff --git a/public/locales/en.json b/public/locales/en.json index d8e4e607..5af2d4c7 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -1,4 +1,7 @@ { + "cron": { + "sourceUnavailable": "Scheduled-job data is loading, incomplete, or stale. Counts are not authoritative." + }, "app": { "title": "OpenClaw Command Center", "connecting": "Connecting...", @@ -64,6 +67,7 @@ "polling": "Polling mode (SSE disconnected)" }, "jobs": { + "providerUnavailable": "AI Jobs is not connected. Scheduled OpenClaw automations are shown under Cron Jobs.", "pageTitle": "AI Jobs - OpenClaw Command Center", "dashboard": "🤖 AI Jobs Dashboard", "refresh": "🔄 Refresh", diff --git a/public/locales/zh-CN.json b/public/locales/zh-CN.json index 83bc43f8..a1686f24 100644 --- a/public/locales/zh-CN.json +++ b/public/locales/zh-CN.json @@ -1,4 +1,5 @@ { + "cron": { "sourceUnavailable": "定时任务数据正在加载、不完整或已过期;计数尚未确认。" }, "app": { "title": "OpenClaw 指挥中心", "connecting": "连接中...", @@ -493,6 +494,7 @@ ] }, "jobs": { + "providerUnavailable": "AI 任务尚未连接。OpenClaw 自动化任务请查看“定时任务”。", "pageTitle": "AI 任务 - OpenClaw 指挥中心", "dashboard": "🤖 AI 任务面板", "refresh": "🔄 刷新", diff --git a/src/cron.js b/src/cron.js index 61fbc8bb..b6540f1b 100644 --- a/src/cron.js +++ b/src/cron.js @@ -1,4 +1,5 @@ const fs = require("fs"); +const { stripVTControlCharacters } = require("node:util"); const path = require("path"); // Convert cron expression to human-readable text @@ -98,62 +99,101 @@ function cronToHuman(expr) { return expr; // Return original as fallback } -// Get cron jobs - reads directly from file for speed (CLI takes 11s+) +// Normalize only display fields; never return automation prompts or delivery config. +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 = "—", + 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 / 1000}s`; + } + const nextMs = j.nextRunAtMs ?? j.state?.nextRunAtMs; + let nextRun = "—"; + if (Number.isFinite(nextMs)) { + const mins = Math.round((nextMs - Date.now()) / 60000); + 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, + }; + }); +} + +// Legacy helper retained for callers that explicitly use a JSON store. function getCronJobs(getOpenClawDir) { try { - const cronPath = path.join(getOpenClawDir(), "cron", "jobs.json"); - if (fs.existsSync(cronPath)) { - const data = JSON.parse(fs.readFileSync(cronPath, "utf8")); - return (data.jobs || []).map((j) => { - // Parse schedule - let scheduleStr = "—"; - 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"; - } - } + const file = path.join(getOpenClawDir(), "cron", "jobs.json"); + if (fs.existsSync(file)) + return normalizeJobs(JSON.parse(fs.readFileSync(file, "utf8")).jobs || []); + } catch { + /* unavailable, not an authoritative empty catalog */ + } + return []; +} - // Format next run - let nextRunStr = "—"; - if (j.state?.nextRunAtMs) { - const next = new Date(j.state.nextRunAtMs); - const now = new Date(); - const diffMs = next - now; - const diffMins = Math.round(diffMs / 60000); - 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`; - } +function createCronHost({ run, now = Date.now, refreshMs = 30000 }) { + 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: 8000 }, + ); + 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 > 1000) + 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 { - id: j.id, - name: j.name || j.id.slice(0, 8), - schedule: scheduleStr, - scheduleHuman: scheduleHuman, - nextRun: nextRunStr, - enabled: j.enabled !== false, - lastStatus: j.state?.lastStatus, - }; + return snapshot; + }) + .finally(() => { + pending = null; + nextCheck = now() + refreshMs; }); - } - } catch (e) { - console.error("Failed to get cron:", e.message); + return pending; } - return []; + function getState() { + if (now() >= nextCheck && !pending) void refresh(); + return snapshot; + } + return { getState, refresh }; } - -module.exports = { - cronToHuman, - getCronJobs, -}; +module.exports = { cronToHuman, getCronJobs, normalizeJobs, createCronHost }; diff --git a/src/index.js b/src/index.js index 221727fe..682dd1a0 100644 --- a/src/index.js +++ b/src/index.js @@ -73,7 +73,8 @@ const { calculateOperatorStats, } = require("./operators"); const { createSessionsModule } = require("./sessions"); -const { getCronJobs } = require("./cron"); +const { createCronHost } = require("./cron"); +const cronHost = createCronHost({ run: runOpenClawAsync }); const { getCerebroTopics, updateTopicStatus } = require("./cerebro"); const { getDailyTokenUsage, @@ -168,7 +169,8 @@ const 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), @@ -260,7 +262,8 @@ function handleApi(req, res) { const data = { sessions: sessionsList, - cron: getCronJobs(getOpenClawDir), + cron: cronHost.getState().jobs, + cronStatus: cronHost.getState().status, system: state.getSystemStatus(), activity: state.getRecentActivity(), tokenStats, @@ -535,9 +538,10 @@ const 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/src/jobs.js b/src/jobs.js index eabec871..ef84ac72 100644 --- a/src/jobs.js +++ b/src/jobs.js @@ -75,8 +75,15 @@ async function handleJobsRequest(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; } diff --git a/src/state.js b/src/state.js index cdf015dc..c0389886 100644 --- a/src/state.js +++ b/src/state.js @@ -36,6 +36,7 @@ function createStateModule(deps) { getSessions, getSystemVitals, getCronJobs, + getCronStatus = () => "available", loadOperators, calculateOperatorStats, getLlmUsage, @@ -523,6 +524,7 @@ function createStateModule(deps) { operators, llmUsage, cron, + cronStatus: getCronStatus(), memory, cerebro, subagents, diff --git a/tests/cron-host.test.js b/tests/cron-host.test.js new file mode 100644 index 00000000..75cc871e --- /dev/null +++ b/tests/cron-host.test.js @@ -0,0 +1,75 @@ +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { createCronHost } = require("../src/cron"); +test("modern cron catalog is cached/coalesced and only display fields leave the adapter", async () => { + let calls = 0, + tick = 1000; + const host = createCronHost({ + now: () => tick, + run: async (args) => { + calls++; + assert.deepEqual(args.slice(0, 6), ["cron", "list", "--agent", "main", "--all", "--json"]); + return JSON.stringify({ + jobs: [ + { + id: "sample", + name: "Example", + payload: { message: "private prompt" }, + nextRunAtMs: Date.now() + 60000, + schedule: { kind: "every", everyMs: 60000 }, + }, + ], + total: 1, + }); + }, + }); + assert.equal(host.getState().status, "loading"); + await Promise.all([host.refresh(), host.refresh()]); + assert.equal(calls, 1); + assert.equal(host.getState().status, "available"); + assert.equal(host.getState().jobs[0].schedule, "every 60s"); + assert.ok(!JSON.stringify(host.getState()).includes("private prompt")); + tick += 100; + host.getState(); + assert.equal(calls, 1); +}); +test("failures preserve old observation but show stale, not authoritative zero", async () => { + let fail = false; + const host = createCronHost({ + run: async () => (fail ? null : JSON.stringify({ jobs: [{ id: "one" }], hasMore: true })), + }); + await host.refresh(); + assert.equal(host.getState().status, "partial"); + const observed = host.getState().observedAt; + fail = true; + await host.refresh(); + assert.equal(host.getState().status, "stale"); + assert.equal(host.getState().jobs.length, 1); + assert.equal(host.getState().observedAt, observed); + const empty = createCronHost({ run: async () => null }); + await empty.refresh(); + assert.equal(empty.getState().status, "unavailable"); +}); +test("malformed catalog is unavailable; confirmed empty catalog is available", async () => { + for (const value of [{}, { jobs: [{}] }, { jobs: "bad" }]) { + const h = createCronHost({ run: async () => JSON.stringify(value) }); + await h.refresh(); + assert.equal(h.getState().status, "unavailable"); + } + const h = createCronHost({ run: async () => JSON.stringify({ jobs: [], total: 0 }) }); + await h.refresh(); + assert.equal(h.getState().status, "available"); + assert.deepEqual(h.getState().jobs, []); +}); +test("automation names and schedules remain data, and pagination stays partial", async () => { + const h = createCronHost({ + run: async () => + JSON.stringify({ + jobs: [{ id: "one", name: "", schedule: { kind: "cron", expr: "" } }], + total: 2, + }), + }); + await h.refresh(); + assert.equal(h.getState().status, "partial"); + assert.equal(h.getState().jobs[0].name, ""); +}); diff --git a/tests/jobs.test.js b/tests/jobs.test.js index f291a2fb..27d1916e 100644 --- a/tests/jobs.test.js +++ b/tests/jobs.test.js @@ -65,7 +65,7 @@ describe("jobs module", () => { _resetForTesting(); }); - it("returns 500 when jobs API is not available", async () => { + it("reports an unavailable capability when optional jobs API is absent", async () => { // Force API to be unavailable for this test _resetForTesting({ forceUnavailable: true }); @@ -86,13 +86,13 @@ describe("jobs module", () => { await handleJobsRequest(mockReq, mockRes, "/api/jobs", query, "GET"); - assert.strictEqual(statusCode, 500); + assert.strictEqual(statusCode, 200); const parsed = JSON.parse(body); assert.ok(parsed.error, "should have an error message"); - assert.ok( - parsed.error.includes("not available"), - `Error should mention not available: ${parsed.error}`, - ); + assert.equal(parsed.status, "unavailable"); + assert.equal(parsed.jobs, null); + await handleJobsRequest(mockReq, mockRes, "/api/jobs/example/run", query, "POST"); + assert.strictEqual(statusCode, 503); }); }); });