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
11 changes: 11 additions & 0 deletions .changeset/dashboard-stop-button.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@gemstack/framework': minor
---

Stop a run from the dashboard

The dashboard now has a **Stop** button that interrupts the running build from the browser, instead of only from the terminal. It POSTs to a new `/stop` route that aborts the run's `AbortSignal`, which `runFramework` checks between phases and the driver honours mid-turn (it kills the current agent turn). The run ends cleanly as *stopped* (not *failed*): the CLI prints `■ Stopped` and exits 0, the dashboard shows a stopped status, and the persisted run records `status: "stopped"` so `--resume` shows it that way.

`startDashboard` gains an `onStop` option (wire it to `controller.abort()`); the page hides the button when no stop handler is wired (e.g. a read-only `--resume` view). The `end` event gained an optional `stopped` flag.

Part of #110 (first interactive slice of #165's web client). Closes #218.
5 changes: 4 additions & 1 deletion packages/framework/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ pieces:
driver for `--fake` and tests. Codex / opencode slot in behind the same three
methods.
- **The product shell** - the `framework` CLI and the localhost
[dashboard](./src/dashboard/server.ts) over an event stream we own.
[dashboard](./src/dashboard/server.ts) over an event stream we own. The
dashboard has a **Stop** button that interrupts the run from the browser (it
aborts the same signal Ctrl+C does); the run ends cleanly as *stopped*, and a
persisted run reflects that so `--resume` shows it stopped.

Everything runs *through* the driver (single execution path): the architect is a
small structured JSON decision the agent returns; build and improve are prompts;
Expand Down
25 changes: 23 additions & 2 deletions packages/framework/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,10 +352,18 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
}
: undefined

// One controller for the whole run: the dashboard Stop button aborts it.
// runFramework checks the signal between phases and the driver kills its current
// turn on it, so a stop takes effect promptly.
const controller = new AbortController()

let dashboard: Dashboard | undefined
if (opts.dashboard) {
try {
dashboard = await startDashboard(opts.port !== undefined ? { port: opts.port } : {})
dashboard = await startDashboard({
...(opts.port !== undefined ? { port: opts.port } : {}),
onStop: () => controller.abort(),
})
io.out(`◆ dashboard: ${dashboard.url}`)
} catch (err) {
io.err(`could not start dashboard (${err instanceof Error ? err.message : String(err)}); continuing headless`)
Expand Down Expand Up @@ -400,6 +408,7 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
cwd,
onEvent,
signals,
signal: controller.signal,
...(opts.model ? { model: opts.model } : {}),
...(opts.maxPasses ? { maxPasses: opts.maxPasses } : {}),
...(deploy ? { deploy } : {}),
Expand Down Expand Up @@ -435,8 +444,20 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
}
return 0
} catch (err) {
io.err(`\n✗ run failed: ${err instanceof Error ? err.message : String(err)}`)
await store?.close()
// A user stop (the dashboard Stop button aborted the signal) is not a failure:
// report it cleanly, keep the dashboard up so the stopped state is visible, and
// exit 0.
if (controller.signal.aborted) {
io.out('\n■ Stopped.')
if (dashboard) {
io.out(`\nDashboard still live at ${dashboard.url}. Press Ctrl+C to exit.`)
await waitForInterrupt()
await dashboard.close()
}
return 0
}
io.err(`\n✗ run failed: ${err instanceof Error ? err.message : String(err)}`)
await dashboard?.close()
return 1
}
Expand Down
29 changes: 25 additions & 4 deletions packages/framework/src/dashboard/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* that foreground the orchestration (stack rationale, loop status, decisions)
* beside a tail of the wrapped agent's own activity.
*/
export function dashboardHtml(title: string): string {
export function dashboardHtml(title: string, stoppable = false): string {
return `<!doctype html>
<html lang="en">
<head>
Expand All @@ -26,6 +26,12 @@ export function dashboardHtml(title: string): string {
#session-link { margin-left: auto; font-size: 12px; color: #7b8496; }
#session-link code { color: #b7c0d0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
#status { font-size: 12px; color: #7b8496; }
#stop { margin-left: 12px; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer;
color: #f0a35e; background: #241a15; border: 1px solid #4a3320; border-radius: 6px;
padding: 4px 12px; }
#stop:hover { background: #2f2118; border-color: #6a4a2e; }
#stop:disabled { opacity: .5; cursor: default; }
#stop[hidden] { display: none; }
#app-banner { display: flex; align-items: center; gap: 8px; padding: 10px 20px;
background: #0f2417; border-bottom: 1px solid #1c3a28; font-size: 13px; }
#app-banner .dot { color: #67d98f; }
Expand Down Expand Up @@ -68,6 +74,7 @@ export function dashboardHtml(title: string): string {
<span class="sub" id="session">connecting…</span>
<span id="session-link"></span>
<span id="status">●</span>
<button id="stop" hidden>■ Stop</button>
</header>
<div id="app-banner" hidden>
<span class="dot">▶</span>
Expand Down Expand Up @@ -100,15 +107,17 @@ export function dashboardHtml(title: string): string {
</section>
</main>
<script>
${clientScript()}
${clientScript(stoppable)}
</script>
</body>
</html>`
}

function clientScript(): string {
function clientScript(stoppable: boolean): string {
// Runs in the browser. Keep it dependency-free.
return `
const STOPPABLE = ${stoppable ? 'true' : 'false'};
let ended = false;
const $ = id => document.getElementById(id);
const log = line => {
const el = $('log');
Expand Down Expand Up @@ -201,6 +210,7 @@ function onEvent(fe) {
s += ' in ' + fe.workspace;
$('session').textContent = s;
if (fe.sessionLink) setSessionLink(undefined, fe.sessionLink);
if (STOPPABLE && !ended) $('stop').hidden = false;
} else if (fe.kind === 'session-update') setSessionLink(fe.sessionId, fe.sessionLink);
else if (fe.kind === 'preview') {
const a = $('app-link');
Expand All @@ -211,9 +221,20 @@ function onEvent(fe) {
else if (fe.kind === 'bootstrap') bootstrap(fe.event);
else if (fe.kind === 'driver') driver(fe.event);
else if (fe.kind === 'log') log(fe.message);
else if (fe.kind === 'end') { $('status').textContent = fe.ok ? '\\u25cf finished' : '\\u25cf failed'; }
else if (fe.kind === 'end') {
ended = true;
$('status').textContent = fe.ok ? '\\u25cf finished' : fe.stopped ? '\\u25a0 stopped' : '\\u25cf failed';
$('stop').hidden = true;
}
}
function stopRun() {
const btn = $('stop');
btn.disabled = true;
btn.textContent = 'stopping\\u2026';
fetch('/stop', { method: 'POST' }).catch(() => {});
}
function esc(s) { const d = document.createElement('div'); d.textContent = String(s); return d.innerHTML; }
$('stop').addEventListener('click', stopRun);
const src = new EventSource('/events');
src.onmessage = ev => { try { onEvent(JSON.parse(ev.data)); } catch {} };
src.onerror = () => { $('status').textContent = '\\u25cb offline'; };
Expand Down
48 changes: 47 additions & 1 deletion packages/framework/src/dashboard/server.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { get } from 'node:http'
import { get, request } from 'node:http'
import { startDashboard } from './server.js'
import type { FrameworkEvent } from '../events.js'

Expand All @@ -14,6 +14,18 @@ function fetchText(url: string): Promise<{ status: number; body: string }> {
})
}

function send(url: string, method: string): Promise<{ status: number; body: string }> {
return new Promise((resolvePromise, rejectPromise) => {
const req = request(url, { method }, res => {
let body = ''
res.on('data', c => (body += c))
res.on('end', () => resolvePromise({ status: res.statusCode ?? 0, body }))
})
req.on('error', rejectPromise)
req.end()
})
}

// Read SSE `data:` lines until `count` have arrived, then resolve and disconnect.
function readSse(url: string, count: number): Promise<FrameworkEvent[]> {
return new Promise((resolvePromise, rejectPromise) => {
Expand Down Expand Up @@ -75,3 +87,37 @@ test('dashboard returns 404 for unknown paths', async () => {
await dash.close()
}
})

test('POST /stop invokes onStop and the page renders the Stop button (#218)', async () => {
let stopped = 0
const dash = await startDashboard({ port: 0, onStop: () => stopped++ })
try {
const { status, body } = await send(dash.url + '/stop', 'POST')
assert.equal(status, 202)
assert.match(body, /"ok":true/)
assert.equal(stopped, 1)
// The page ships the button + enables it when a stop handler is wired.
const page = await fetchText(dash.url + '/')
assert.match(page.body, /id="stop"/)
assert.match(page.body, /STOPPABLE = true/)
} finally {
await dash.close()
}
})

test('/stop is 405 for a non-POST and 404 when stopping is not wired (#218)', async () => {
const withStop = await startDashboard({ port: 0, onStop: () => {} })
try {
assert.equal((await send(withStop.url + '/stop', 'GET')).status, 405)
} finally {
await withStop.close()
}
const noStop = await startDashboard({ port: 0 })
try {
assert.equal((await send(noStop.url + '/stop', 'POST')).status, 404)
const page = await fetchText(noStop.url + '/')
assert.match(page.body, /STOPPABLE = false/)
} finally {
await noStop.close()
}
})
31 changes: 29 additions & 2 deletions packages/framework/src/dashboard/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ export interface DashboardOptions {
host?: string
/** Page title. Default `"The Framework"`. */
title?: string
/**
* Called when the browser hits the Stop button (`POST /stop`). Wire this to
* abort the run (e.g. an `AbortController.abort()`). Omit to disable stopping;
* the page hides the button when the server reports no stop handler.
*/
onStop?: () => void
}

/** A running localhost dashboard. Push {@link FrameworkEvent}s; it renders them live. */
Expand Down Expand Up @@ -40,10 +46,11 @@ export function startDashboard(opts: DashboardOptions = {}): Promise<Dashboard>
const host = opts.host ?? '127.0.0.1'
const port = opts.port ?? 4477
const title = opts.title ?? 'The Framework'
const onStop = opts.onStop
const stream = new EventStream<FrameworkEvent>()
const clients = new Set<ServerResponse>()

const server = createServer((req, res) => handle(req, res, stream, clients, title))
const server = createServer((req, res) => handle(req, res, stream, clients, title, onStop))

return new Promise<Dashboard>((resolvePromise, rejectPromise) => {
server.once('error', rejectPromise)
Expand All @@ -67,17 +74,37 @@ function handle(
stream: EventStream<FrameworkEvent>,
clients: Set<ServerResponse>,
title: string,
onStop: (() => void) | undefined,
): void {
const url = req.url ?? '/'
if (url === '/' || url.startsWith('/?')) {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
res.end(dashboardHtml(title))
res.end(dashboardHtml(title, Boolean(onStop)))
return
}
if (url === '/events') {
streamEvents(req, res, stream, clients)
return
}
if (url === '/stop') {
// The Stop button. Idempotent: a stop after the run has ended just aborts an
// already-aborted signal (a no-op). 405 for a non-POST so a stray GET can't
// interrupt a run. 404 when no handler was wired (stopping disabled).
if (req.method !== 'POST') {
res.writeHead(405, { 'content-type': 'text/plain', allow: 'POST' })
res.end('method not allowed')
return
}
if (!onStop) {
res.writeHead(404, { 'content-type': 'text/plain' })
res.end('stopping not enabled')
return
}
onStop()
res.writeHead(202, { 'content-type': 'application/json' })
res.end('{"ok":true}')
return
}
res.writeHead(404, { 'content-type': 'text/plain' })
res.end('not found')
}
Expand Down
6 changes: 6 additions & 0 deletions packages/framework/src/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,9 @@ test('formatFrameworkEvent renders a preview line', () => {
'▶ your app is running at http://localhost:3000',
)
})

test('formatFrameworkEvent distinguishes finished / stopped / failed (#218)', () => {
assert.equal(formatFrameworkEvent({ kind: 'end', ok: true }), '✓ finished')
assert.equal(formatFrameworkEvent({ kind: 'end', ok: false, stopped: true }), '■ stopped')
assert.equal(formatFrameworkEvent({ kind: 'end', ok: false, detail: 'boom' }), '✗ failed: boom')
})
10 changes: 7 additions & 3 deletions packages/framework/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,12 @@ export type FrameworkEvent =
| { kind: 'preview'; url: string; command: string }
/** A framework-level log line. */
| { kind: 'log'; message: string }
/** The run finished. `ok` is false when it threw. */
| { kind: 'end'; ok: boolean; detail?: string }
/**
* The run finished. `ok` is false when it threw. `stopped` marks the common,
* non-error case where the user interrupted it (the dashboard Stop button /
* Ctrl+C), so a surface can show "stopped" rather than "failed".
*/
| { kind: 'end'; ok: boolean; stopped?: boolean; detail?: string }

/** Render a {@link FrameworkEvent} as one human-readable line (terminal surface). */
export function formatFrameworkEvent(event: FrameworkEvent): string {
Expand All @@ -51,7 +55,7 @@ export function formatFrameworkEvent(event: FrameworkEvent): string {
case 'bootstrap':
return formatBootstrapEvent(event.event)
case 'end':
return event.ok ? '✓ finished' : `✗ failed: ${event.detail ?? 'unknown error'}`
return event.ok ? '✓ finished' : event.stopped ? '■ stopped' : `✗ failed: ${event.detail ?? 'unknown error'}`
}
}

Expand Down
5 changes: 4 additions & 1 deletion packages/framework/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,10 @@ export async function runFramework(opts: RunFrameworkOptions): Promise<RunFramew
emit({ kind: 'end', ok: true })
return { result, detection, events, ledger, ...(preview ? { preview } : {}) }
} catch (err) {
emit({ kind: 'end', ok: false, detail: err instanceof Error ? err.message : String(err) })
// A user interrupt (the dashboard Stop button / Ctrl+C aborts the signal) is a
// clean stop, not a failure — mark it so surfaces show "stopped".
const stopped = opts.signal?.aborted === true
emit({ kind: 'end', ok: false, ...(stopped ? { stopped: true } : {}), detail: err instanceof Error ? err.message : String(err) })
throw err
} finally {
await session.dispose()
Expand Down
6 changes: 6 additions & 0 deletions packages/framework/src/store/run-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,9 @@ test('applyEventToMeta marks a thrown run as failed', () => {
const failed = applyEventToMeta(base, { kind: 'end', ok: false, detail: 'boom' }, AT)
assert.equal(failed.status, 'failed')
})

test('applyEventToMeta marks a user-stopped run as stopped, not failed (#218)', () => {
const base = metaFromEvents(RUN.slice(0, 4), AT)
const stopped = applyEventToMeta(base, { kind: 'end', ok: false, stopped: true }, AT)
assert.equal(stopped.status, 'stopped')
})
4 changes: 2 additions & 2 deletions packages/framework/src/store/run-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const META_FILE = 'run.json'
export const RUN_META_VERSION = 1

/** How a run ended (or that it is still going). */
export type RunStatus = 'running' | 'done' | 'failed'
export type RunStatus = 'running' | 'done' | 'stopped' | 'failed'

/**
* A queryable snapshot of the run, derived entirely from the event log. Lets the
Expand Down Expand Up @@ -108,7 +108,7 @@ export function applyEventToMeta(meta: RunMeta, event: FrameworkEvent, at: strin
break
}
case 'end':
next.status = event.ok ? 'done' : 'failed'
next.status = event.ok ? 'done' : event.stopped ? 'stopped' : 'failed'
break
default:
break
Expand Down
Loading