@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,337 @@
1
+ /**
2
+ * Runs the operator's declared start/stop/restart commands — the Node body
3
+ * behind {@link ServiceController}.
4
+ *
5
+ * Unlike the host collector (a long-lived stream), control is one-shot. The
6
+ * argv is passed as an array and NEVER through `shell: true` — the config is a
7
+ * code-execution surface already, and a shell would add word-splitting and
8
+ * metacharacter expansion on top of it. Only commands that passed the config's
9
+ * ownership check and the per-command consent gate ever reach here.
10
+ *
11
+ * It never throws, never rejects, and — the guarantee the whole dashboard leans
12
+ * on — always settles. The deadline is this module's own (SIGTERM, then SIGKILL
13
+ * after a grace, then an answer either way) rather than `execFile`'s single
14
+ * SIGTERM, which a command that traps it survives indefinitely. A non-zero exit,
15
+ * a timeout, a missing binary, or an action with no consented command all
16
+ * resolve as a failure with a readable detail, because "launchctl: permission
17
+ * denied" on screen is worth more to an operator than a stack trace in a
18
+ * terminal they are not reading. And a success is only the command's own
19
+ * verdict: the caller re-polls, because a `KeepAlive` job exits 0 from a stop
20
+ * and comes straight back.
21
+ */
22
+
23
+ import { type ChildProcess, spawn } from "node:child_process";
24
+ import type { ServiceController, ServiceControlResult } from "../core/llama-source.js";
25
+ import type { ServiceAction } from "../core/types.js";
26
+
27
+ /**
28
+ * How long a control command may run before it is killed. Generous next to the
29
+ * probe's 1.5s: `launchctl bootout` waits on the job it is tearing down, and a
30
+ * `systemctl restart` blocks until the unit settles.
31
+ */
32
+ const CONTROL_TIMEOUT_MS = 10_000;
33
+
34
+ /**
35
+ * Grace between the SIGTERM at the deadline and the SIGKILL that follows it,
36
+ * and the point at which this module answers whatever the child does. A command
37
+ * that traps or ignores SIGTERM would otherwise never settle: `execFile`'s own
38
+ * `timeout` option signals once and then waits forever on a child that survives
39
+ * it, and one unresolved promise here would hang the API handler, the browser's
40
+ * fetch, and the control row with it.
41
+ */
42
+ const KILL_ESCALATION_MS = 750;
43
+
44
+ /** Output past this is a runaway, not a message an operator wants to read. */
45
+ const MAX_OUTPUT_BYTES = 256 * 1024;
46
+
47
+ /** Longest failure detail we surface; the rest is noise on a rail 260px wide. */
48
+ const MAX_DETAIL_LENGTH = 160;
49
+
50
+ /**
51
+ * Longest program name inside a detail. A launcher can live behind a very long
52
+ * absolute path, and the reason ("permission denied") must survive the clamp —
53
+ * so the path gives up its head, not the message its tail.
54
+ */
55
+ const MAX_PROGRAM_LENGTH = 48;
56
+
57
+ /** The consented commands, keyed by action. A missing action cannot be run. */
58
+ export type ServiceControlCommands = Partial<Record<ServiceAction, string[]>>;
59
+
60
+ export interface ServiceControlOptions {
61
+ /** Ceiling on one command's runtime, ms. Defaults to {@link CONTROL_TIMEOUT_MS}. */
62
+ timeoutMs?: number;
63
+ }
64
+
65
+ /** The actions offered, in the order the dashboard renders them. */
66
+ const ACTION_ORDER: readonly ServiceAction[] = ["start", "stop", "restart"];
67
+
68
+ /**
69
+ * Drops the control characters a terminal-shaped tool sprays into its output —
70
+ * ANSI colour escapes above all. They are invisible on screen but a screen
71
+ * reader reads them aloud, and this string ends up in a `role="alert"` region.
72
+ */
73
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching them is the point.
74
+ const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F]+/gu;
75
+
76
+ function sanitize(text: string): string {
77
+ return text.replace(CONTROL_CHARACTERS, " ").replace(/\s+/gu, " ").trim();
78
+ }
79
+
80
+ /** The first non-empty line of a command's output, cleaned, or null. */
81
+ function firstLine(output: unknown): string | null {
82
+ if (typeof output !== "string") return null;
83
+ for (const line of output.split("\n")) {
84
+ const clean = sanitize(line);
85
+ if (clean !== "") return clean;
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * The program as it reads in a notice. The reason is the part an operator acts
92
+ * on, so a very long path gives up its head (`…/bin/launchctl`) rather than
93
+ * pushing the reason past the clamp.
94
+ */
95
+ function programLabel(program: string): string {
96
+ const clean = sanitize(program);
97
+ if (clean.length <= MAX_PROGRAM_LENGTH) return clean;
98
+ return `…${clean.slice(clean.length - (MAX_PROGRAM_LENGTH - 1))}`;
99
+ }
100
+
101
+ /** The finished detail: bounded as a whole, not merely in its fragments. */
102
+ function detail(program: string, reason: string): string {
103
+ const text = `${programLabel(program)}: ${reason}`;
104
+ return text.length > MAX_DETAIL_LENGTH ? `${text.slice(0, MAX_DETAIL_LENGTH - 1)}…` : text;
105
+ }
106
+
107
+ /**
108
+ * Everything one finished run knows about itself. `timedOut` and `overflowed`
109
+ * are this module's own verdicts, not the child's: only we know the deadline
110
+ * passed or the output cap was breached, and a child killed for either reason
111
+ * must not be described by whatever it happened to print on the way out.
112
+ */
113
+ interface CommandOutcome {
114
+ /** A spawn-level errno (`ENOENT`, `EACCES`), when the program never ran. */
115
+ errorCode?: string;
116
+ /** The exit status, or `null` when a signal ended it. */
117
+ exitCode?: number | null;
118
+ signal?: string | null;
119
+ stdout?: string;
120
+ stderr?: string;
121
+ timedOut?: boolean;
122
+ overflowed?: boolean;
123
+ }
124
+
125
+ /**
126
+ * Turns a finished run into something an operator can act on. The interesting
127
+ * cases are the ones an operator hits in practice: a command that is not
128
+ * installed, one the user may not run, one that hung, and one that ran and
129
+ * refused — the last of which usually explains itself on stderr (`launchctl`'s
130
+ * "Load failed: 5: Input/output error"), so that line is preferred over a bare
131
+ * exit status.
132
+ */
133
+ function describeFailure(program: string, timeoutMs: number, outcome: CommandOutcome): string {
134
+ if (outcome.errorCode === "ENOENT") return detail(program, "command not found");
135
+ if (outcome.errorCode === "EACCES" || outcome.errorCode === "EPERM") {
136
+ return detail(program, "permission denied");
137
+ }
138
+ if (outcome.timedOut === true) return detail(program, `timed out after ${timeoutMs}ms`);
139
+ // An overflowing child is killed mid-run, and its truncated output is NOT the
140
+ // reason it failed — reporting that output would invent a failure message out
141
+ // of a chatty command's ordinary chatter.
142
+ if (outcome.overflowed === true) {
143
+ return detail(
144
+ program,
145
+ `produced more than ${MAX_OUTPUT_BYTES / 1024} KB of output and was killed`,
146
+ );
147
+ }
148
+
149
+ const line = firstLine(outcome.stderr) ?? firstLine(outcome.stdout);
150
+ if (line !== null) return detail(program, line);
151
+ if (typeof outcome.signal === "string" && outcome.signal !== "") {
152
+ return detail(program, `killed by ${outcome.signal}`);
153
+ }
154
+ if (typeof outcome.exitCode === "number") {
155
+ return detail(program, `exited with status ${outcome.exitCode}`);
156
+ }
157
+ if (outcome.errorCode !== undefined) return detail(program, sanitize(outcome.errorCode));
158
+ return detail(program, "failed");
159
+ }
160
+
161
+ /**
162
+ * Runs one command under a deadline this module owns.
163
+ *
164
+ * `spawn` rather than `execFile`, for two reasons that both cost the operator
165
+ * dearly otherwise. First, `execFile`'s `timeout` (and `promisify`'s) sends a
166
+ * single SIGTERM and then waits on the child forever, so a command that traps
167
+ * it — a wrapper script with `trap '' TERM` — leaves the promise pending, and
168
+ * with it the API request, the browser's fetch, and the control row, which
169
+ * stays disabled until the page is reloaded. Second, `execFile` does not
170
+ * forward `detached`, so there is no process GROUP to signal: killing the
171
+ * direct child of a wrapper leaves the `launchctl` (or `sleep`) it was waiting
172
+ * on running, while the dashboard reports the command killed.
173
+ *
174
+ * So this owns the whole lifecycle — a detached group, bounded output, SIGTERM
175
+ * at the deadline, SIGKILL after a grace, and an answer from our own timer
176
+ * regardless of whether the child ever exits. It is the host collector's lesson
177
+ * applied to a one-shot command.
178
+ */
179
+ function runCommand(
180
+ program: string,
181
+ args: string[],
182
+ timeoutMs: number,
183
+ ): Promise<ServiceControlResult> {
184
+ return new Promise<ServiceControlResult>((resolve) => {
185
+ let settled = false;
186
+ let timedOut = false;
187
+ let overflowed = false;
188
+ let stdout = "";
189
+ let stderr = "";
190
+ let deadline: ReturnType<typeof setTimeout> | undefined;
191
+ let escalation: ReturnType<typeof setTimeout> | undefined;
192
+
193
+ const finish = (result: ServiceControlResult): void => {
194
+ if (settled) return;
195
+ settled = true;
196
+ // Both timers are cleared on every path, so neither outlives the call.
197
+ clearTimeout(deadline);
198
+ clearTimeout(escalation);
199
+ resolve(result);
200
+ };
201
+
202
+ const fail = (outcome: CommandOutcome): void => {
203
+ finish({ ok: false, detail: describeFailure(program, timeoutMs, outcome) });
204
+ };
205
+
206
+ let child: ChildProcess;
207
+ try {
208
+ child = spawn(program, args, {
209
+ // Its own process group, so the escalation below reaches a wrapper's
210
+ // children too. Never `unref`'d: this run waits for it.
211
+ detached: true,
212
+ stdio: ["ignore", "pipe", "pipe"],
213
+ windowsHide: true,
214
+ });
215
+ } catch (error) {
216
+ // A synchronous throw (an invalid program string) is just a failure.
217
+ fail({ errorCode: error instanceof Error ? error.message : String(error) });
218
+ return;
219
+ }
220
+
221
+ /**
222
+ * Signals the child's whole process group, falling back to the child alone
223
+ * where there is no group (already reaped, or a platform without negative
224
+ * pids). Every path is guarded: signalling a process that has just exited
225
+ * is a race, not an error.
226
+ */
227
+ const signal = (name: NodeJS.Signals): void => {
228
+ const pid = child.pid;
229
+ if (pid === undefined) return;
230
+ try {
231
+ process.kill(-pid, name);
232
+ } catch {
233
+ try {
234
+ child.kill(name);
235
+ } catch {
236
+ // Already gone — nothing to signal.
237
+ }
238
+ }
239
+ };
240
+
241
+ /**
242
+ * Keeps at most {@link MAX_OUTPUT_BYTES} of a stream. A command that floods
243
+ * its output is killed rather than buffered without bound, and the flood is
244
+ * never mistaken for its reason.
245
+ */
246
+ const collect = (chunk: string, held: string): string => {
247
+ if (held.length + chunk.length <= MAX_OUTPUT_BYTES) return held + chunk;
248
+ if (!overflowed) {
249
+ overflowed = true;
250
+ signal("SIGKILL");
251
+ }
252
+ return held;
253
+ };
254
+
255
+ child.stdout?.setEncoding("utf8");
256
+ child.stdout?.on("data", (chunk: string) => {
257
+ stdout = collect(chunk, stdout);
258
+ });
259
+ child.stderr?.setEncoding("utf8");
260
+ child.stderr?.on("data", (chunk: string) => {
261
+ stderr = collect(chunk, stderr);
262
+ });
263
+
264
+ // A listener is required or an 'error' would throw as unhandled. It fires
265
+ // for a program that could not be started at all (ENOENT, EACCES).
266
+ child.once("error", (error: NodeJS.ErrnoException) => {
267
+ fail({ errorCode: error.code ?? error.message, stdout, stderr });
268
+ });
269
+
270
+ // 'close' rather than 'exit': it lands once the pipes are drained, so the
271
+ // reason on stderr is complete by the time it is read.
272
+ child.once("close", (code, signalName) => {
273
+ if (timedOut) {
274
+ fail({ timedOut: true });
275
+ return;
276
+ }
277
+ if (overflowed) {
278
+ fail({ overflowed: true });
279
+ return;
280
+ }
281
+ if (code === 0) {
282
+ finish({ ok: true, detail: null });
283
+ return;
284
+ }
285
+ fail({ exitCode: code, signal: signalName, stdout, stderr });
286
+ });
287
+
288
+ deadline = setTimeout(() => {
289
+ timedOut = true;
290
+ // Ask first: a control command mid-teardown deserves the chance to finish
291
+ // what it started, and most tools exit promptly on SIGTERM.
292
+ signal("SIGTERM");
293
+ escalation = setTimeout(() => {
294
+ signal("SIGKILL");
295
+ // Answer regardless of what the child does next. A SIGKILL cannot be
296
+ // trapped, but a process can be unkillable (uninterruptible sleep) or
297
+ // hold its stdio open through a grandchild, and neither may hold the
298
+ // dashboard.
299
+ fail({ timedOut: true });
300
+ }, KILL_ESCALATION_MS);
301
+ }, timeoutMs);
302
+ });
303
+ }
304
+
305
+ /**
306
+ * A controller over the given consented commands. Building one costs nothing
307
+ * and holds nothing: each {@link ServiceController.run} is a single one-shot
308
+ * exec, so the instance can be shared and needs no close.
309
+ */
310
+ export function createServiceController(
311
+ commands: ServiceControlCommands,
312
+ options: ServiceControlOptions = {},
313
+ ): ServiceController {
314
+ const timeoutMs = options.timeoutMs ?? CONTROL_TIMEOUT_MS;
315
+ const actions = ACTION_ORDER.filter((action) => {
316
+ const command = commands[action];
317
+ return command !== undefined && command.length > 0;
318
+ });
319
+
320
+ return {
321
+ actions,
322
+
323
+ run(action: ServiceAction): Promise<ServiceControlResult> {
324
+ const command = commands[action];
325
+ const program = command?.[0];
326
+ if (command === undefined || program === undefined) {
327
+ // Reachable when a client POSTs an action the dashboard never offered.
328
+ return Promise.resolve({
329
+ ok: false,
330
+ detail: `no consented ${action} command for this machine — run /steward_initialize`,
331
+ });
332
+ }
333
+
334
+ return runCommand(program, command.slice(1), timeoutMs);
335
+ },
336
+ };
337
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Resolves the local process serving a host:port, for the SERVICE panel's real
3
+ * pid and uptime — facts `llama-server` does not report over HTTP.
4
+ *
5
+ * It shells out to `lsof` (the listener's pid) and `ps` (its start time), so it
6
+ * is macOS/Linux only and best-effort: anything it cannot determine degrades to
7
+ * n/a, never an error. This is exactly why it is injected into the otherwise
8
+ * Node-free {@link LlamaSource} rather than living in `core/`.
9
+ */
10
+
11
+ import { execFile } from "node:child_process";
12
+ import { promisify } from "node:util";
13
+ import type { ServiceProbe, ServiceProcess } from "../core/llama-source.js";
14
+
15
+ const run = promisify(execFile);
16
+
17
+ /** No single probe command may hang the metrics poll. */
18
+ const PROBE_TIMEOUT_MS = 1500;
19
+
20
+ /** The first pid listening on the TCP port, or null when none is found. */
21
+ async function listenerPid(port: number): Promise<number | null> {
22
+ try {
23
+ const { stdout } = await run("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
24
+ timeout: PROBE_TIMEOUT_MS,
25
+ });
26
+ for (const line of stdout.split("\n")) {
27
+ const pid = Number(line.trim());
28
+ if (Number.isInteger(pid) && pid > 0) return pid;
29
+ }
30
+ return null;
31
+ } catch {
32
+ // lsof missing, no match (exit 1), or timed out: no pid to report.
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /** A process's start time as epoch ms, or null when it cannot be read. */
38
+ async function startedAt(pid: number): Promise<number | null> {
39
+ try {
40
+ // `ps -o lstart=` prints an absolute, locale-parseable timestamp on both
41
+ // macOS and Linux (unlike `etimes`, which is Linux-only).
42
+ const { stdout } = await run("ps", ["-o", "lstart=", "-p", String(pid)], {
43
+ timeout: PROBE_TIMEOUT_MS,
44
+ });
45
+ const parsed = Date.parse(stdout.trim());
46
+ return Number.isNaN(parsed) ? null : parsed;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * A probe with a per-pid start-time cache: the listener pid is cheap to re-read
54
+ * each snapshot, but a process's start time never changes, so `ps` runs once per
55
+ * pid rather than on every poll. A new (or absent) pid drops the cache.
56
+ */
57
+ export function createListenerProbe(): ServiceProbe {
58
+ const startCache = new Map<number, number | null>();
59
+ return async (_host: string, port: number): Promise<ServiceProcess | null> => {
60
+ const pid = await listenerPid(port);
61
+ if (pid === null) {
62
+ startCache.clear();
63
+ return null;
64
+ }
65
+ if (!startCache.has(pid)) {
66
+ startCache.clear();
67
+ startCache.set(pid, await startedAt(pid));
68
+ }
69
+ return { pid, startedAt: startCache.get(pid) ?? null };
70
+ };
71
+ }