@jmcombs/pi-steward 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/core/disconnected-source.ts +110 -0
  4. package/core/drift.ts +247 -0
  5. package/core/format.ts +317 -0
  6. package/core/host-metrics.ts +121 -0
  7. package/core/llama-config.ts +72 -0
  8. package/core/llama-connection.ts +215 -0
  9. package/core/llama-models.ts +261 -0
  10. package/core/llama-slots.ts +104 -0
  11. package/core/llama-source.ts +1523 -0
  12. package/core/log-parse.ts +440 -0
  13. package/core/model-color.ts +59 -0
  14. package/core/select.ts +2923 -0
  15. package/core/slot-activity.ts +658 -0
  16. package/core/source.ts +84 -0
  17. package/core/state.ts +609 -0
  18. package/core/status-widget.ts +222 -0
  19. package/core/temperature.ts +149 -0
  20. package/core/types.ts +431 -0
  21. package/index.ts +503 -0
  22. package/package.json +51 -0
  23. package/server/api.ts +216 -0
  24. package/server/assets.ts +198 -0
  25. package/server/config-wiring.ts +490 -0
  26. package/server/drift-probe.ts +150 -0
  27. package/server/host-collector.ts +272 -0
  28. package/server/index.ts +228 -0
  29. package/server/log-tailer.ts +432 -0
  30. package/server/service-control.ts +337 -0
  31. package/server/service-probe.ts +71 -0
  32. package/server/steward-config.ts +430 -0
  33. package/setup/init-prompt.ts +214 -0
  34. package/setup/steward-setup.d.mts +16 -0
  35. package/setup/steward-setup.mjs +1398 -0
  36. package/ui/components/console.ts +511 -0
  37. package/ui/components/gauges.ts +120 -0
  38. package/ui/components/metrics.ts +63 -0
  39. package/ui/components/models.ts +296 -0
  40. package/ui/components/service.ts +358 -0
  41. package/ui/components/slots.ts +114 -0
  42. package/ui/components/sparkline.ts +59 -0
  43. package/ui/components/toolbar.ts +211 -0
  44. package/ui/dom.ts +120 -0
  45. package/ui/favicon.svg +17 -0
  46. package/ui/index.html +34 -0
  47. package/ui/main.ts +678 -0
  48. package/ui/steward.css +2008 -0
@@ -0,0 +1,272 @@
1
+ /**
2
+ * The Node body behind {@link HostMetricsProvider}: it spawns the operator's
3
+ * declared collector command and drains a persistent NDJSON stream from its
4
+ * stdout, keeping the latest validated reading for the live source to overlay.
5
+ *
6
+ * This is the long-lived process that clears the per-poll-exec timeout blocker —
7
+ * Steward spawns the collector once and reads it, rather than shelling out on
8
+ * every snapshot. The hazards it guards against were all verified real on this
9
+ * hardware (plan H1):
10
+ *
11
+ * - It spawns `detached: true` and, on close, kills the whole PROCESS GROUP
12
+ * (`process.kill(-pid)`). A plain `child.kill()` orphans a collector that is
13
+ * itself a shell pipeline (`macmon | jq …`) — each respawn would leak one.
14
+ * - It reads stdout line-by-line and validates every line against the schema
15
+ * (see `core/host-metrics.ts`); malformed, oversized, or non-UTF8 lines are
16
+ * dropped, never turned into an all-`null` sample.
17
+ * - A producer that exits is respawned with exponential backoff, under a
18
+ * failure CAP so a command that cannot stay up (or a `jq` that block-buffers
19
+ * and never emits) fails honestly instead of fork-bombing. A spawn that does
20
+ * produce a valid sample resets the streak, so an occasional restart over a
21
+ * long healthy run never accretes toward the cap.
22
+ * - stderr is inherited (the operator's terminal), never treated as data.
23
+ *
24
+ * Node-only (spawns processes); injected into the otherwise Node-free
25
+ * {@link LlamaSource}. Staleness is judged by the overlay, not here — this module
26
+ * only stamps each sample's arrival time.
27
+ */
28
+
29
+ import { type ChildProcess, spawn as nodeSpawn } from "node:child_process";
30
+ import {
31
+ type HostMetricsProvider,
32
+ type HostSample,
33
+ parseHostMetricsLine,
34
+ } from "../core/host-metrics.js";
35
+
36
+ /** The child-spawn surface this module uses; injected in tests for observability. */
37
+ export type SpawnHostCollector = (command: string, args: string[]) => ChildProcess;
38
+
39
+ export interface HostCollectorOptions {
40
+ /**
41
+ * How many consecutive failed (re)starts to tolerate before giving up. A start
42
+ * that yields at least one valid sample resets the count. Default 5.
43
+ */
44
+ maxRespawns?: number;
45
+ /** First backoff after a failure, ms; doubles each further failure. Default 500. */
46
+ minBackoffMs?: number;
47
+ /** Ceiling for the backoff, ms. Default 5000. */
48
+ maxBackoffMs?: number;
49
+ /** Grace after SIGTERM before a SIGKILL escalation on close. Default 750. */
50
+ killEscalationMs?: number;
51
+ /** Arrival clock for each sample. Injected in tests; defaults to `Date.now`. */
52
+ now?: () => number;
53
+ /** The spawner. Injected in tests; defaults to a `detached` `node:child_process` spawn. */
54
+ spawn?: SpawnHostCollector;
55
+ }
56
+
57
+ /**
58
+ * Longest stdout line we will assemble. A producer emitting a newline-less flood
59
+ * must never buffer without bound (Node's own line readers have no such cap), so
60
+ * the splitter below discards a run that exceeds this and resyncs at the next
61
+ * newline — memory stays bounded whatever the producer emits.
62
+ */
63
+ const MAX_LINE_LENGTH = 64 * 1024;
64
+
65
+ /** Grace after SIGTERM before escalating to SIGKILL on a producer that ignores it. */
66
+ const KILL_ESCALATION_MS = 750;
67
+
68
+ const DEFAULT_MAX_RESPAWNS = 5;
69
+ const DEFAULT_MIN_BACKOFF_MS = 500;
70
+ const DEFAULT_MAX_BACKOFF_MS = 5000;
71
+
72
+ /** Feeds decoded stdout chunks in; hands complete, within-cap lines out. */
73
+ export interface LineSplitter {
74
+ push(chunk: string): void;
75
+ }
76
+
77
+ /**
78
+ * A bounded NDJSON line splitter. It accumulates decoded chunks and emits each
79
+ * complete line (a trailing `\r` stripped, for CRLF producers) to `onLine`,
80
+ * reassembling lines split across chunks. Crucially, it NEVER holds more than
81
+ * `maxLineLength` of a single unterminated line: once the bytes since the last
82
+ * newline exceed the cap it discards the buffer and enters a resync — dropping
83
+ * everything until the next newline — so an unterminated flood cannot grow
84
+ * memory without bound. An over-cap line that does eventually terminate is
85
+ * dropped whole; the following line parses normally.
86
+ */
87
+ export function createLineSplitter(
88
+ maxLineLength: number,
89
+ onLine: (line: string) => void,
90
+ ): LineSplitter {
91
+ // Bytes accumulated since the last newline, always ≤ maxLineLength.
92
+ let pending = "";
93
+ // True while discarding the tail of an over-cap run, until the next newline.
94
+ let overflowed = false;
95
+
96
+ return {
97
+ push(chunk: string): void {
98
+ let start = 0;
99
+ for (;;) {
100
+ const newline = chunk.indexOf("\n", start);
101
+ if (newline === -1) {
102
+ // No line terminator in the remainder: buffer it, unless doing so
103
+ // would breach the cap — in which case drop and resync, never store it.
104
+ const rest = chunk.slice(start);
105
+ if (!overflowed) {
106
+ if (pending.length + rest.length > maxLineLength) {
107
+ pending = "";
108
+ overflowed = true;
109
+ } else {
110
+ pending += rest;
111
+ }
112
+ }
113
+ return;
114
+ }
115
+
116
+ let segment = chunk.slice(start, newline);
117
+ if (segment.endsWith("\r")) segment = segment.slice(0, -1);
118
+ if (overflowed) {
119
+ // This newline ends the over-cap run; resume normal buffering after it.
120
+ overflowed = false;
121
+ pending = "";
122
+ } else if (pending.length + segment.length > maxLineLength) {
123
+ // A completed but over-cap line — dropped whole, like any bad line.
124
+ pending = "";
125
+ } else {
126
+ const line = pending + segment;
127
+ pending = "";
128
+ onLine(line);
129
+ }
130
+ start = newline + 1;
131
+ }
132
+ },
133
+ };
134
+ }
135
+
136
+ /** The default spawner: detached (its own group), stdout piped, stderr inherited. */
137
+ function defaultSpawn(command: string, args: string[]): ChildProcess {
138
+ return nodeSpawn(command, args, {
139
+ detached: true,
140
+ // stdin ignored, stdout is the data channel, stderr goes to the terminal.
141
+ stdio: ["ignore", "pipe", "inherit"],
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Spawns `command` and streams host readings off its stdout. `intervalMs` is the
147
+ * collector's declared cadence (from `steward.json`); the overlay uses it for
148
+ * staleness, and it is reported here only if the collector is given up on.
149
+ */
150
+ export function createHostCollector(
151
+ command: string[],
152
+ intervalMs: number,
153
+ options: HostCollectorOptions = {},
154
+ ): HostMetricsProvider {
155
+ const program = command[0] ?? "";
156
+ const args = command.slice(1);
157
+ const maxRespawns = options.maxRespawns ?? DEFAULT_MAX_RESPAWNS;
158
+ const minBackoffMs = options.minBackoffMs ?? DEFAULT_MIN_BACKOFF_MS;
159
+ const maxBackoffMs = options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
160
+ const killEscalationMs = options.killEscalationMs ?? KILL_ESCALATION_MS;
161
+ const now = options.now ?? Date.now;
162
+ const spawn = options.spawn ?? defaultSpawn;
163
+
164
+ let sample: HostSample | null = null;
165
+ let child: ChildProcess | null = null;
166
+ let backoffTimer: ReturnType<typeof setTimeout> | null = null;
167
+ let failures = 0;
168
+ let closed = false;
169
+
170
+ function handleLine(line: string): void {
171
+ // The splitter guarantees `line` is within the cap; malformed/foreign lines
172
+ // simply fail validation and are dropped.
173
+ const reading = parseHostMetricsLine(line);
174
+ if (reading === null) return;
175
+ // A real reading means this spawn is healthy: forgive its restart history so
176
+ // an occasional respawn over a long, working run never reaches the cap.
177
+ failures = 0;
178
+ sample = { reading, receivedAt: now() };
179
+ }
180
+
181
+ function scheduleRespawn(): void {
182
+ failures += 1;
183
+ if (failures > maxRespawns) {
184
+ console.warn(
185
+ `[steward] host collector gave up after ${failures} failed starts ` +
186
+ `(command: ${program}, intervalMs: ${intervalMs})`,
187
+ );
188
+ return;
189
+ }
190
+ const backoff = Math.min(maxBackoffMs, minBackoffMs * 2 ** (failures - 1));
191
+ backoffTimer = setTimeout(spawnOnce, backoff);
192
+ }
193
+
194
+ function spawnOnce(): void {
195
+ if (closed) return;
196
+ backoffTimer = null;
197
+
198
+ let proc: ChildProcess;
199
+ try {
200
+ proc = spawn(program, args);
201
+ } catch {
202
+ // A synchronous spawn throw (rare) is just another failed start.
203
+ scheduleRespawn();
204
+ return;
205
+ }
206
+ child = proc;
207
+
208
+ // exit and error can both fire for one spawn (e.g. ENOENT); settle once so a
209
+ // single failed start counts once toward the cap.
210
+ let settled = false;
211
+ const settle = (): void => {
212
+ if (settled) return;
213
+ settled = true;
214
+ if (child === proc) child = null;
215
+ if (!closed) scheduleRespawn();
216
+ };
217
+ // A listener is required or an 'error' would throw as unhandled.
218
+ proc.once("error", settle);
219
+ proc.once("exit", settle);
220
+
221
+ if (proc.stdout !== null) {
222
+ // Own the line-splitting (with a hard cap) rather than delegating to a
223
+ // reader that would buffer an unterminated flood without bound.
224
+ proc.stdout.setEncoding("utf8");
225
+ const splitter = createLineSplitter(MAX_LINE_LENGTH, handleLine);
226
+ proc.stdout.on("data", (chunk: string) => splitter.push(chunk));
227
+ }
228
+ }
229
+
230
+ spawnOnce();
231
+
232
+ return {
233
+ latest(): HostSample | null {
234
+ return sample;
235
+ },
236
+
237
+ close(): void {
238
+ if (closed) return;
239
+ closed = true;
240
+ if (backoffTimer !== null) {
241
+ clearTimeout(backoffTimer);
242
+ backoffTimer = null;
243
+ }
244
+ const proc = child;
245
+ child = null;
246
+ if (proc === null || proc.pid === undefined) return;
247
+ const pid = proc.pid;
248
+
249
+ // Signal the whole group: the collector may be a shell pipeline, and a
250
+ // direct kill would orphan the producer feeding it. Fall back to a direct
251
+ // kill when there is no group (already gone, or negative pids unsupported).
252
+ const signalGroup = (signal: NodeJS.Signals): void => {
253
+ try {
254
+ process.kill(-pid, signal);
255
+ } catch {
256
+ try {
257
+ proc.kill(signal);
258
+ } catch {
259
+ // Already dead — nothing to do.
260
+ }
261
+ }
262
+ };
263
+
264
+ signalGroup("SIGTERM");
265
+ // A producer that traps or ignores SIGTERM would otherwise linger; escalate
266
+ // to an uncatchable SIGKILL after a grace period. The timer is unref'd so it
267
+ // never keeps the process alive, and is cleared if it lands before firing.
268
+ const escalation = setTimeout(() => signalGroup("SIGKILL"), killEscalationMs);
269
+ escalation.unref();
270
+ },
271
+ };
272
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * The Steward server.
3
+ *
4
+ * A `node:http` server bound to loopback that does two things: serve the
5
+ * dashboard's own files (see `./assets.ts`) and expose one
6
+ * {@link StewardDataSource} over HTTP (see `./api.ts`). It holds the single
7
+ * source instance for the process, which is why swapping the mock for a live
8
+ * `llama-server` reader is a change here and nowhere else.
9
+ *
10
+ * It is never exposed beyond `127.0.0.1`: it can start and stop a local
11
+ * service and has no authentication of its own.
12
+ */
13
+
14
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
15
+ import { createDisconnectedSource } from "../core/disconnected-source.js";
16
+ import type { StewardDataSource } from "../core/source.js";
17
+ import { handleApiRequest } from "./api.js";
18
+ import { assertTypeStripping, readAsset } from "./assets.js";
19
+
20
+ /** The port Steward asks for unless told otherwise. */
21
+ export const DEFAULT_PORT = 8788;
22
+
23
+ /** Loopback only, deliberately: see the module comment. */
24
+ const HOST = "127.0.0.1";
25
+
26
+ export interface StewardServerOptions {
27
+ /** Port to bind. `0` picks a free one, which {@link StewardServer.port} reports. */
28
+ port?: number;
29
+ /**
30
+ * The source to serve. Defaults to the simulated one, which is built on the
31
+ * first {@link StewardServer.start} rather than here, so a server that never
32
+ * binds never starts a simulation. The server takes ownership either way: it
33
+ * closes the source when it stops, and when a start fails.
34
+ */
35
+ source?: StewardDataSource;
36
+ }
37
+
38
+ export interface StewardServer {
39
+ /**
40
+ * Binds the socket and resolves with the URL to open, port included.
41
+ * Resolves with the same URL if it is already bound, and joins an in-flight
42
+ * start rather than binding twice.
43
+ *
44
+ * A start that fails releases everything it created, which includes closing
45
+ * the data source — so a rejected start, like {@link StewardServer.stop},
46
+ * spends the instance. Calling `start` on a spent instance rejects.
47
+ */
48
+ start(): Promise<string>;
49
+ /**
50
+ * Closes live connections, the socket, and the data source, waiting for an
51
+ * in-flight {@link StewardServer.start} first so it cannot leave a socket
52
+ * bound behind the stop.
53
+ *
54
+ * Terminal: repeat calls resolve without doing more, and the instance cannot
55
+ * be started again — its source has been closed and closed sources do not
56
+ * come back.
57
+ */
58
+ stop(): Promise<void>;
59
+ /** The bound URL, or `null` before {@link start} resolves. */
60
+ readonly url: string | null;
61
+ /** The bound port, or `null` before {@link start} resolves. */
62
+ readonly port: number | null;
63
+ }
64
+
65
+ function sendPlain(res: ServerResponse, status: number, body: string): void {
66
+ res.writeHead(status, {
67
+ "Content-Type": "text/plain; charset=utf-8",
68
+ "Content-Length": Buffer.byteLength(body),
69
+ "Cache-Control": "no-store",
70
+ });
71
+ res.end(body);
72
+ }
73
+
74
+ /** Closes any connection still open, so `close()` is not held up by an SSE client. */
75
+ function closeConnections(server: Server): void {
76
+ if (typeof server.closeAllConnections === "function") server.closeAllConnections();
77
+ }
78
+
79
+ export function createStewardServer(options: StewardServerOptions = {}): StewardServer {
80
+ const requestedPort = options.port ?? DEFAULT_PORT;
81
+
82
+ let source: StewardDataSource | null = options.source ?? null;
83
+ let boundPort: number | null = null;
84
+ let boundUrl: string | null = null;
85
+ /** Set once the instance is spent, by a stop or by a start that failed. */
86
+ let spent = false;
87
+ let starting: Promise<string> | null = null;
88
+ let stopping: Promise<void> | null = null;
89
+
90
+ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
91
+ // Requests only arrive between a resolved start and a stop, so the source
92
+ // is there — but a stop that lands mid-request must not be a crash.
93
+ const active = source;
94
+ if (active === null) {
95
+ sendPlain(res, 503, "Steward is shutting down\n");
96
+ return;
97
+ }
98
+
99
+ // The base is a formality: only the path and query of `req.url` are real.
100
+ const { pathname } = new URL(req.url ?? "/", `http://${HOST}`);
101
+
102
+ if (await handleApiRequest(req, res, active, pathname)) return;
103
+
104
+ if (req.method === "GET") {
105
+ const asset = await readAsset(pathname);
106
+ if (asset !== null) {
107
+ res.writeHead(200, {
108
+ "Content-Type": asset.contentType,
109
+ "Content-Length": Buffer.byteLength(asset.body),
110
+ "Cache-Control": "no-store",
111
+ });
112
+ res.end(asset.body);
113
+ return;
114
+ }
115
+ }
116
+
117
+ sendPlain(res, 404, "Not found\n");
118
+ }
119
+
120
+ const server = createServer((req, res) => {
121
+ handleRequest(req, res).catch((error: unknown) => {
122
+ const detail = error instanceof Error ? error.message : String(error);
123
+ // The detail goes to the operator's terminal, not to the page: it can
124
+ // name absolute paths, and the browser has no use for it either way.
125
+ console.error(`[steward] ${req.method ?? "GET"} ${req.url ?? "/"} failed: ${detail}`);
126
+ if (!res.headersSent) sendPlain(res, 500, "Internal error\n");
127
+ else res.end();
128
+ });
129
+ });
130
+
131
+ /** Releases every resource the instance holds. Safe to call more than once. */
132
+ function release(): Promise<void> {
133
+ const active = source;
134
+ source = null;
135
+ boundUrl = null;
136
+ boundPort = null;
137
+ if (active !== null) active.close();
138
+ if (!server.listening) return Promise.resolve();
139
+ return new Promise<void>((resolve, reject) => {
140
+ server.close((error) => {
141
+ if (error) reject(error);
142
+ else resolve();
143
+ });
144
+ closeConnections(server);
145
+ });
146
+ }
147
+
148
+ function bind(): Promise<string> {
149
+ return new Promise<string>((resolve, reject) => {
150
+ // Every browser module is type-stripped on demand, so a runtime that
151
+ // cannot strip serves the shell and nothing that makes it work. Fail
152
+ // here, where whoever asked for the dashboard is still listening.
153
+ assertTypeStripping();
154
+ source = source ?? createDisconnectedSource();
155
+
156
+ const onError = (error: Error) => {
157
+ server.removeListener("listening", onListening);
158
+ reject(error);
159
+ };
160
+ const onListening = () => {
161
+ server.removeListener("error", onError);
162
+ const address = server.address();
163
+ if (address === null || typeof address === "string") {
164
+ reject(new Error("Steward server bound to an unexpected address"));
165
+ return;
166
+ }
167
+ boundPort = address.port;
168
+ boundUrl = `http://${HOST}:${address.port}`;
169
+ resolve(boundUrl);
170
+ };
171
+ server.once("error", onError);
172
+ server.once("listening", onListening);
173
+ server.listen(requestedPort, HOST);
174
+ });
175
+ }
176
+
177
+ return {
178
+ get url(): string | null {
179
+ return boundUrl;
180
+ },
181
+
182
+ get port(): number | null {
183
+ return boundPort;
184
+ },
185
+
186
+ start(): Promise<string> {
187
+ if (spent) {
188
+ return Promise.reject(
189
+ new Error("Steward server is spent: start a new one rather than reviving this one"),
190
+ );
191
+ }
192
+ if (boundUrl !== null) return Promise.resolve(boundUrl);
193
+ if (starting !== null) return starting;
194
+
195
+ const attempt = bind().then(
196
+ (url) => {
197
+ starting = null;
198
+ return url;
199
+ },
200
+ async (error: unknown) => {
201
+ // A start owns everything it built, whether or not it got as far as
202
+ // binding: the source's tickers are running by now and only this
203
+ // path can still reach them.
204
+ starting = null;
205
+ spent = true;
206
+ await release();
207
+ throw error;
208
+ },
209
+ );
210
+ starting = attempt;
211
+ return attempt;
212
+ },
213
+
214
+ stop(): Promise<void> {
215
+ if (stopping !== null) return stopping;
216
+ spent = true;
217
+ const pending = starting;
218
+ const attempt = (async () => {
219
+ // A stop that lands mid-start must wait for the socket the start is
220
+ // about to bind, or it would close nothing and leave it listening.
221
+ if (pending !== null) await pending.catch(() => undefined);
222
+ await release();
223
+ })();
224
+ stopping = attempt;
225
+ return attempt;
226
+ },
227
+ };
228
+ }