diff --git a/src/support/dashboardHtml.test.ts b/src/support/dashboardHtml.test.ts index ac87aa7c..e50912a0 100644 --- a/src/support/dashboardHtml.test.ts +++ b/src/support/dashboardHtml.test.ts @@ -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(''); }); diff --git a/src/support/dashboardHtml.ts b/src/support/dashboardHtml.ts index b475cf8d..d0f6eed2 100644 --- a/src/support/dashboardHtml.ts +++ b/src/support/dashboardHtml.ts @@ -43,12 +43,13 @@ const DASHBOARD_HTML = ` - + @@ -326,11 +327,11 @@ const DASHBOARD_HTML = ` 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": @@ -530,35 +531,114 @@ const DASHBOARD_HTML = ` 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", @@ -582,10 +662,39 @@ const DASHBOARD_HTML = ` 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; @@ -597,6 +706,7 @@ const DASHBOARD_HTML = ` }); 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 }); diff --git a/src/support/web.heartbeat.test.ts b/src/support/web.heartbeat.test.ts new file mode 100644 index 00000000..0130310d --- /dev/null +++ b/src/support/web.heartbeat.test.ts @@ -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 { + 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' }); + }); +}); diff --git a/src/support/web.ts b/src/support/web.ts index e15dec8b..3b630006 100644 --- a/src/support/web.ts +++ b/src/support/web.ts @@ -11,6 +11,7 @@ import { handleUsageRoute } from './usageRoute.js'; 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'; @@ -74,19 +75,6 @@ let gitStatusPoller: NodeJS.Timeout | null = null; -// `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'; -} @@ -692,10 +680,18 @@ export async function startWebServer(port: number = 3847): Promise { // ---- 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') { @@ -935,50 +931,9 @@ export async function startWebServer(port: number = 3847): Promise { 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((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((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((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') { diff --git a/src/support/webServiceControl.ts b/src/support/webServiceControl.ts new file mode 100644 index 00000000..5ba38b92 --- /dev/null +++ b/src/support/webServiceControl.ts @@ -0,0 +1,60 @@ +// Service-control routes for the dashboard: /api/service/{status,stop,restart}. +// Split out of web.ts, which sits at the repo's per-file line cap. +import { execFile } from 'node:child_process'; +import type { ServerResponse } from 'node:http'; + +// `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. (AGT-3408) +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. +export function safeErrorMessage(err: unknown): string { + if (err instanceof Error && typeof err.message === 'string' && err.message) { + return err.message; + } + return 'Internal error'; +} + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function systemctl(action: 'stop' | 'restart'): Promise { + return new Promise((resolve, reject) => { + execFile('systemctl', ['--user', action, 'openswarm'], { timeout: SYSTEMCTL_TIMEOUT_MS }, (err) => { + if (err) reject(err); else resolve(); + }); + }); +} + +/** + * Serve one service-control route. Returns false when `url`/`method` is not one + * of them, so the caller's router can keep looking. + */ +export async function handleServiceControl(url: string, method: string | undefined, res: ServerResponse): Promise { + if (url === '/api/service/status' && method === 'GET') { + try { + const status = await new Promise((resolve) => { + execFile('systemctl', ['--user', 'is-active', 'openswarm'], { timeout: SYSTEMCTL_TIMEOUT_MS }, (err, stdout) => { + resolve(err ? 'unknown' : stdout.trim()); + }); + }); + sendJson(res, 200, { status }); + } catch { + sendJson(res, 500, { status: 'unknown' }); + } + return true; + } + const action = url === '/api/service/stop' ? 'stop' : url === '/api/service/restart' ? 'restart' : null; + if (!action || method !== 'POST') return false; + try { + await systemctl(action); + sendJson(res, 200, { ok: true }); + } catch (e) { + sendJson(res, 500, { error: safeErrorMessage(e) }); + } + return true; +} diff --git a/tests/web/chatView.test.ts b/tests/web/chatView.test.ts index adf791a8..73476285 100644 --- a/tests/web/chatView.test.ts +++ b/tests/web/chatView.test.ts @@ -459,6 +459,75 @@ describe('startChatView composer outcome (AGT-4026)', () => { }); }); +// The composer used to address whoever spoke last, so a message drafted for one +// agent left addressed to another that spoke mid-sentence. The recipient is now +// pinned and moves only when the operator picks someone else. +describe('composer recipient pin', () => { + const worker = boardEvent({ id: 'w', seq: 1, kind: 'advice-request', status: 'open', actor: 'sable', actorName: 'Sable', correlationId: 'c-sable', taskId: 't-sable', taskLabel: 'AX-1' }); + const reviewer = boardEvent({ id: 'r', seq: 2, kind: 'advice-response', status: 'open', actor: 'thistle', actorName: 'Thistle', actorRole: 'reviewer', correlationId: 'c-thistle', taskId: 't-thistle', taskLabel: 'AX-2' }); + + function sseStub() { + const sources: { onmessage: ((m: { data: string }) => void) | null }[] = []; + class FakeSource { onmessage: ((m: { data: string }) => void) | null = null; constructor() { sources.push(this); } close() {} } + return { FakeSource, push: (event: unknown) => sources[0]!.onmessage!({ data: JSON.stringify({ type: 'coordination:event', data: event }) }) }; + } + + it('keeps the pinned agent when someone else speaks later', async () => { + const doc = shell(); + const sse = sseStub(); + const view = startChatView(doc, { fetchImpl: fetchWith([worker]), eventSourceImpl: sse.FakeSource }); + await vi.waitFor(() => expect(doc.querySelectorAll('#room .line')).toHaveLength(1)); + const input = doc.getElementById('composer-text') as HTMLInputElement; + expect(input.placeholder).toBe('Message Sable…'); + + sse.push(reviewer); + await vi.waitFor(() => expect(doc.querySelectorAll('#room .line')).toHaveLength(2)); + expect(input.placeholder).toBe('Message Sable…'); + const select = doc.getElementById('composer-recipient') as HTMLSelectElement; + expect([...select.options].map((o) => o.textContent)).toEqual(['Thistle · reviewer · AX-2', 'Sable · worker · AX-1']); + expect(select.options[select.selectedIndex]?.textContent).toBe('Sable · worker · AX-1'); + view.stop(); + }); + + it('sends to the agent the operator picked', async () => { + const doc = shell(); + const fetchImpl = fetchWith([worker, reviewer]); + const view = startChatView(doc, { fetchImpl, eventSourceImpl: null }); + await vi.waitFor(() => expect(doc.querySelectorAll('#room .line')).toHaveLength(2)); + const select = doc.getElementById('composer-recipient') as HTMLSelectElement; + expect(select.options[select.selectedIndex]?.textContent).toBe('Thistle · reviewer · AX-2'); + + select.value = [...select.options].find((o) => o.textContent!.startsWith('Sable'))!.value; + select.dispatchEvent(new window.Event('change', { bubbles: true })); + const input = doc.getElementById('composer-text') as HTMLInputElement; + expect(input.placeholder).toBe('Message Sable…'); + input.value = 'Rebase first.'; + doc.getElementById('composer')!.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true })); + + await vi.waitFor(() => expect(fetchImpl.mock.calls.some((c) => c[0] === '/api/coordination/message')).toBe(true)); + const [, init] = fetchImpl.mock.calls.find((c) => c[0] === '/api/coordination/message')!; + expect(JSON.parse((init as { body: string }).body)).toMatchObject({ recipient: 'sable', taskId: 't-sable', correlationId: 'c-sable' }); + view.stop(); + }); + + it('warns, without refusing, when the pinned agent has failed', async () => { + const doc = shell(); + const failed = boardEvent({ id: 'f', seq: 3, actor: 'sable', actorName: 'Sable', correlationId: 'c-sable', taskId: 't-sable', status: 'failed', summary: 'Worker execution failed' }); + const view = startChatView(doc, { fetchImpl: fetchWith([worker, failed]), eventSourceImpl: null }); + await vi.waitFor(() => expect(doc.getElementById('composer-target-warning')?.textContent).toBe('Sable failed on this task and may not read this.')); + expect((doc.getElementById('composer-text') as HTMLInputElement).disabled).toBe(false); + view.stop(); + }); + + it('says nothing while the pinned agent is still in an open exchange', async () => { + const doc = shell(); + const view = startChatView(doc, { fetchImpl: fetchWith([worker]), eventSourceImpl: null }); + await vi.waitFor(() => expect(doc.getElementById('composer-recipient')).not.toBeNull()); + expect(doc.getElementById('composer-target-warning')!.textContent).toBe(''); + view.stop(); + }); +}); + describe('answering a parked agent from chat (AGT-4030)', () => { // Ten agents sat on human-question with no way to answer them: the composer // addressed the newest stage exchange, so the daemon filed the reply as a diff --git a/tests/web/supervisorActions.test.ts b/tests/web/supervisorActions.test.ts new file mode 100644 index 00000000..bc8fb374 --- /dev/null +++ b/tests/web/supervisorActions.test.ts @@ -0,0 +1,182 @@ +// The Supervisor page is a server-rendered string with one inline script, so a +// source-shape assertion cannot tell whether a failed Stop still reads as sent. +// Run the real script in jsdom with a scripted fetch and a hand-driven clock. + +import { describe, expect, it } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { buildDashboardHtml } from '../../src/support/dashboardHtml.js'; + +type Responder = (url: string, init?: RequestInit) => { status: number; body?: unknown } | undefined; + +interface Page { + window: JSDOM['window']; + doc: Document; + calls: { url: string; init?: RequestInit }[]; + timers: { delay: number; fn: () => void }[]; + emit: (event: unknown) => void; + settle: () => Promise; + fire: (delay: number) => void; +} + +function load(respond: Responder = () => undefined): Page { + const calls: Page['calls'] = []; + const timers: Page['timers'] = []; + const sources: { onmessage: ((m: { data: string }) => void) | null }[] = []; + const dom = new JSDOM(buildDashboardHtml(['codex', 'openrouter']), { + url: 'http://127.0.0.1:3847/', + runScripts: 'dangerously', + beforeParse(window) { + const w = window as unknown as Record; + w.fetch = async (url: string, init?: RequestInit) => { + calls.push({ url, init }); + const answer = respond(url, init) ?? { status: 200, body: ['/api/chat/history', '/api/logs', '/api/stages'].includes(url) ? [] : {} }; + return { + ok: answer.status >= 200 && answer.status < 300, + status: answer.status, + json: async () => answer.body ?? {}, + text: async () => JSON.stringify(answer.body ?? {}), + }; + }; + w.EventSource = class { onmessage = null; onerror = null; onopen = null; constructor() { sources.push(this as never); } close() {} addEventListener() {} }; + // A hand-driven clock: every timer is recorded and fired on demand. + w.setTimeout = (fn: () => void, delay = 0) => { timers.push({ delay, fn }); return timers.length; }; + w.clearTimeout = (id: number) => { if (timers[id - 1]) timers[id - 1].fn = () => {}; }; + w.setInterval = () => 0; + }, + }); + const settle = async () => { for (let i = 0; i < 10; i += 1) await new Promise((r) => setImmediate(r)); }; + return { + window: dom.window, + doc: dom.window.document, + calls, + timers, + emit: (event) => sources.forEach((s) => s.onmessage?.({ data: JSON.stringify(event) })), + settle, + fire: (delay) => timers.filter((t) => t.delay === delay).forEach((t) => { const fn = t.fn; t.fn = () => {}; fn(); }), + }; +} + +const call = (page: Page, name: string, ...args: unknown[]) => + (page.window as unknown as Record unknown>)[name](...args); +const posts = (page: Page, path: string) => page.calls.filter((c) => c.url === path && c.init?.method === 'POST'); + +describe('Supervisor service actions report the daemon\'s answer', () => { + it('says Stop failed, with the reason, when the daemon refuses', async () => { + const page = load((url) => (url === '/api/service/stop' ? { status: 500, body: { error: 'systemctl: not found' } } : undefined)); + await page.settle(); + const btn = page.doc.getElementById('svc-stop-btn')!; + call(page, 'svcAction', 'stop'); + expect(btn.textContent).toBe('Stop the daemon?'); + await call(page, 'svcAction', 'stop'); + expect(posts(page, '/api/service/stop')).toHaveLength(1); + expect(btn.textContent).toBe('Stop failed'); + expect(btn.classList.contains('is-failed')).toBe(true); + expect(btn.getAttribute('title')).toBe('systemctl: not found'); + expect(page.doc.body.textContent).toContain('Service stop failed: systemctl: not found'); + page.fire(5000); + expect(btn.textContent).toBe('⏸ Stop'); + }); + + it('says the request was sent when the daemon accepts', async () => { + const page = load(); + await page.settle(); + const btn = page.doc.getElementById('svc-restart-btn')!; + call(page, 'svcAction', 'restart'); + await call(page, 'svcAction', 'restart'); + expect(btn.textContent).toBe('Restart sent'); + expect(btn.classList.contains('is-failed')).toBe(false); + }); +}); + +describe('Supervisor heartbeat button cannot hang', () => { + it('reports a refused trigger instead of spinning', async () => { + const page = load((url) => (url === '/api/heartbeat' ? { status: 503, body: { error: 'No daemon runner is attached to this dashboard' } } : undefined)); + await page.settle(); + await call(page, 'triggerHeartbeat'); + const btn = page.doc.getElementById('hb-btn') as HTMLButtonElement; + expect(btn.textContent).toBe('Heartbeat failed'); + expect(btn.disabled).toBe(false); + expect(btn.getAttribute('title')).toContain('No daemon runner'); + }); + + it('gives up waiting for the start signal and says so', async () => { + const page = load((url) => (url === '/api/heartbeat' ? { status: 202, body: { ok: true } } : undefined)); + await page.settle(); + await call(page, 'triggerHeartbeat'); + const btn = page.doc.getElementById('hb-btn') as HTMLButtonElement; + expect(btn.textContent).toBe('⟳ Running…'); + expect(btn.disabled).toBe(true); + page.fire(15000); + expect(btn.textContent).toBe('No response'); + expect(btn.disabled).toBe(false); + }); + + it('returns to idle when the heartbeat announces itself', async () => { + const page = load((url) => (url === '/api/heartbeat' ? { status: 202, body: { ok: true } } : undefined)); + await page.settle(); + await call(page, 'triggerHeartbeat'); + page.emit({ type: 'heartbeat' }); + const btn = page.doc.getElementById('hb-btn') as HTMLButtonElement; + expect(btn.textContent).toBe('▶ Heartbeat'); + expect(btn.disabled).toBe(false); + page.fire(15000); + expect(btn.textContent).toBe('▶ Heartbeat'); + }); +}); + +describe('Supervisor fleet-wide switches take two presses', () => { + it('arms a provider on the first press and switches on the second', async () => { + const page = load(); + await page.settle(); + const btn = page.doc.getElementById('provider-openrouter')!; + call(page, 'switchProvider', 'openrouter'); + expect(btn.textContent).toBe('Switch fleet?'); + expect(posts(page, '/api/provider')).toHaveLength(0); + call(page, 'switchProvider', 'openrouter'); + await page.settle(); + expect(posts(page, '/api/provider')).toHaveLength(1); + expect(JSON.parse(posts(page, '/api/provider')[0]!.init!.body as string)).toEqual({ provider: 'openrouter' }); + expect(btn.textContent).toBe('OpenRouter'); + }); + + it('keeps only one provider armed at a time', async () => { + const page = load(); + await page.settle(); + call(page, 'switchProvider', 'openrouter'); + call(page, 'switchProvider', 'codex'); + expect(page.doc.getElementById('provider-openrouter')!.textContent).toBe('OpenRouter'); + expect(page.doc.getElementById('provider-codex')!.textContent).toBe('Switch fleet?'); + expect(posts(page, '/api/provider')).toHaveLength(0); + }); + + it('stages a thinking level and applies it only on confirm', async () => { + const page = load((url) => (url === '/api/reasoning-effort' ? { status: 200, body: { effort: 'high' } } : undefined)); + await page.settle(); + const select = page.doc.getElementById('reasoning-effort') as HTMLSelectElement; + const confirm = page.doc.getElementById('effort-confirm') as HTMLButtonElement; + select.value = 'high'; + call(page, 'stageReasoningEffort', 'high'); + expect(confirm.hidden).toBe(false); + expect(confirm.textContent).toBe('Set fleet to High?'); + expect(posts(page, '/api/reasoning-effort')).toHaveLength(0); + call(page, 'confirmReasoningEffort'); + await page.settle(); + expect(JSON.parse(posts(page, '/api/reasoning-effort')[0]!.init!.body as string)).toEqual({ effort: 'high' }); + }); + + it('lets an unconfirmed thinking level lapse back to the applied one', async () => { + const page = load((url) => (url === '/api/reasoning-effort' ? { status: 200, body: { effort: 'low' } } : undefined)); + await page.settle(); + // The page reads the applied level in its deferred, second loading pass. + page.fire(3000); + await page.settle(); + const select = page.doc.getElementById('reasoning-effort') as HTMLSelectElement; + expect(select.value).toBe('low'); + select.value = 'high'; + call(page, 'stageReasoningEffort', 'high'); + page.fire(6000); + expect(select.value).toBe('low'); + expect((page.doc.getElementById('effort-confirm') as HTMLButtonElement).hidden).toBe(true); + expect(posts(page, '/api/reasoning-effort')).toHaveLength(0); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index b6244e25..347f7a39 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -106,6 +106,10 @@ const integrationBoundaryCoverageExcludes = [ // already outside the measured set when it lived in web.ts. Moving code // between files must not change what the threshold is computed over. 'src/support/webExecTasks.ts', + // Same reason: the /api/service/* routes moved out of web.ts so the + // dashboard harden change fit under the size gate. They shell out to + // systemctl, and were outside the measured set while they lived in web.ts. + 'src/support/webServiceControl.ts', 'src/support/dev.ts', 'src/support/chatBackend.ts', 'src/support/gitStatus.ts', diff --git a/web/static/css/chat.css b/web/static/css/chat.css index bbba042f..bb3c3c2a 100644 --- a/web/static/css/chat.css +++ b/web/static/css/chat.css @@ -189,6 +189,13 @@ a.issue-link:hover .issue-title { color: var(--fg-primary); text-decoration: und line-height: 1.5; } .composer-status { min-height: 18px; } +/* Recipient chip: the pinned agent is chosen, never inferred from the newest speaker. */ +.composer-target { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); } +.composer-target[hidden] { display: none; } +.composer-target-label { color: var(--fg-muted); font-size: var(--text-sm); } +.composer-recipient { width: auto; max-width: min(100%, 420px); min-height: 32px; } +.composer-target-warning { color: var(--warning); font-size: var(--text-sm); } +.composer-target-warning:empty { display: none; } body.dropping .room { outline: 2px dashed var(--accent); outline-offset: -6px; } diff --git a/web/static/css/supervisor.css b/web/static/css/supervisor.css index 47e0ca54..b173e53c 100644 --- a/web/static/css/supervisor.css +++ b/web/static/css/supervisor.css @@ -66,6 +66,13 @@ body.page-supervisor { .control-spacer { flex: 1; } .control-label { font-size: var(--text-xs); color: var(--fg-muted); } .effort-select { min-height: 28px; width: auto; padding: 0 var(--space-6) 0 var(--space-2); font-size: var(--text-xs); } +/* Fleet-wide changes confirm in place: a staged thinking level and an armed + provider read as "pending", not as danger. */ +.effort-confirm { min-height: 28px; color: var(--warning); border-color: var(--warning); background: var(--warning-subtle); } +.effort-confirm[hidden] { display: none; } +.provider-btn.is-armed { color: var(--warning); background: var(--warning-subtle); border-color: transparent; font-weight: 600; } +/* An action's outcome, said on the button for a few seconds. */ +.btn.is-flashing.is-failed { color: var(--danger); border-color: var(--danger); background: var(--danger-subtle); } .svc-status { display: inline-flex; diff --git a/web/static/js/chatView.mjs b/web/static/js/chatView.mjs index b34c45a9..b1e2da18 100644 --- a/web/static/js/chatView.mjs +++ b/web/static/js/chatView.mjs @@ -8,7 +8,7 @@ // (`/api/coordination/history`), live updates from the same SSE channel the // orchestration view uses, with the board snapshot as a polling backstop. -import { buildChatThreads, latestAddressable, openQuestionFor } from './conversationModel.mjs'; +import { addressKeyOf, addressableTargets, buildChatThreads, chatLineOf, openQuestionFor, targetEndedReason } from './conversationModel.mjs'; import { ROLE_COLORS } from './orchestrationView.mjs'; import { autogrow, bindDraft, bindEnterToSubmit, setSendEnabled, setSendingState } from './composer.mjs'; import { createScrollFollow } from './scrollFollow.mjs'; @@ -241,6 +241,10 @@ export function startChatView(doc, { fetchImpl, eventSourceImpl, pollMs = 30_000 }); let sending = false; let addressable = false; + // Who the composer is talking to. Pinned once, then changed only by the + // operator: it used to follow whoever spoke last, so a message drafted for + // one agent could leave addressed to another that spoke mid-sentence. + let pinnedKey = null; // Files the operator has staged but not yet sent. Held until the message goes // out so a refused send does not silently discard them (AGT-4026's rule, // applied to attachments). @@ -331,6 +335,96 @@ export function startChatView(doc, { fetchImpl, eventSourceImpl, pollMs = 30_000 return lines; }; + /** + * The pinned agent's newest utterance. The first agent to become addressable + * is pinned as the default; after that only the operator moves the pin. If + * the pinned agent leaves the retained window it cannot be addressed at all, + * and the newest speaker takes its place. + */ + const currentTarget = (events) => { + const targets = addressableTargets(events); + const pinned = pinnedKey ? targets.find((target) => addressKeyOf(target) === pinnedKey) : null; + if (pinned) return pinned; + pinnedKey = targets[0] ? addressKeyOf(targets[0]) : null; + return targets[0] ?? null; + }; + + const describeTarget = (target) => { + const line = chatLineOf(target); + return [line.speakerName, line.speakerRole, line.taskLabel].filter(Boolean).join(' · '); + }; + + // The recipient chip: a picker over every addressable agent plus a warning + // when the pinned one has stopped. Created on demand like the status line, + // so the shell markup does not have to know about it. + let targetOptionsKey = ''; + const renderTargetControl = (events, target) => { + if (!form) return; + let control = doc.getElementById('composer-target'); + if (!control) { + control = doc.createElement('div'); + control.id = 'composer-target'; + control.className = 'composer-target'; + control.innerHTML = '' + + '' + + ''; + form.insertAdjacentElement('afterbegin', control); + control.querySelector('select').addEventListener('change', (changeEvent) => { + pinnedKey = changeEvent.target.value || null; + updateComposer([...byId.values()]); + }); + } + const select = control.querySelector('select'); + const warning = control.querySelector('.composer-target-warning'); + control.hidden = !target; + const targets = addressableTargets(events); + const optionsKey = targets.map((entry) => `${addressKeyOf(entry)}=${describeTarget(entry)}`).join('\n'); + // Rebuild only when the set changes: replacing the options under an open + // picker would close it on every arriving event. + if (optionsKey !== targetOptionsKey) { + targetOptionsKey = optionsKey; + select.innerHTML = targets + .map((entry) => ``) + .join(''); + } + if (target) select.value = addressKeyOf(target); + select.disabled = sending; + const ended = target ? targetEndedReason(events, target) : null; + const name = target ? (target.actorName || target.actor) : ''; + warning.textContent = ended === 'failed' + ? `${name} failed on this task and may not read this.` + : ended === 'expired' + ? `${name}'s exchange expired; it may not read this.` + : ended === 'delivered' + ? `${name} already delivered its result; it may have stopped listening.` + : ''; + control.classList.toggle('is-ended', !!ended); + }; + + /** Recipient chip, placeholder and enablement — everything that follows the pin. */ + const updateComposer = (events) => { + const target = currentTarget(events); + addressable = !!target; + const question = target + ? openQuestionFor(events, target.actor, { repository: target.repository, taskId: target.taskId }) + : null; + renderTargetControl(events, target); + if (input) { + // A send takes seconds under load (AGT-4027) and agent events keep + // arriving, so a redraw lands mid-flight — it must not re-enable the box + // the send just locked. + input.disabled = !target || sending; + // Say which of the two things the next message will do: unpark an agent + // that is waiting, or just speak into the room. + input.placeholder = !target + ? 'No agent to address yet' + : question + ? `Answer ${target.actorName || target.actor}: ${(question.summary || '').slice(0, 60)}…` + : `Message ${target.actorName || target.actor}…`; + } + syncSendState(); + }; + // One line under the composer carrying the send's outcome. Created on demand // so the shell markup does not have to know about it. const setComposerStatus = (message, { pending = false } = {}) => { @@ -345,8 +439,8 @@ export function startChatView(doc, { fetchImpl, eventSourceImpl, pollMs = 30_000 sending = pending; line.textContent = pending ? 'Sending…' : message; line.classList.toggle('is-error', !pending && !!message); - addressable = !!latestAddressable([...byId.values()]); - if (input) input.disabled = pending || !addressable; + // Re-derive the box, the picker and the placeholder: a send locks all three. + updateComposer([...byId.values()]); setSendingState(button, pending); syncSendState(); }; @@ -376,25 +470,7 @@ export function startChatView(doc, { fetchImpl, eventSourceImpl, pollMs = 30_000 : emptyState(); // The composer can only address an agent that exists; without one the // POST would be unroutable (the API requires repository/taskId/recipient). - const target = latestAddressable(events); - addressable = !!target; - const question = target - ? openQuestionFor(events, target.actor, { repository: target.repository, taskId: target.taskId }) - : null; - if (input) { - // A send takes seconds under load (AGT-4027) and agent events keep - // arriving, so a redraw lands mid-flight — it must not re-enable the box - // the send just locked. - input.disabled = !target || sending; - // Say which of the two things the next message will do: unpark an agent - // that is waiting, or just speak into the room. - input.placeholder = !target - ? 'No agent to address yet' - : question - ? `Answer ${target.actorName || target.actor}: ${(question.summary || '').slice(0, 60)}…` - : `Message ${target.actorName || target.actor}…`; - } - syncSendState(); + updateComposer(events); // One announcement per batch of arrivals, not one per line (§8.5). if (lines.length > renderedLines) follow.announce(lines.length - renderedLines); renderedLines = lines.length; @@ -419,8 +495,7 @@ export function startChatView(doc, { fetchImpl, eventSourceImpl, pollMs = 30_000 return false; }; - // Interjections join the newest exchange: the message is addressed to the - // last agent that spoke, exactly like replying in a busy room. Same POST + // Interjections go to the pinned agent, on its newest exchange for that task. Same POST // contract (and same bare-fetch auth posture) as the orchestration composer. // Returns the failure reason, or null when the message was accepted. The // caller keeps the operator's text until it hears null: a refusal that @@ -429,7 +504,7 @@ export function startChatView(doc, { fetchImpl, eventSourceImpl, pollMs = 30_000 // "already answered" both arrive that way (AGT-4026). const send = async (text) => { const events = [...byId.values()]; - const target = latestAddressable(events); + const target = currentTarget(events); if (!target) return 'No agent is addressable yet.'; // Answering beats chatting: if this agent is parked on a question, the // message has to ride that exchange or the daemon files it as a note and diff --git a/web/static/js/conversationModel.mjs b/web/static/js/conversationModel.mjs index 793794e7..eaa6e53d 100644 --- a/web/static/js/conversationModel.mjs +++ b/web/static/js/conversationModel.mjs @@ -245,3 +245,51 @@ export function latestAddressable(events) { } return null; } + +/** + * The identity the composer pins to. An address alone is not unique (agents + * name themselves, so two on different tasks can both be "sable"), so the + * repository and task are part of who the operator is talking to. + */ +export function addressKeyOf(event) { + // JSON, not a control-character join: the key round-trips through an + //