@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
package/server/api.ts ADDED
@@ -0,0 +1,216 @@
1
+ /**
2
+ * The JSON and SSE routes.
3
+ *
4
+ * The browser polls `/api/snapshot` for everything it repaints and holds one
5
+ * `/api/logs/stream` connection open for the console. Actions are POSTs that
6
+ * return no body — the client re-polls rather than trusting an optimistic
7
+ * result, because a load or a restart can take tens of seconds and can fail.
8
+ *
9
+ * Every route reads and writes through the {@link StewardDataSource} it is
10
+ * given; nothing here knows whether that source is simulated or live.
11
+ */
12
+
13
+ import type { IncomingMessage, ServerResponse } from "node:http";
14
+ import type { StewardDataSource } from "../core/source.js";
15
+ import type { LogLine, LogStreamStatus, ModelAction, ServiceAction } from "../core/types.js";
16
+
17
+ /** Lines replayed to a client that connects mid-run, matching the UI's buffer. */
18
+ const BACKLOG_LINES = 200;
19
+
20
+ /**
21
+ * How often the stream re-reads the source's health.
22
+ *
23
+ * Source state is not a line and cannot ride the line stream: a source that has
24
+ * nothing to say is exactly the case the console has to report on. The file can
25
+ * also vanish mid-session (macOS unlinks `/tmp` files untouched for three days)
26
+ * and come back on its own, so it is polled rather than sent once at open.
27
+ */
28
+ const SOURCE_POLL_MS = 2000;
29
+
30
+ function isServiceAction(value: string): value is ServiceAction {
31
+ return value === "start" || value === "stop" || value === "restart";
32
+ }
33
+
34
+ function isModelAction(value: string): value is ModelAction {
35
+ return value === "load" || value === "unload";
36
+ }
37
+
38
+ function sendJson(res: ServerResponse, status: number, payload: unknown): void {
39
+ const body = JSON.stringify(payload);
40
+ res.writeHead(status, {
41
+ "Content-Type": "application/json; charset=utf-8",
42
+ "Content-Length": Buffer.byteLength(body),
43
+ "Cache-Control": "no-store",
44
+ });
45
+ res.end(body);
46
+ }
47
+
48
+ function sendStatus(res: ServerResponse, status: number): void {
49
+ res.writeHead(status, { "Cache-Control": "no-store" });
50
+ res.end();
51
+ }
52
+
53
+ /**
54
+ * Opens the log stream: the buffered backlog first, as individual events, then
55
+ * every line as it arrives. The subscription is released when the client goes
56
+ * away — a browser that reloads must not leave a listener behind.
57
+ */
58
+ function streamLogs(req: IncomingMessage, res: ServerResponse, source: StewardDataSource): void {
59
+ res.writeHead(200, {
60
+ "Content-Type": "text/event-stream; charset=utf-8",
61
+ "Cache-Control": "no-store",
62
+ Connection: "keep-alive",
63
+ });
64
+ res.flushHeaders();
65
+ // Log lines are small and latency matters more than packet count here.
66
+ req.socket.setNoDelay(true);
67
+
68
+ /** Set once the socket is gone, which the guard alone cannot see in time. */
69
+ let gone = false;
70
+ /**
71
+ * Set while the client is behind. Lines that arrive then are dropped rather
72
+ * than queued: this is a live tail, and an unbounded write buffer would cost
73
+ * the server memory to deliver a backlog no operator is reading. The gap is
74
+ * visible in the sequence numbers, which is the honest outcome.
75
+ */
76
+ let saturated = false;
77
+
78
+ const write = (frame: string): void => {
79
+ if (gone || res.writableEnded || res.destroyed) return;
80
+ // The socket can end between that guard and this write — a race the
81
+ // stream cannot avoid, only absorb.
82
+ const flushed = res.write(frame, (error) => {
83
+ if (error) gone = true;
84
+ });
85
+ if (!flushed) saturated = true;
86
+ };
87
+
88
+ const send = (line: LogLine): void => {
89
+ if (saturated) return;
90
+ write(`data: ${JSON.stringify(line)}\n\n`);
91
+ };
92
+
93
+ /**
94
+ * Source health, on its own named event so a `message` listener never sees
95
+ * it. Sent on open and again only when it CHANGES — a console that re-heard
96
+ * "the file is gone" every two seconds would announce it every two seconds.
97
+ *
98
+ * A saturated socket does not suppress this one: it is a handful of bytes and
99
+ * the state it carries is exactly what an operator needs when things are
100
+ * going wrong.
101
+ */
102
+ let lastStatus = "";
103
+ const sendStatusFrame = (): void => {
104
+ const read = source.logStatus;
105
+ if (read === undefined) return;
106
+ let status: LogStreamStatus;
107
+ try {
108
+ status = read.call(source);
109
+ } catch {
110
+ // A source that cannot report on itself is not a reason to drop the tail.
111
+ return;
112
+ }
113
+ const encoded = JSON.stringify(status);
114
+ if (encoded === lastStatus) return;
115
+ lastStatus = encoded;
116
+ write(`event: source\ndata: ${encoded}\n\n`);
117
+ };
118
+
119
+ res.on("drain", () => {
120
+ saturated = false;
121
+ });
122
+ res.on("error", () => {
123
+ gone = true;
124
+ });
125
+
126
+ // Backlog and subscription are taken together, so a line that arrives while
127
+ // the stream is opening lands in one of them rather than neither. Sources that
128
+ // predate `attachLogs` fall back to the two calls, which are safe only because
129
+ // nothing awaits between them — do not insert one here.
130
+ const attach = source.attachLogs;
131
+ const attached =
132
+ attach === undefined
133
+ ? { backlog: source.recentLogs(BACKLOG_LINES), unsubscribe: source.subscribeLogs(send) }
134
+ : attach.call(source, send, BACKLOG_LINES);
135
+
136
+ // Health first, so a client that opens onto an unavailable source knows it
137
+ // before it has read a single (simulated) line.
138
+ sendStatusFrame();
139
+ for (const line of attached.backlog) send(line);
140
+
141
+ const statusTimer = setInterval(sendStatusFrame, SOURCE_POLL_MS);
142
+ statusTimer.unref?.();
143
+
144
+ res.on("close", () => {
145
+ gone = true;
146
+ clearInterval(statusTimer);
147
+ attached.unsubscribe();
148
+ res.end();
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Handles `/api/**`, returning `false` when the request is not one of these
154
+ * routes so the caller can fall through to the asset route (and, failing that,
155
+ * a 404).
156
+ */
157
+ export async function handleApiRequest(
158
+ req: IncomingMessage,
159
+ res: ServerResponse,
160
+ source: StewardDataSource,
161
+ pathname: string,
162
+ ): Promise<boolean> {
163
+ const method = req.method ?? "GET";
164
+
165
+ if (method === "GET" && pathname === "/api/snapshot") {
166
+ sendJson(res, 200, await source.snapshot());
167
+ return true;
168
+ }
169
+
170
+ if (method === "GET" && pathname === "/api/logs/stream") {
171
+ streamLogs(req, res, source);
172
+ return true;
173
+ }
174
+
175
+ if (method === "POST") {
176
+ const service = /^\/api\/service\/([^/]+)$/.exec(pathname);
177
+ if (service !== null) {
178
+ const action = decodeURIComponent(service[1] ?? "");
179
+ if (!isServiceAction(action)) {
180
+ sendStatus(res, 400);
181
+ return true;
182
+ }
183
+ try {
184
+ await source.setService(action);
185
+ } catch (error) {
186
+ // A control command that was refused is the operator's business, not a
187
+ // crash: the reason ("launchctl: permission denied") goes back as a
188
+ // body so the dashboard can show it inline instead of a bare 500.
189
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
190
+ return true;
191
+ }
192
+ sendStatus(res, 204);
193
+ return true;
194
+ }
195
+
196
+ const model = /^\/api\/models\/([^/]+)\/([^/]+)$/.exec(pathname);
197
+ if (model !== null) {
198
+ const modelId = decodeURIComponent(model[1] ?? "");
199
+ const action = decodeURIComponent(model[2] ?? "");
200
+ if (!isModelAction(action)) {
201
+ sendStatus(res, 400);
202
+ return true;
203
+ }
204
+ const snapshot = await source.snapshot();
205
+ if (!snapshot.models.some((entry) => entry.id === modelId)) {
206
+ sendStatus(res, 404);
207
+ return true;
208
+ }
209
+ await source.setModel(modelId, action);
210
+ sendStatus(res, 204);
211
+ return true;
212
+ }
213
+ }
214
+
215
+ return false;
216
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * The static asset route.
3
+ *
4
+ * Steward has no build step: the browser modules are authored as TypeScript
5
+ * and shipped as TypeScript. A request for `/ui/main.js` reads `ui/main.ts`
6
+ * off disk and hands it to `node:module`'s `stripTypeScriptTypes`, which
7
+ * blanks the type annotations in place and leaves runnable JavaScript. Because
8
+ * only erasable syntax is allowed in `ui/` and `core/`, that is the whole
9
+ * toolchain.
10
+ *
11
+ * Everything else (`.html`, `.css`, `.svg`) is served verbatim. Nothing is
12
+ * cached: this is a live operator tool, and a stale module is worse than a
13
+ * re-read.
14
+ */
15
+
16
+ import { readFile, realpath } from "node:fs/promises";
17
+ // `stripTypeScriptTypes` landed in Node 22.13. Reaching it through a namespace
18
+ // import keeps a runtime without it from failing the module link, so the guard
19
+ // below can report the real requirement instead of a link error.
20
+ import * as nodeModule from "node:module";
21
+ import path from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ /** One file, ready to write to the response. */
25
+ export interface Asset {
26
+ body: string;
27
+ contentType: string;
28
+ }
29
+
30
+ /**
31
+ * Resolved from this module's own URL rather than `process.cwd()`, so the
32
+ * routes work the same from the repo and from an installed npm package.
33
+ */
34
+ const PACKAGE_ROOT = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
35
+ const UI_ROOT = path.join(PACKAGE_ROOT, "ui");
36
+ const CORE_ROOT = path.join(PACKAGE_ROOT, "core");
37
+
38
+ const JAVASCRIPT = "text/javascript; charset=utf-8";
39
+ const CSS = "text/css; charset=utf-8";
40
+ const HTML = "text/html; charset=utf-8";
41
+ const SVG = "image/svg+xml";
42
+
43
+ const CONTENT_TYPES: Record<string, string | undefined> = {
44
+ ".js": JAVASCRIPT,
45
+ ".css": CSS,
46
+ ".html": HTML,
47
+ ".svg": SVG,
48
+ };
49
+
50
+ const NODE_REQUIREMENT =
51
+ "Steward needs Node >= 22.13: this runtime has no node:module stripTypeScriptTypes(), " +
52
+ "so the browser modules cannot be served.";
53
+
54
+ /**
55
+ * Test sources ship inside the package but are not part of the dashboard.
56
+ * Matches `format.test.js` and `format.test.ts` alike.
57
+ */
58
+ const TEST_SOURCE = /\.test\.[^./]+$/;
59
+
60
+ interface ResolvedAsset {
61
+ file: string;
62
+ /** Directory the request was routed to; the file must resolve inside it. */
63
+ root: string;
64
+ contentType: string;
65
+ /** Whether the file on disk is TypeScript that must be stripped first. */
66
+ strip: boolean;
67
+ }
68
+
69
+ /**
70
+ * Throws unless the host can strip TypeScript types. Defaults to this runtime,
71
+ * and is called at server start so the requirement reaches the operator rather
72
+ * than surfacing as a blank page once the shell asks for its first module.
73
+ */
74
+ export function assertTypeStripping(host: { stripTypeScriptTypes?: unknown } = nodeModule): void {
75
+ if (typeof host.stripTypeScriptTypes !== "function") throw new Error(NODE_REQUIREMENT);
76
+ }
77
+
78
+ /** True when `candidate` is `root` itself or lives underneath it. */
79
+ function isInside(root: string, candidate: string): boolean {
80
+ return candidate === root || candidate.startsWith(root + path.sep);
81
+ }
82
+
83
+ /**
84
+ * The roots with every symlink already resolved, so the containment check
85
+ * below compares like with like. Resolved once: they cannot move under a
86
+ * running server. A root that cannot be resolved falls back to its lexical
87
+ * path, which is the strictest answer available.
88
+ */
89
+ const realRoots = new Map<string, Promise<string>>();
90
+
91
+ function realRoot(root: string): Promise<string> {
92
+ const cached = realRoots.get(root);
93
+ if (cached !== undefined) return cached;
94
+ const pending = realpath(root).catch(() => root);
95
+ realRoots.set(root, pending);
96
+ return pending;
97
+ }
98
+
99
+ /**
100
+ * Maps a request path to a file, or `null` when the path is not served.
101
+ *
102
+ * The lexical check here rejects traversal — including percent-encoded
103
+ * segments, which are decoded first — before anything touches the disk.
104
+ * Symlinks are dealt with in {@link readAsset}, where the real path is known.
105
+ */
106
+ function resolveAsset(pathname: string): ResolvedAsset | null {
107
+ let decoded: string;
108
+ try {
109
+ decoded = decodeURIComponent(pathname);
110
+ } catch {
111
+ return null;
112
+ }
113
+ if (decoded.includes("\0")) return null;
114
+
115
+ if (decoded === "/" || decoded === "") {
116
+ return {
117
+ file: path.join(UI_ROOT, "index.html"),
118
+ root: UI_ROOT,
119
+ contentType: HTML,
120
+ strip: false,
121
+ };
122
+ }
123
+ if (decoded === "/favicon.svg") {
124
+ return {
125
+ file: path.join(UI_ROOT, "favicon.svg"),
126
+ root: UI_ROOT,
127
+ contentType: SVG,
128
+ strip: false,
129
+ };
130
+ }
131
+
132
+ const match = /^\/(ui|core)\/(.+)$/.exec(decoded);
133
+ const area = match?.[1];
134
+ const rest = match?.[2];
135
+ if (rest === undefined) return null;
136
+ if (TEST_SOURCE.test(rest)) return null;
137
+
138
+ const extension = path.extname(rest);
139
+ const contentType = CONTENT_TYPES[extension];
140
+ if (contentType === undefined) return null;
141
+
142
+ const root = area === "core" ? CORE_ROOT : UI_ROOT;
143
+ // Import specifiers are written `.js` (NodeNext style); the source is `.ts`.
144
+ const strip = extension === ".js";
145
+ const file = path.resolve(root, strip ? `${rest.slice(0, -".js".length)}.ts` : rest);
146
+ if (!isInside(root, file)) return null;
147
+
148
+ return { file, root, contentType, strip };
149
+ }
150
+
151
+ function stripTypes(source: string): string {
152
+ const strip = nodeModule.stripTypeScriptTypes;
153
+ if (typeof strip !== "function") throw new Error(NODE_REQUIREMENT);
154
+ // Default 'strip' mode only: it erases types without rewriting anything, so
155
+ // line numbers survive and no source map is needed.
156
+ return strip(source);
157
+ }
158
+
159
+ /** True for the errno codes that mean "there is no such file", not "it broke". */
160
+ function isMissing(error: unknown): boolean {
161
+ if (typeof error !== "object" || error === null || !("code" in error)) return false;
162
+ const code = (error as { code?: unknown }).code;
163
+ return code === "ENOENT" || code === "ENOTDIR" || code === "EISDIR";
164
+ }
165
+
166
+ /**
167
+ * Reads the asset a request path maps to, or resolves `null` when the path is
168
+ * unroutable or the file is absent — both of which the caller answers with a
169
+ * 404.
170
+ */
171
+ export async function readAsset(pathname: string): Promise<Asset | null> {
172
+ const resolved = resolveAsset(pathname);
173
+ if (resolved === null) return null;
174
+
175
+ // `path.resolve` cannot see through a symlink but `readFile` follows one, so
176
+ // containment is re-checked against the path the filesystem actually means.
177
+ let file: string;
178
+ try {
179
+ file = await realpath(resolved.file);
180
+ } catch (error) {
181
+ if (isMissing(error)) return null;
182
+ throw error;
183
+ }
184
+ if (!isInside(await realRoot(resolved.root), file)) return null;
185
+
186
+ let source: string;
187
+ try {
188
+ source = await readFile(file, "utf8");
189
+ } catch (error) {
190
+ if (isMissing(error)) return null;
191
+ throw error;
192
+ }
193
+
194
+ return {
195
+ body: resolved.strip ? stripTypes(source) : source,
196
+ contentType: resolved.contentType,
197
+ };
198
+ }