@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,432 @@
1
+ /**
2
+ * The Node body behind {@link LogTailer}: it follows `llama-server`'s combined
3
+ * stdout/stderr redirect and turns every appended line into a {@link LogLine}.
4
+ *
5
+ * The source is deliberately the router's stdout redirect (what launchd's
6
+ * `StandardOutPath`/`StandardErrorPath` capture), not `--log-file`: llama.cpp
7
+ * propagates `--log-file` into every child model server, so that file gets each
8
+ * child line twice — once written by the child, once echoed by the router — and
9
+ * interleaves concurrent writers. The router's stdout has a single writer and
10
+ * carries the children's output in order, `[port]`-prefixed.
11
+ *
12
+ * It **polls `fs.stat`** rather than watching. `fs.watch` misses appends on
13
+ * Windows and has its own locking quirks there; polling a local file every few
14
+ * hundred ms is portable, predictable, and cheap next to what the dashboard
15
+ * already does per snapshot.
16
+ *
17
+ * The hazards it handles, in the order they actually happen on this platform:
18
+ *
19
+ * - **The file is unlinked under us.** macOS runs `com.apple.tmp_cleaner`
20
+ * daily at 00:00 and deletes any `/tmp` file whose atime, mtime and ctime all
21
+ * exceed three days — so a router stopped for a long weekend loses its log.
22
+ * That is reported as a distinct `missing` state, never as an error, and it
23
+ * is self-healing: the tailer keeps polling and picks the path back up the
24
+ * moment something recreates it.
25
+ * - **The service restarts.** launchd APPENDS across restarts (16 boots in one
26
+ * observed file), so a restart is not a rotation: the offset stands, the
27
+ * sequence numbers keep climbing, and the console simply gets a fresh banner.
28
+ * Resetting `seq` here would make the client's replay/restart detection
29
+ * re-adopt the whole buffer.
30
+ * - **Truncation and replacement.** A shrinking file (`copytruncate`) restarts
31
+ * at offset 0; a changed inode (delete-and-recreate, logrotate create-new)
32
+ * reopens. Neither resets `seq`.
33
+ * - **Partial trailing lines.** A read can land mid-line, so an unterminated
34
+ * tail is buffered and only emitted once its newline arrives — and, per the
35
+ * bounded-splitter lesson from `host-collector.ts`, a newline-less flood is
36
+ * discarded rather than accumulated, so memory stays bounded whatever is
37
+ * written.
38
+ *
39
+ * Nothing here throws. A path that does not exist, cannot be read, or vanishes
40
+ * mid-run is a state the console renders honestly, not a crash.
41
+ */
42
+
43
+ import { closeSync, fstatSync, openSync, readSync, type Stats, statSync } from "node:fs";
44
+ import { StringDecoder } from "node:string_decoder";
45
+ import type { LogTailer } from "../core/llama-source.js";
46
+ import { type ParsedLogLine, parseLogLine } from "../core/log-parse.js";
47
+ import type { LogAttachment, Unsubscribe } from "../core/source.js";
48
+ import type { LogLine, LogSourceState, LogStreamStatus } from "../core/types.js";
49
+ import { createLineSplitter } from "./host-collector.js";
50
+
51
+ /** The environment variable that points Steward at a log file explicitly. */
52
+ export const LOG_FILE_ENV = "STEWARD_LOG_FILE";
53
+
54
+ /**
55
+ * Where a launchd-managed router's combined redirect lands on this platform by
56
+ * convention. Only ever adopted when the file actually exists — naming a path
57
+ * nobody configured, in a console that then reports it as missing, would send an
58
+ * operator hunting a file that was never theirs.
59
+ */
60
+ export const DEFAULT_LOG_PATH = "/tmp/llama-router.log";
61
+
62
+ /** How often the file is re-`stat`ed. Fast enough to feel live, cheap enough to ignore. */
63
+ const DEFAULT_POLL_INTERVAL_MS = 400;
64
+
65
+ /**
66
+ * How much of an existing file is read as backlog on the first poll (and after a
67
+ * reopen). 256 KB is ~3,000 real lines — far more than the 200 the client
68
+ * replays — while a log that has grown for months is not read whole.
69
+ */
70
+ const DEFAULT_BACKLOG_BYTES = 256 * 1024;
71
+
72
+ /** Lines kept for {@link LogTailer.recent}; the client's own buffer is no larger. */
73
+ const DEFAULT_MAX_LINES = 500;
74
+
75
+ /**
76
+ * Longest single line assembled before it is discarded. Same cap and the same
77
+ * reasoning as the host collector's: a producer that writes without newlines
78
+ * must not be able to grow this process's memory.
79
+ */
80
+ const MAX_LINE_LENGTH = 64 * 1024;
81
+
82
+ /**
83
+ * Bytes read per poll. A tail that falls a long way behind (a burst, a long
84
+ * pause in the event loop) catches up over several polls instead of blocking one
85
+ * of them on a multi-megabyte read.
86
+ */
87
+ const MAX_READ_PER_POLL = 1024 * 1024;
88
+
89
+ export interface FileTailerOptions {
90
+ /** The log file to follow. It need not exist yet. */
91
+ path: string;
92
+ /** Milliseconds between `stat` polls. `0` leaves the tailer unscheduled (tests drive `poll`). */
93
+ pollIntervalMs?: number;
94
+ /** Bytes of existing file read as backlog on the first poll. */
95
+ backlogBytes?: number;
96
+ /** Lines retained for {@link LogTailer.recent}. */
97
+ maxLines?: number;
98
+ /** Arrival clock stamped on each line. Injected in tests; defaults to `Date.now`. */
99
+ now?: () => number;
100
+ }
101
+
102
+ /** A file tailer plus the poll its timer drives, so tests need no timers. */
103
+ export interface FileTailer extends LogTailer {
104
+ /** Runs one `stat`-and-read cycle. Never throws. */
105
+ poll(): void;
106
+ }
107
+
108
+ /** Node's `stat` error shape, narrowed enough to read the code off. */
109
+ function errorCode(error: unknown): string {
110
+ if (typeof error === "object" && error !== null && "code" in error) {
111
+ const code = (error as { code?: unknown }).code;
112
+ if (typeof code === "string") return code;
113
+ }
114
+ return "unknown error";
115
+ }
116
+
117
+ /**
118
+ * Follows `path`, emitting one {@link LogLine} per appended line.
119
+ *
120
+ * The backlog and the live tail come from ONE offset: every time the tailer
121
+ * starts following a file — first sight, a replacement, a truncation — it
122
+ * anchors at the end minus a backlog window, and every later poll continues from
123
+ * where the last one stopped.
124
+ *
125
+ * Use {@link LogTailer.attach} to open a console: it hands back the backlog and
126
+ * registers the listener in one step, which is the only way to get each line
127
+ * exactly once. Calling {@link LogTailer.recent} and then
128
+ * {@link LogTailer.subscribe} separately is correct only if nothing can poll
129
+ * between them, and a line that arrives in that window is in neither result.
130
+ */
131
+ export function createFileTailer(options: FileTailerOptions): FileTailer {
132
+ const path = options.path;
133
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
134
+ const backlogBytes = options.backlogBytes ?? DEFAULT_BACKLOG_BYTES;
135
+ const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
136
+ const now = options.now ?? Date.now;
137
+
138
+ const lines: LogLine[] = [];
139
+ const listeners = new Set<(line: LogLine) => void>();
140
+ /** Port→model id, refreshed from `/models` and reinforced by spawn lines. */
141
+ let ports: Map<number, string> = new Map();
142
+
143
+ /** Monotonic for the tailer's whole life — never reset, by any of the hazards. */
144
+ let seq = 0;
145
+ /** Byte offset already consumed from the current file. */
146
+ let position = 0;
147
+ /** Identity of the file we are following, so a replacement is detectable. */
148
+ let inode: number | null = null;
149
+ let device: number | null = null;
150
+ /** False until the first poll has anchored the offset. */
151
+ let anchored = false;
152
+ /** Set when a read starts mid-file, so the leading partial line is discarded. */
153
+ let dropPartial = false;
154
+ let source: LogSourceState = "ok";
155
+ let detail: string | null = null;
156
+ let closed = false;
157
+
158
+ /** The line before the one being parsed — the args run's only state. */
159
+ let previous: ParsedLogLine | null = null;
160
+ let decoder = new StringDecoder("utf8");
161
+ let splitter = createLineSplitter(MAX_LINE_LENGTH, handleLine);
162
+
163
+ /**
164
+ * Attributes and emits one complete line.
165
+ *
166
+ * A child line belongs to whichever model holds its port; a router line
167
+ * belongs to the model it names, if it names one. Everything else is
168
+ * router-wide and stays `null` — roughly a quarter of a real log is genuinely
169
+ * about no single model, and inventing an attribution for it would be worse
170
+ * than the em dash the console renders.
171
+ */
172
+ function handleLine(raw: string): void {
173
+ if (raw.trim() === "") return;
174
+ const parsed = parseLogLine(raw, previous);
175
+ previous = parsed;
176
+
177
+ // A spawn seen live maps its port before the next `/models` poll does.
178
+ if (parsed.modelName !== null && parsed.namedPort !== null) {
179
+ ports.set(parsed.namedPort, parsed.modelName);
180
+ }
181
+
182
+ const fromPort = parsed.port === null ? null : (ports.get(parsed.port) ?? null);
183
+ const modelId = parsed.origin === "child" ? (fromPort ?? parsed.modelName) : parsed.modelName;
184
+
185
+ seq += 1;
186
+ const line: LogLine = {
187
+ seq,
188
+ // The only timestamp in the file is a per-process elapsed counter that is
189
+ // not sortable across processes, so this is arrival time — which is what
190
+ // the console labels it as.
191
+ ts: now(),
192
+ level: parsed.level,
193
+ modelId,
194
+ message: parsed.message,
195
+ kind: parsed.kind,
196
+ origin: parsed.origin,
197
+ family: parsed.family,
198
+ // Every enrichment below is optional on the wire and absent when the
199
+ // parser did not find it, so a future llama.cpp that renames a payload
200
+ // costs one empty badge and never a dropped row.
201
+ ...(parsed.port === null ? {} : { port: parsed.port }),
202
+ ...(parsed.frame === null ? {} : { frame: parsed.frame }),
203
+ ...(parsed.contextLost ? { contextLost: true } : {}),
204
+ ...(parsed.cacheHit === null ? {} : { cacheHit: parsed.cacheHit }),
205
+ };
206
+ lines.push(line);
207
+ if (lines.length > maxLines) lines.splice(0, lines.length - maxLines);
208
+ for (const listener of listeners) listener(line);
209
+ }
210
+
211
+ /** Drops every byte of half-read state. Called whenever the file identity changes. */
212
+ function resetStream(): void {
213
+ decoder = new StringDecoder("utf8");
214
+ splitter = createLineSplitter(MAX_LINE_LENGTH, handleLine);
215
+ previous = null;
216
+ }
217
+
218
+ /** Where to start reading a file we have not been following: its tail. */
219
+ function anchor(size: number): void {
220
+ position = Math.max(0, size - backlogBytes);
221
+ dropPartial = position > 0;
222
+ resetStream();
223
+ }
224
+
225
+ /** Reads at most one chunk of new bytes and feeds them to the splitter. */
226
+ function drain(size: number): void {
227
+ if (position >= size) return;
228
+ const length = Math.min(size - position, MAX_READ_PER_POLL);
229
+
230
+ let fd: number;
231
+ try {
232
+ fd = openSync(path, "r");
233
+ } catch (error) {
234
+ markUnavailable(error);
235
+ return;
236
+ }
237
+ try {
238
+ // The file can be replaced between the stat above and this open; reading
239
+ // a different inode at our offset would emit garbage. Check identity
240
+ // against the handle we actually hold and let the next poll re-anchor.
241
+ const open = fstatSync(fd);
242
+ if (open.ino !== inode || open.dev !== device) return;
243
+
244
+ const buffer = Buffer.allocUnsafe(length);
245
+ const read = readSync(fd, buffer, 0, length, position);
246
+ if (read <= 0) return;
247
+ position += read;
248
+
249
+ let text = decoder.write(buffer.subarray(0, read));
250
+ if (dropPartial) {
251
+ // Anchoring mid-file lands in the middle of a line; that fragment is not
252
+ // a record and is never emitted as one.
253
+ const newline = text.indexOf("\n");
254
+ text = newline === -1 ? "" : text.slice(newline + 1);
255
+ if (newline !== -1) dropPartial = false;
256
+ }
257
+ splitter.push(text);
258
+ } catch (error) {
259
+ markUnavailable(error);
260
+ } finally {
261
+ closeSync(fd);
262
+ }
263
+ }
264
+
265
+ /** The path is there but unreadable — a permission or I/O problem, not an absence. */
266
+ function markUnavailable(error: unknown): void {
267
+ source = "unavailable";
268
+ detail = `${path} could not be read (${errorCode(error)})`;
269
+ }
270
+
271
+ function poll(): void {
272
+ if (closed) return;
273
+ try {
274
+ let stats: Stats;
275
+ try {
276
+ stats = statSync(path);
277
+ } catch (error) {
278
+ if (errorCode(error) === "ENOENT") {
279
+ // The likely macOS case: `tmp_cleaner` unlinked it. Hold the offset and
280
+ // the sequence, keep polling, and say so — this heals itself.
281
+ source = "missing";
282
+ detail = `${path} does not exist`;
283
+ } else {
284
+ markUnavailable(error);
285
+ }
286
+ return;
287
+ }
288
+
289
+ const replaced = anchored && (stats.ino !== inode || stats.dev !== device);
290
+ if (!anchored) {
291
+ // First sight: start at the tail, so a log that has grown for months
292
+ // costs one backlog window rather than a whole-file read.
293
+ inode = stats.ino;
294
+ device = stats.dev;
295
+ anchor(stats.size);
296
+ anchored = true;
297
+ } else if (replaced) {
298
+ // Unlinked and recreated (the `tmp_cleaner` case, once the router writes
299
+ // again), or rotated create-new: a different file, followed from its
300
+ // start — but through the same backlog window as a first sight, so
301
+ // rotating into a pre-populated file cannot flood every connected
302
+ // console with a whole log.
303
+ //
304
+ // Recovery from `missing` lands here, because a recreated file always
305
+ // has a new inode — while a transient stat failure on the SAME file does
306
+ // not, and so cannot make us re-read and re-emit what we already sent.
307
+ inode = stats.ino;
308
+ device = stats.dev;
309
+ anchor(stats.size);
310
+ } else if (stats.size < position) {
311
+ // Truncated in place (`copytruncate`): same file, fresh content, and the
312
+ // same cap again — a truncate followed by a large write before the next
313
+ // poll is otherwise the same flood by another route.
314
+ anchor(stats.size);
315
+ }
316
+
317
+ source = "ok";
318
+ detail = null;
319
+ drain(stats.size);
320
+ } catch (error) {
321
+ // Belt and braces: a tail that throws would take the dashboard's poll
322
+ // loop with it.
323
+ markUnavailable(error);
324
+ }
325
+ }
326
+
327
+ poll();
328
+
329
+ const timer = pollIntervalMs > 0 ? setInterval(poll, pollIntervalMs) : null;
330
+ // The dashboard's HTTP server keeps the process alive; a log tail should never
331
+ // be the reason it cannot exit.
332
+ timer?.unref();
333
+
334
+ return {
335
+ recent(limit: number): LogLine[] {
336
+ if (limit <= 0) return [];
337
+ return lines.slice(-limit);
338
+ },
339
+
340
+ subscribe(listener: (line: LogLine) => void): Unsubscribe {
341
+ listeners.add(listener);
342
+ return () => {
343
+ listeners.delete(listener);
344
+ };
345
+ },
346
+
347
+ attach(listener: (line: LogLine) => void, limit: number): LogAttachment {
348
+ // The backlog is taken and the listener registered with no suspension
349
+ // point between them, so a poll cannot land in the gap and drop a line
350
+ // out of both halves.
351
+ const backlog = limit <= 0 ? [] : lines.slice(-limit);
352
+ listeners.add(listener);
353
+ return {
354
+ backlog,
355
+ unsubscribe: () => {
356
+ listeners.delete(listener);
357
+ },
358
+ };
359
+ },
360
+
361
+ setPorts(next: ReadonlyMap<number, string>): void {
362
+ // Merged, not replaced, and HTTP wins every conflict: a child that spawned
363
+ // since the last poll is known here from its spawn line and not yet from
364
+ // `/models`, and an empty map (a failed read, or nothing loaded) must not
365
+ // blank out attribution for every child line until the next good poll.
366
+ ports = new Map([...ports, ...next]);
367
+ },
368
+
369
+ status(): LogStreamStatus {
370
+ return { source, path, detail };
371
+ },
372
+
373
+ poll,
374
+
375
+ close(): void {
376
+ closed = true;
377
+ if (timer !== null) clearInterval(timer);
378
+ listeners.clear();
379
+ },
380
+ };
381
+ }
382
+
383
+ /** The `log` block of `steward.json`, as this module needs it. */
384
+ export interface LogPathConfig {
385
+ path: string;
386
+ }
387
+
388
+ export interface ResolveLogPathOptions {
389
+ /** The validated `log` block from `steward.json`, or `null` when it has none. */
390
+ config?: LogPathConfig | null;
391
+ /** Environment to read {@link LOG_FILE_ENV} from. Defaults to `process.env`. */
392
+ env?: Record<string, string | undefined>;
393
+ /** Existence test for the convention default. Injected in tests. */
394
+ exists?: (path: string) => boolean;
395
+ }
396
+
397
+ /** True when `path` names a readable regular file. */
398
+ function isFile(path: string): boolean {
399
+ try {
400
+ return statSync(path).isFile();
401
+ } catch {
402
+ return false;
403
+ }
404
+ }
405
+
406
+ /**
407
+ * The log file to follow, or `null` for an honest "no log source".
408
+ *
409
+ * Precedence: `STEWARD_LOG_FILE`, then `steward.json`'s `log.path`, then the
410
+ * platform convention. The first two are taken at their word even when the file
411
+ * is absent — the operator (or the skill) named that path, and a named path that
412
+ * is currently missing is a state worth showing, and one that heals itself. The
413
+ * convention default is only adopted when the file is really there, so a machine
414
+ * that simply has not been wired up says "no log source" instead of blaming a
415
+ * `/tmp` file nobody chose.
416
+ *
417
+ * Service-manager introspection deliberately does not happen here: recording
418
+ * where this machine's router writes its log is the `/steward_initialize` skill's
419
+ * job, and its answer arrives as `log.path`.
420
+ */
421
+ export function resolveLogPath(options: ResolveLogPathOptions = {}): string | null {
422
+ const env = options.env ?? process.env;
423
+ const exists = options.exists ?? isFile;
424
+
425
+ const override = env[LOG_FILE_ENV];
426
+ if (override !== undefined && override.trim() !== "") return override.trim();
427
+
428
+ const configured = options.config?.path;
429
+ if (configured !== undefined && configured.trim() !== "") return configured.trim();
430
+
431
+ return exists(DEFAULT_LOG_PATH) ? DEFAULT_LOG_PATH : null;
432
+ }