Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/support/dashboardHtml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ describe('buildDashboardHtml (INT-3284)', () => {
it('renders a persistent fleet-wide thinking effort control', () => {
const html = buildDashboardHtml(['openrouter']);
expect(html).toContain('id="reasoning-effort"');
expect(html).toContain('setReasoningEffort(this.value)');
// Picking a level stages it; a confirm button beside the select applies it
// fleet-wide (behaviour pinned in tests/web/supervisorActions.test.ts).
expect(html).toContain('stageReasoningEffort(this.value)');
expect(html).toContain('id="effort-confirm"');
expect(html).toContain('/api/reasoning-effort');
expect(html).toContain('<option value="high">High</option>');
});
Expand Down
136 changes: 123 additions & 13 deletions src/support/dashboardHtml.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// Auto-generated: Dashboard HTML template for OpenSwarm Supervisor
const DASHBOARD_HTML = `<!DOCTYPE html>
<html lang="en">
Expand Down Expand Up @@ -43,12 +43,13 @@
<!--PROVIDER_BUTTONS-->
</div>
<label class="control-label" for="reasoning-effort">Thinking</label>
<select class="input effort-select" id="reasoning-effort" aria-label="Fleet-wide thinking effort" onchange="setReasoningEffort(this.value)">
<select class="input effort-select" id="reasoning-effort" aria-label="Fleet-wide thinking effort" onchange="stageReasoningEffort(this.value)">
<option value="">Default</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<button class="btn btn-sm effort-confirm" id="effort-confirm" type="button" hidden onclick="confirmReasoningEffort()"></button>
<button class="btn btn-danger btn-sm" id="svc-stop-btn" type="button" onclick="svcAction('stop')">⏸ Stop</button>
<button class="btn btn-secondary btn-sm" id="svc-restart-btn" type="button" onclick="svcAction('restart')">↻ Restart</button>
</div>
Expand Down Expand Up @@ -326,11 +327,11 @@
fetchProcesses();
addLogLine({ taskId: "system", stage: "exit", line: "Process exited PID=" + ev.data.pid + " code=" + ev.data.exitCode + " duration=" + (ev.data.durationMs / 1000).toFixed(1) + "s" });
break;
case "heartbeat": {
const btn = document.getElementById("hb-btn");
btn.disabled = false; btn.textContent = "▶ Heartbeat";
case "heartbeat":
// Fired when a heartbeat starts, scheduled or manual: the start
// signal a manual trigger is waiting for.
resetHeartbeatButton();
break;
}
case "pr_processor_start":
case "pr_processor_end":
case "pr_processor_pr":
Expand Down Expand Up @@ -530,35 +531,114 @@
return true;
}

// The outcome of an action, said on the button that was pressed. The live
// log also records it, but on a phone the log is in another tab, and a
// button that just returns to its label reads as success.
function flashButton(btn, label, failed, restLabel, reason) {
clearTimeout(btn._flashTimer);
btn.textContent = label;
btn.classList.add("is-flashing");
btn.classList.toggle("is-failed", !!failed);
if (reason) btn.title = reason;
btn._flashTimer = setTimeout(function() {
btn.textContent = restLabel;
btn.classList.remove("is-flashing", "is-failed");
btn.removeAttribute("title");
}, 5000);
}

// fetch() resolves on 4xx/5xx, so an awaited call that did not throw is not
// an accepted one. Returns the server's reason, or null when it accepted.
async function failureOf(res) {
if (res.ok) return null;
try {
const data = await res.json();
if (data && data.error) return data.error;
} catch {}
return "HTTP " + res.status;
}

async function svcAction(action) {
const btnId = action === "stop" ? "svc-stop-btn" : "svc-restart-btn";
const btn = document.getElementById(btnId);
if (armButton(btn, action === "stop" ? "Stop the daemon?" : "Restart the daemon?")) return;
const restLabel = btn.textContent;
const verb = action === "stop" ? "Stop" : "Restart";
btn.disabled = true;
let failure = null;
try {
await fetch("/api/service/" + action, { method: "POST" });
addLogLine({ taskId: "system", stage: "service", line: "Service " + action + " requested" });
failure = await failureOf(await fetch("/api/service/" + action, { method: "POST" }));
} catch(e) {
addLogLine({ taskId: "system", stage: "error", line: "Service " + action + " failed: " + e.message });
failure = e.message;
}
btn.disabled = false;
if (failure) {
addLogLine({ taskId: "system", stage: "error", line: "Service " + action + " failed: " + failure });
flashButton(btn, verb + " failed", true, restLabel, failure);
} else {
addLogLine({ taskId: "system", stage: "service", line: "Service " + action + " requested" });
flashButton(btn, verb + " sent", false, restLabel);
}
setTimeout(fetchSvcStatus, 2000);
}

// ---- Heartbeat trigger ----
// POST /api/heartbeat answers 202 before the run starts; the run announces
// itself with a "heartbeat" SSE event. Without a deadline on that signal a
// run that never started (one already in flight, a dropped stream) left
// the button on "Running…" with nothing to say it was stuck.
const HEARTBEAT_START_TIMEOUT_MS = 15000;
const HEARTBEAT_LABEL = "▶ Heartbeat";
let heartbeatStartTimer = null;

function resetHeartbeatButton() {
clearTimeout(heartbeatStartTimer);
heartbeatStartTimer = null;
const btn = document.getElementById("hb-btn");
if (!btn) return;
btn.disabled = false;
if (!btn.classList.contains("is-flashing")) btn.textContent = HEARTBEAT_LABEL;
}

async function triggerHeartbeat() {
const btn = document.getElementById("hb-btn");
btn.disabled = true; btn.textContent = "⟳ Running…";
addLogLine({ taskId: "system", stage: "manual", line: "Heartbeat triggered by user" });
let failure = null;
try {
await fetch("/api/heartbeat", { method: "POST" });
failure = await failureOf(await fetch("/api/heartbeat", { method: "POST" }));
} catch(e) {
addLogLine({ taskId: "system", stage: "error", line: "Heartbeat failed: " + e.message });
btn.disabled = false; btn.textContent = "▶ Heartbeat";
failure = e.message;
}
if (failure) {
resetHeartbeatButton();
addLogLine({ taskId: "system", stage: "error", line: "Heartbeat failed: " + failure });
flashButton(btn, "Heartbeat failed", true, HEARTBEAT_LABEL, failure);
return;
}
clearTimeout(heartbeatStartTimer);
heartbeatStartTimer = setTimeout(function() {
const reason = "The daemon accepted the request but no heartbeat started within "
+ (HEARTBEAT_START_TIMEOUT_MS / 1000) + "s. One may already be running.";
resetHeartbeatButton();
addLogLine({ taskId: "system", stage: "error", line: "Heartbeat: no response. " + reason });
flashButton(btn, "No response", true, HEARTBEAT_LABEL, reason);
}, HEARTBEAT_START_TIMEOUT_MS);
}

// Switching the default provider re-routes every agent, so it takes the
// same two presses as Stop: the first says what the second will do.
function switchProvider(provider) {
const btn = document.getElementById("provider-" + provider);
if (!btn || btn.classList.contains("active")) return;
document.querySelectorAll(".provider-btn.is-armed").forEach(function(other) {
if (other !== btn) armButton(other);
});
if (armButton(btn, "Switch fleet?")) return;
applyProvider(provider);
}

async function switchProvider(provider) {
async function applyProvider(provider) {
try {
const res = await fetch("/api/provider", {
method: "POST",
Expand All @@ -582,10 +662,39 @@
const res = await fetch("/api/reasoning-effort");
if (!res.ok) return;
const data = await res.json();
document.getElementById("reasoning-effort").value = data.effort || "";
const select = document.getElementById("reasoning-effort");
select.value = data.effort || "";
select.dataset.applied = select.value;
} catch {}
}

// A select cannot be armed like a button, so picking a value stages it and
// a confirm button beside it applies it. Left alone, the stage lapses and
// the select returns to what the fleet is actually using.
let effortStageTimer = null;
function stageReasoningEffort(value) {
const select = document.getElementById("reasoning-effort");
const confirmBtn = document.getElementById("effort-confirm");
clearTimeout(effortStageTimer);
if (value === (select.dataset.applied || "")) {
confirmBtn.hidden = true;
return;
}
const label = select.options[select.selectedIndex].textContent;
confirmBtn.textContent = "Set fleet to " + label + "?";
confirmBtn.hidden = false;
effortStageTimer = setTimeout(function() {
confirmBtn.hidden = true;
select.value = select.dataset.applied || "";
}, 6000);
}

function confirmReasoningEffort() {
clearTimeout(effortStageTimer);
document.getElementById("effort-confirm").hidden = true;
setReasoningEffort(document.getElementById("reasoning-effort").value);
}

async function setReasoningEffort(value) {
const select = document.getElementById("reasoning-effort");
select.disabled = true;
Expand All @@ -597,6 +706,7 @@
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Failed to update thinking effort");
select.dataset.applied = data.effort || "";
addLogLine({ taskId: "system", stage: "reasoning", line: "Thinking effort set to " + (data.effort || "default") });
} catch (e) {
addLogLine({ taskId: "system", stage: "error", line: "Thinking effort update failed: " + e.message });
Expand Down
30 changes: 30 additions & 0 deletions src/support/web.heartbeat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// `openswarm dash` serves the Supervisor with no runner attached. The heartbeat
// route used to answer 202 there, so the button waited for a start signal
// nothing could send. It now refuses with a reason the page can show.
import { afterEach, describe, expect, it } from 'vitest';
import { createServer } from 'node:net';
import { startWebServer, stopWebServer } from './web.js';

async function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const s = createServer();
s.once('error', reject);
s.listen(0, '127.0.0.1', () => {
const addr = s.address();
const port = typeof addr === 'object' && addr ? addr.port : 0;
s.close((err) => (err ? reject(err) : resolve(port)));
});
});
}

describe('POST /api/heartbeat without a runner', () => {
afterEach(async () => { await stopWebServer(); });

it('refuses with 503 and says why', async () => {
const port = await freePort();
await startWebServer(port);
const res = await fetch(`http://127.0.0.1:${port}/api/heartbeat`, { method: 'POST' });
expect(res.status).toBe(503);
expect(await res.json()).toEqual({ error: 'No daemon runner is attached to this dashboard' });
});
});
71 changes: 13 additions & 58 deletions src/support/web.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// ============================================
// OpenSwarm - Web Interface
// ============================================
Expand All @@ -11,6 +11,7 @@
import { atomicWriteFileSync } from './atomicFile.js';
import { safeConsole as console } from './safeLog.js';
import { execFile } from 'node:child_process';
import { handleServiceControl, safeErrorMessage } from './webServiceControl.js';
import { getChatHistory } from '../discord/index.js';
import { addSSEClient, getActiveSSECount, broadcastEvent, getLogBuffer, getStageBuffer, getChatBuffer } from '../core/eventHub.js';
import { formatCost } from './costTracker.js';
Expand Down Expand Up @@ -74,19 +75,6 @@



// `systemctl` talking to a wedged user service manager can hang past any
// caller's patience; bound it so a stuck call surfaces as a controlled error
// instead of leaving the HTTP request open indefinitely.
const SYSTEMCTL_TIMEOUT_MS = 10_000;

// Never leak raw Error objects (which include stack traces in many runtimes)
// or arbitrary thrown values to HTTP responses.
function safeErrorMessage(err: unknown): string {
if (err instanceof Error && typeof err.message === 'string' && err.message) {
return err.message;
}
return 'Internal error';
}



Expand Down Expand Up @@ -692,10 +680,18 @@

// ---- Heartbeat (manual trigger) ----
} else if (url === '/api/heartbeat' && req.method === 'POST') {
// `openswarm dash` serves this page with no runner attached. Answering
// 202 there left the button on "Running…" forever, waiting for a start
// signal nothing could send.
if (!runnerRef) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'No daemon runner is attached to this dashboard' }));
return;
}
res.writeHead(202, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
// Non-blocking heartbeat
runnerRef?.heartbeat().catch((e: Error) => console.error('[Web] Heartbeat error:', e));
runnerRef.heartbeat().catch((e: Error) => console.error('[Web] Heartbeat error:', e));

// ---- Provider toggle ----
} else if (url === '/api/provider' && req.method === 'POST') {
Expand Down Expand Up @@ -935,50 +931,9 @@
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, response, provider: result.provider, model: result.model }));

// ---- Service control: status ----
} else if (url === '/api/service/status' && req.method === 'GET') {
try {
const result = await new Promise<string>((resolve) => {
execFile('systemctl', ['--user', 'is-active', 'openswarm'], { timeout: SYSTEMCTL_TIMEOUT_MS }, (err, stdout) => {
resolve(err ? 'unknown' : stdout.trim());
});
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: result }));
} catch {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'unknown' }));
}

// ---- Service control: stop ----
} else if (url === '/api/service/stop' && req.method === 'POST') {
try {
await new Promise<void>((resolve, reject) => {
execFile('systemctl', ['--user', 'stop', 'openswarm'], { timeout: SYSTEMCTL_TIMEOUT_MS }, (err) => {
if (err) reject(err); else resolve();
});
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
} catch (e) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: safeErrorMessage(e) }));
}

// ---- Service control: restart ----
} else if (url === '/api/service/restart' && req.method === 'POST') {
try {
await new Promise<void>((resolve, reject) => {
execFile('systemctl', ['--user', 'restart', 'openswarm'], { timeout: SYSTEMCTL_TIMEOUT_MS }, (err) => {
if (err) reject(err); else resolve();
});
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
} catch (e) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: safeErrorMessage(e) }));
}
// ---- Service control: status / stop / restart ----
} else if (url.startsWith('/api/service/') && await handleServiceControl(url, req.method, res)) {
// handled

// ---- Knowledge Graph: project health ----
} else if (url.startsWith('/api/knowledge/') && req.method === 'GET') {
Expand Down
Loading
Loading