-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.ts
More file actions
122 lines (115 loc) · 4.26 KB
/
Copy pathserver.ts
File metadata and controls
122 lines (115 loc) · 4.26 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
import type { AddressInfo } from 'node:net'
import { EventStream } from '@gemstack/ai-autopilot'
import type { FrameworkEvent } from '../events.js'
import { dashboardHtml } from './page.js'
import { serveSSE } from './sse.js'
/** Options for {@link startDashboard}. */
export interface DashboardOptions {
/** Port to bind. Default `4477`; pass `0` for an ephemeral port. */
port?: number
/** Host to bind. Default `127.0.0.1` (localhost only). */
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. */
export interface Dashboard {
/** The URL to open. */
readonly url: string
/** The event stream backing the page (own it here, guardrail #2). */
readonly stream: EventStream<FrameworkEvent>
/** Push one event to every connected browser (and the replay buffer). */
push(event: FrameworkEvent): void
/** Close connections and stop the server. Idempotent. */
close(): Promise<void>
}
/**
* Start the localhost dashboard: a tiny `node:http` server that serves one HTML
* page and streams {@link FrameworkEvent}s to it over Server-Sent Events. The
* page foregrounds what the wrapped agent's own chat cannot: the chosen stack
* and its PROS/CONS rationale, the loop status + checklist blockers, the
* decisions ledger, and a link to the live agent session (#165).
*
* We own the stream, so the same events feed the terminal and the browser, and a
* late-connecting browser replays history via {@link EventStream}.
*/
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, onStop))
return new Promise<Dashboard>((resolvePromise, rejectPromise) => {
server.once('error', rejectPromise)
server.listen(port, host, () => {
server.removeListener('error', rejectPromise)
const address = server.address() as AddressInfo
const url = `http://${host}:${address.port}`
resolvePromise({
url,
stream,
push: event => stream.push(event),
close: () => closeServer(server, clients, stream),
})
})
})
}
function handle(
req: IncomingMessage,
res: ServerResponse,
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, Boolean(onStop)))
return
}
if (url === '/events') {
serveSSE(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')
}
function closeServer(
server: Server,
clients: Set<ServerResponse>,
stream: EventStream<FrameworkEvent>,
): Promise<void> {
stream.close()
for (const res of clients) res.end()
clients.clear()
return new Promise(resolvePromise => server.close(() => resolvePromise()))
}