@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,490 @@
1
+ /**
2
+ * Keeps the live source wired to whatever `steward.json` says RIGHT NOW.
3
+ *
4
+ * The artifact is written by `/steward_initialize`, and an operator runs that
5
+ * with the dashboard open — which is the whole point of a setup flow. Read once
6
+ * at extension load, the config was baked into a closure: a machine that gained
7
+ * a collector, a log path, control commands or a launch baseline saw none of it
8
+ * until the next Pi session, and a machine whose config was deleted kept running
9
+ * the collector it declared. This module is the seam that makes both take effect
10
+ * on the next repaint instead.
11
+ *
12
+ * WHAT TRIGGERS A RE-READ. `fs.watch` on the config's DIRECTORY, never a timer.
13
+ * The directory rather than the file because appearing and disappearing are the
14
+ * two cases that matter most, and a watch on a path that does not exist yet
15
+ * cannot be armed at all — while a rename-into-place (how any careful writer
16
+ * updates a config) replaces the inode a file watch was holding. When the
17
+ * directory itself is missing, the nearest existing ancestor is watched instead
18
+ * and the watch moves down as the directories appear.
19
+ *
20
+ * WHAT SURVIVES A TRIGGER. `fs.watch` is chatty — a single save is routinely two
21
+ * or three events, and a watch on an ancestor sees traffic that has nothing to do
22
+ * with us — so every refresh first takes ONE `stat` and compares an identity
23
+ * tuple: device, inode, mtime, ctime, size, mode and owner. Unchanged means the
24
+ * file is not re-read, not re-parsed, and nothing is rebuilt or respawned. The
25
+ * tuple reaches past mtime and size on purpose — a `chmod o+w` moves neither,
26
+ * and that no-op-looking change is exactly the one the security gate exists to
27
+ * catch.
28
+ *
29
+ * WHAT IT CANNOT SEE. A watch does not begin delivering the instant it is
30
+ * created — on macOS the FSEvents stream starts on another thread — so the
31
+ * window this misses is a change that lands between the startup READ and the
32
+ * watch being armed a moment later, which is not a window an operator can act
33
+ * in. A watch the platform refuses outright (some network mounts) is warned
34
+ * about once, and the config read at startup simply stands.
35
+ *
36
+ * WHAT IS REBUILT. As little as possible, because rebuilding is not free: the
37
+ * collector is a detached process group with a warmup, and respawning one drops
38
+ * the metrics stream. So each part is keyed on the config fields it is actually
39
+ * built from — the collector on its argv and cadence, the tailer on its path, the
40
+ * drift probe on the recorded launch argv — and an unchanged key hands back the
41
+ * SAME instance, which the source reads as "leave this one alone" (see
42
+ * {@link LlamaLiveParts}). Everything else is rebuilt from the new config, so a
43
+ * command whose consent hash went missing stops being offered, and a config that
44
+ * failed to load takes every part down with it.
45
+ *
46
+ * Node-only: it stats, watches, spawns and executes. The source it feeds stays
47
+ * free of all of that.
48
+ */
49
+
50
+ import { type FSWatcher, watch as nodeWatch, statSync } from "node:fs";
51
+ import { dirname, resolve as resolvePath } from "node:path";
52
+ import type { DriftProbe } from "../core/drift.js";
53
+ import type { HostMetricsProvider } from "../core/host-metrics.js";
54
+ import type { LlamaLiveParts, LogTailer, ServiceController } from "../core/llama-source.js";
55
+ import type { Unsubscribe } from "../core/source.js";
56
+ import type { ServiceAction } from "../core/types.js";
57
+ import { createDriftProbe } from "./drift-probe.js";
58
+ import { createHostCollector } from "./host-collector.js";
59
+ import { createFileTailer, type LogPathConfig, resolveLogPath } from "./log-tailer.js";
60
+ import { createServiceController, type ServiceControlCommands } from "./service-control.js";
61
+ import {
62
+ consentDrift,
63
+ consentedControls,
64
+ hostCollectorConsented,
65
+ readStewardConfig,
66
+ type StewardConfig,
67
+ stewardConfigPath,
68
+ } from "./steward-config.js";
69
+
70
+ /**
71
+ * How long a burst of filesystem events is allowed to settle before the config
72
+ * is re-read.
73
+ *
74
+ * This is not a poll — nothing is scheduled until an event arrives — but it does
75
+ * two useful things when one does. A save is several events, and coalescing them
76
+ * costs one `stat` instead of three. More importantly, a writer that truncates
77
+ * and then writes (rather than renaming into place) is briefly holding a file
78
+ * that parses as malformed JSON, and reading it in that window would tear the
79
+ * collector down and build it straight back up.
80
+ */
81
+ const SETTLE_MS = 60;
82
+
83
+ /** How far up the tree the watch will climb looking for a directory that exists. */
84
+ const MAX_ANCESTORS = 3;
85
+
86
+ /**
87
+ * How many times a watch that dies may be re-armed before Steward stops trying.
88
+ *
89
+ * A watch the OS drops is worth replacing — one transient EMFILE should not
90
+ * cost the operator every later config change — but a watch that cannot stay up
91
+ * must fail honestly rather than re-arm forever, exactly as the host collector's
92
+ * respawn cap does. Past the cap the config read at startup simply stands, and
93
+ * the operator is told.
94
+ */
95
+ const MAX_WATCH_LOSSES = 3;
96
+
97
+ /** Something with a `close()`; a `fs.watch` handle satisfies it, as does a stub. */
98
+ export interface Closable {
99
+ close(): void;
100
+ }
101
+
102
+ /**
103
+ * Watches `directory`, calling `onEvent` for changes to `filename` within it.
104
+ *
105
+ * `lost` says the watch itself has ENDED and delivered its last event — the OS
106
+ * dropped it, and the handle is already closed. It is a distinct signal because
107
+ * a dead watch is indistinguishable from a quiet directory from the outside,
108
+ * and a silently dead one means every later config change is missed.
109
+ */
110
+ export type WatchDirectory = (
111
+ directory: string,
112
+ onEvent: (filename: string | null, lost: boolean) => void,
113
+ ) => Closable;
114
+
115
+ export interface ConfigWiringOptions {
116
+ /** The artifact to follow. Defaults to {@link stewardConfigPath}. */
117
+ path?: string;
118
+ /** Reads and validates the artifact. Injected in tests; defaults to the real gate. */
119
+ read?: (path: string) => StewardConfig | null;
120
+ /** Spawns the host collector. Injected in tests, so none is ever spawned there. */
121
+ createCollector?: (command: string[], intervalMs: number) => HostMetricsProvider;
122
+ /** Builds the service controller. Injected in tests. */
123
+ createController?: (commands: ServiceControlCommands) => ServiceController;
124
+ /** Builds the launch-argv drift probe. Injected in tests. */
125
+ createProbe?: (launchArgv: string[]) => DriftProbe;
126
+ /** Opens a log tail. Injected in tests, so no real file is followed there. */
127
+ createTailer?: (path: string) => LogTailer;
128
+ /** Resolves the log path from the config, the env, and the convention. Injected in tests. */
129
+ resolveLog?: (config: LogPathConfig | null) => string | null;
130
+ /** Watches a directory. Injected in tests, which trigger refreshes by hand. */
131
+ watch?: WatchDirectory;
132
+ /** Defers the refresh so a burst of events costs one read. Injected in tests. */
133
+ settle?: (run: () => void) => Unsubscribe;
134
+ /** Sink for the "cannot watch" warning. Injected in tests; defaults to `console.warn`. */
135
+ warn?: (message: string) => void;
136
+ }
137
+
138
+ /**
139
+ * A live view of `steward.json`, expressed as the parts a
140
+ * {@link import("../core/llama-source.js").LlamaSource} is built from.
141
+ *
142
+ * One wiring belongs to one source. It is created with the config already read
143
+ * (so the source starts wired, not empty), and hands over ownership of
144
+ * everything it builds: the source closes the collector and the tailer it is
145
+ * holding, whether that happens at a swap or at its own `close()`. The wiring
146
+ * only ever CREATES, which is what keeps a single owner for a process group.
147
+ */
148
+ export interface ConfigWiring {
149
+ /** The parts as of the last read — the source's constructor arguments. */
150
+ readonly parts: LlamaLiveParts;
151
+ /**
152
+ * Registers the source's `reconfigure` and starts watching. The returned
153
+ * unsubscribe stops the watcher and is called by the source's `close()`, so a
154
+ * source that is spent can never be handed a newly spawned collector.
155
+ */
156
+ rewire(apply: (parts: LlamaLiveParts) => void): Unsubscribe;
157
+ }
158
+
159
+ /**
160
+ * The cheap identity of a file: enough to say "nothing about this has changed"
161
+ * without opening it, and `null` when it is not there at all.
162
+ *
163
+ * Content is deliberately not hashed. The point of this gate is that the common
164
+ * case — a watch event that concerns some other file, or the second and third
165
+ * events of one save — costs a single `stat` and stops there.
166
+ *
167
+ * The tuple is wider than a plain mtime+size for two reasons. `ctime` is what
168
+ * catches a `chmod o+w` or a `chown`, which change neither of those and are
169
+ * precisely the edits the security gate exists to refuse; the mode and the
170
+ * owner ride along beside it because they are what the refusal is ABOUT, and a
171
+ * filesystem with a coarse `ctime` should not be able to hide them. And the
172
+ * timestamps are read in nanoseconds rather than milliseconds, which closes the
173
+ * one blind spot a millisecond tuple has — two writes of equal size, to the
174
+ * same inode, inside the same millisecond.
175
+ */
176
+ function readIdentity(path: string): string | null {
177
+ try {
178
+ const stat = statSync(path, { bigint: true });
179
+ return [
180
+ stat.dev,
181
+ stat.ino,
182
+ stat.mtimeNs,
183
+ stat.ctimeNs,
184
+ stat.size,
185
+ stat.mode,
186
+ stat.uid,
187
+ stat.gid,
188
+ ].join(":");
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
193
+
194
+ /** True when two argvs are the same command, token for token. */
195
+ function sameCommand(a: readonly string[], b: readonly string[]): boolean {
196
+ return a.length === b.length && a.every((token, index) => token === b[index]);
197
+ }
198
+
199
+ /** The control actions, in the order the dashboard renders them. */
200
+ const CONTROL_ACTIONS: readonly ServiceAction[] = ["start", "stop", "restart"];
201
+
202
+ /** True when two consented control sets declare the same commands for the same actions. */
203
+ function sameControls(a: ServiceControlCommands, b: ServiceControlCommands): boolean {
204
+ return CONTROL_ACTIONS.every((action) => {
205
+ const left = a[action];
206
+ const right = b[action];
207
+ if (left === undefined || right === undefined) return left === right;
208
+ return sameCommand(left, right);
209
+ });
210
+ }
211
+
212
+ /** The default directory watch: non-persistent, so it never holds the process open. */
213
+ function watchDirectory(
214
+ directory: string,
215
+ onEvent: (filename: string | null, lost: boolean) => void,
216
+ ): Closable {
217
+ const watcher: FSWatcher = nodeWatch(directory, { persistent: false }, (_event, filename) =>
218
+ onEvent(typeof filename === "string" ? filename : null, false),
219
+ );
220
+ // A watch that fails — its directory removed, or a transient error on a
221
+ // filesystem that cannot sustain one — emits an error rather than throwing,
222
+ // and is finished afterwards. Reported as a LOSS, so the wiring replaces it
223
+ // instead of holding a closed handle and going quietly deaf.
224
+ watcher.on("error", () => {
225
+ watcher.close();
226
+ onEvent(null, true);
227
+ });
228
+ return watcher;
229
+ }
230
+
231
+ /** The default settle: one unref'd timer, armed by an event and never by a clock. */
232
+ function settleWithTimer(run: () => void): Unsubscribe {
233
+ const timer = setTimeout(run, SETTLE_MS);
234
+ timer.unref?.();
235
+ return () => clearTimeout(timer);
236
+ }
237
+
238
+ /**
239
+ * The deepest existing directory at or above `directory`, within
240
+ * {@link MAX_ANCESTORS} steps, or `null` when none of them exists.
241
+ *
242
+ * The climb is bounded because the point of it is a config directory that has
243
+ * not been created yet (`~/.config/steward`), not a home directory that is
244
+ * missing — and every step up widens the watch to files that have nothing to do
245
+ * with Steward.
246
+ */
247
+ function nearestExistingDirectory(directory: string): string | null {
248
+ let current = directory;
249
+ for (let step = 0; step <= MAX_ANCESTORS; step += 1) {
250
+ try {
251
+ if (statSync(current).isDirectory()) return current;
252
+ } catch {
253
+ // Not there — try its parent.
254
+ }
255
+ const parent = dirname(current);
256
+ if (parent === current) return null;
257
+ current = parent;
258
+ }
259
+ return null;
260
+ }
261
+
262
+ export function createConfigWiring(options: ConfigWiringOptions = {}): ConfigWiring {
263
+ const path = resolvePath(options.path ?? stewardConfigPath());
264
+ const read = options.read ?? ((target: string) => readStewardConfig({ path: target }));
265
+ const createCollector = options.createCollector ?? createHostCollector;
266
+ const createController = options.createController ?? createServiceController;
267
+ const createProbe =
268
+ options.createProbe ?? ((launchArgv: string[]) => createDriftProbe({ launchArgv }));
269
+ const createTailer =
270
+ options.createTailer ?? ((target: string) => createFileTailer({ path: target }));
271
+ const resolveLog =
272
+ options.resolveLog ?? ((config: LogPathConfig | null) => resolveLogPath({ config }));
273
+ const watch = options.watch ?? watchDirectory;
274
+ const settle = options.settle ?? settleWithTimer;
275
+ const warn = options.warn ?? ((message: string) => console.warn(message));
276
+
277
+ /** What each owned part was built from, so an unchanged key is not rebuilt. */
278
+ let collector: { command: string[]; intervalMs: number; provider: HostMetricsProvider } | null =
279
+ null;
280
+ let tailer: { path: string; instance: LogTailer } | null = null;
281
+ let probe: { launchArgv: string[]; instance: DriftProbe } | null = null;
282
+ let controller: { commands: ServiceControlCommands; instance: ServiceController } | null = null;
283
+
284
+ /**
285
+ * The parts for one config, reusing every running part whose inputs are
286
+ * unchanged.
287
+ *
288
+ * A part dropped here is NOT closed here: it is left out of the returned
289
+ * parts, and the source closes what it was holding when it takes them. One
290
+ * owner, one close — the alternative (closing on the way out AND on the swap)
291
+ * is how a live collector gets killed by a config edit that did not touch it.
292
+ */
293
+ function build(config: StewardConfig | null): LlamaLiveParts {
294
+ const parts: LlamaLiveParts = {};
295
+
296
+ // Topology is declared, not measured, so it is known as soon as the config
297
+ // is read — independent of whether the collector below was ever consented.
298
+ // It used to ride along with `host`, which meant a config declaring
299
+ // `discrete` was ignored until its collector was approved, and the dashboard
300
+ // drew the wrong gauge set in the meantime.
301
+ if (config !== null) parts.topology = config.memoryTopology;
302
+
303
+ // The collector: keyed on its exact argv and its declared cadence, the two
304
+ // things the running child was started with. Consent is re-checked here
305
+ // rather than trusted from last time, so a command whose hash disappeared
306
+ // from the artifact stops being run instead of riding on the old approval.
307
+ if (config !== null && hostCollectorConsented(config)) {
308
+ const { command, intervalMs } = config.hostCollector;
309
+ if (
310
+ collector === null ||
311
+ collector.intervalMs !== intervalMs ||
312
+ !sameCommand(collector.command, command)
313
+ ) {
314
+ collector = {
315
+ command: [...command],
316
+ intervalMs,
317
+ provider: createCollector(command, intervalMs),
318
+ };
319
+ }
320
+ parts.host = {
321
+ provider: collector.provider,
322
+ // Topology and the staleness horizon are read from the CURRENT config
323
+ // even when the child is reused: they are facts about the machine and
324
+ // the cadence, not state the collector holds.
325
+ topology: config.memoryTopology,
326
+ // Stale past 3× the collector's declared cadence — the readings drop to
327
+ // n/a rather than being held (maintainer decision, plan H3).
328
+ staleMs: 3 * intervalMs,
329
+ };
330
+ } else {
331
+ collector = null;
332
+ }
333
+
334
+ // Control: per-action, and only what the operator approved. A rewritten
335
+ // command drops out of this set until its new hash is consented to, which is
336
+ // the security gate doing its job — and `consentDrift` below is what stops
337
+ // that looking like a machine that was never set up.
338
+ const commands = config === null ? {} : consentedControls(config);
339
+ if (Object.keys(commands).length === 0) {
340
+ controller = null;
341
+ } else {
342
+ if (controller === null || !sameControls(controller.commands, commands)) {
343
+ controller = { commands, instance: createController(commands) };
344
+ }
345
+ parts.control = controller.instance;
346
+ }
347
+
348
+ // The drift baseline: keyed on the recorded argv, because that is all the
349
+ // probe is built from. Reusing it keeps its per-pid cache, so an unrelated
350
+ // config edit does not cost a `ps` per snapshot until it warms again.
351
+ const launchArgv = config?.llama?.launchArgv ?? null;
352
+ if (launchArgv === null) {
353
+ probe = null;
354
+ } else {
355
+ if (probe === null || !sameCommand(probe.launchArgv, launchArgv)) {
356
+ probe = { launchArgv: [...launchArgv], instance: createProbe(launchArgv) };
357
+ }
358
+ parts.probeDrift = probe.instance;
359
+ }
360
+
361
+ if (config !== null) parts.consentDrift = consentDrift(config);
362
+
363
+ // The log path is not config-only: `STEWARD_LOG_FILE` and the platform
364
+ // convention still resolve without an artifact, so losing the config does
365
+ // not necessarily lose the console. The tailer is keyed on the resolved
366
+ // path, so a config that changes anything else leaves the tail — and the
367
+ // slot occupancy folded out of it — completely untouched.
368
+ const logPath = resolveLog(config?.log ?? null);
369
+ if (logPath === null) {
370
+ tailer = null;
371
+ } else {
372
+ if (tailer === null || tailer.path !== logPath) {
373
+ tailer = { path: logPath, instance: createTailer(logPath) };
374
+ }
375
+ parts.logTail = tailer.instance;
376
+ }
377
+
378
+ return parts;
379
+ }
380
+
381
+ let identity = readIdentity(path);
382
+ let parts = build(identity === null ? null : read(path));
383
+
384
+ let sink: ((next: LlamaLiveParts) => void) | null = null;
385
+ let watcher: Closable | null = null;
386
+ let watching: string | null = null;
387
+ let cancelSettle: Unsubscribe | null = null;
388
+ let stopped = false;
389
+ /** Watches the OS has dropped on us, capped by {@link MAX_WATCH_LOSSES}. */
390
+ let losses = 0;
391
+
392
+ /**
393
+ * One re-read, gated on the file's identity.
394
+ *
395
+ * The gate is the reason a chatty watcher is cheap, and the reason an event
396
+ * that concerns some other file in the directory costs a `stat` and nothing
397
+ * else. Past it, a config that is absent, refused (foreign owner,
398
+ * world-writable) or malformed all arrive here as `null` — and are treated
399
+ * identically, because in all three cases Steward has no artifact it is
400
+ * entitled to act on and must stop acting on the last one.
401
+ */
402
+ function refresh(): void {
403
+ if (stopped) return;
404
+ const next = readIdentity(path);
405
+ if (next === identity) return;
406
+ identity = next;
407
+ parts = build(next === null ? null : read(path));
408
+ sink?.(parts);
409
+ }
410
+
411
+ /** Arms (or re-arms) the watch on the deepest directory that exists today. */
412
+ function arm(): void {
413
+ if (stopped) return;
414
+ const directory = nearestExistingDirectory(dirname(path));
415
+ if (directory === null) {
416
+ warn(`[steward] cannot watch ${path} for changes: no directory above it exists`);
417
+ return;
418
+ }
419
+ if (watcher !== null && watching === directory) return;
420
+ watcher?.close();
421
+ watcher = null;
422
+ watching = null;
423
+ try {
424
+ watcher = watch(directory, (filename, lost) => onEvent(directory, filename, lost));
425
+ watching = directory;
426
+ } catch (error) {
427
+ // A platform or filesystem that cannot watch (some network mounts) is a
428
+ // degrade, not a failure: the config read at startup stands, and the
429
+ // operator is told why a later `/steward_initialize` needs a restart.
430
+ const detail = error instanceof Error ? error.message : String(error);
431
+ warn(`[steward] cannot watch ${path} for changes (${detail}); a change needs a restart`);
432
+ }
433
+ }
434
+
435
+ function onEvent(directory: string, filename: string | null, lost = false): void {
436
+ if (stopped) return;
437
+ if (lost) {
438
+ // The handle is already closed and will send nothing more. Dropping the
439
+ // reference is what lets `arm` replace it — a watch whose directory still
440
+ // exists would otherwise look like one that is already in the right
441
+ // place, and Steward would stop noticing config changes without a word.
442
+ watcher = null;
443
+ watching = null;
444
+ losses += 1;
445
+ if (losses > MAX_WATCH_LOSSES) {
446
+ warn(
447
+ `[steward] gave up watching ${path} after ${losses} dropped watches; ` +
448
+ "a config change now needs a restart",
449
+ );
450
+ return;
451
+ }
452
+ }
453
+ // Re-checked on every event, because an event is also how we learn that the
454
+ // shape of the tree changed: the config's directory may have just been
455
+ // created under the ancestor we settled for, or removed out from under us.
456
+ // It costs one `stat` and is a no-op while the right directory is watched.
457
+ arm();
458
+ // Inside the config's own directory, only the config's own name is our news
459
+ // — a `filename` we were not given (some platforms omit it, and the lost
460
+ // watch above reports none) is treated as ours.
461
+ if (directory === dirname(path) && filename !== null) {
462
+ if (resolvePath(directory, filename) !== path) return;
463
+ }
464
+ cancelSettle?.();
465
+ cancelSettle = settle(() => {
466
+ cancelSettle = null;
467
+ refresh();
468
+ });
469
+ }
470
+
471
+ return {
472
+ get parts(): LlamaLiveParts {
473
+ return parts;
474
+ },
475
+
476
+ rewire(apply: (next: LlamaLiveParts) => void): Unsubscribe {
477
+ sink = apply;
478
+ arm();
479
+ return () => {
480
+ stopped = true;
481
+ sink = null;
482
+ cancelSettle?.();
483
+ cancelSettle = null;
484
+ watcher?.close();
485
+ watcher = null;
486
+ watching = null;
487
+ };
488
+ },
489
+ };
490
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Re-validates, every snapshot, that `llama-server` is still running with the
3
+ * flags `steward.json` says it was launched with — the Node body behind
4
+ * {@link DriftProbe}.
5
+ *
6
+ * It is handed the pid the snapshot ALREADY resolved for the SERVICE block and
7
+ * reads that process's live command line with `ps`. Taking the pid rather than
8
+ * a host and port is deliberate: re-running `lsof` here would cost a second
9
+ * ~60 ms subprocess on every poll, and — because the two lookups would land at
10
+ * different instants — a restart could leave the SERVICE block and the drift
11
+ * notice describing two different processes. One lookup, one process, one story.
12
+ * The comparison itself is pure and lives in `core/drift.ts`, so what counts as
13
+ * drift can be proven without a real server.
14
+ *
15
+ * Two properties matter more than anything this module does:
16
+ *
17
+ * - It NEVER throws and never reports a verdict it did not reach. No pid, no
18
+ * `ps`, a permission error, an empty or truncated line — all of them are
19
+ * `unknown`, which renders nothing. Fabricating "clean" would restore the
20
+ * exact false all-clear this check exists to remove; fabricating "drifted"
21
+ * would nag a machine that is configured correctly, which spends the same
22
+ * trust.
23
+ * - It is cheap enough to run on every poll, without ever going quiet. The
24
+ * command line of a process cannot change while it lives, so a SUCCESSFUL
25
+ * read is cached and serves every later snapshot; a new (or vanished) pid
26
+ * drops it. A FAILED read is never cached as an answer — it backs off and is
27
+ * retried, because a check that stopped running while still rendering
28
+ * nothing is indistinguishable from a machine that is fine.
29
+ *
30
+ * macOS/Linux only, like the service probe: a platform without `ps` simply
31
+ * reports `unknown`, which is the honest answer.
32
+ */
33
+
34
+ import { execFile } from "node:child_process";
35
+ import { promisify } from "node:util";
36
+ import type { DriftProbe, LaunchDrift } from "../core/drift.js";
37
+ import { diffLaunchArgv, unknownLaunchDrift } from "../core/drift.js";
38
+
39
+ const run = promisify(execFile);
40
+
41
+ /** No single probe command may hang the metrics poll. */
42
+ const PROBE_TIMEOUT_MS = 1500;
43
+
44
+ /**
45
+ * How many polls a failed read waits before it is retried, at most. A read that
46
+ * keeps failing (a `ps` that is not there, a process we may not inspect) backs
47
+ * off rather than shelling out every 1.6 s — but it NEVER gives up, because a
48
+ * check that has quietly stopped running looks exactly like a compliant machine.
49
+ */
50
+ const MAX_RETRY_SKIP = 8;
51
+
52
+ /**
53
+ * Reads one process's live command line — the argv joined on single spaces, as
54
+ * `ps` prints it — or `null` when it cannot be read. Injected in tests, so the
55
+ * probe is proven against a fake process list rather than this machine's real
56
+ * `llama-server`.
57
+ */
58
+ export type ArgvReader = (pid: number) => Promise<string | null>;
59
+
60
+ export interface DriftProbeOptions {
61
+ /** The argv `/steward_initialize` recorded, from `steward.json`'s `llama` block. */
62
+ launchArgv: readonly string[];
63
+ /** Overrides the `ps` command-line read. */
64
+ readArgv?: ArgvReader;
65
+ }
66
+
67
+ /**
68
+ * One process's full command line, or `null`.
69
+ *
70
+ * `-ww` is not decoration. `ps` truncates to the display width, and while both
71
+ * macOS and Linux hand back the whole line when stdout is a pipe (verified on
72
+ * this machine: a 10 KB argv came back whole), `-ww` asks for unlimited width
73
+ * explicitly rather than relying on that. Anything the flag fails to prevent is
74
+ * still caught downstream, where a line cut mid-token is reported `unknown`.
75
+ *
76
+ * `args=` (rather than `command=`) is the portable spelling of "the argv" on
77
+ * both platforms; the trailing `=` drops the header row.
78
+ */
79
+ async function processArgv(pid: number): Promise<string | null> {
80
+ try {
81
+ const { stdout } = await run("ps", ["-ww", "-o", "args=", "-p", String(pid)], {
82
+ timeout: PROBE_TIMEOUT_MS,
83
+ });
84
+ // A process that exited between the lsof and the ps prints nothing and
85
+ // exits non-zero; a header-only read is empty too. Either way: no line.
86
+ const line = stdout.split("\n")[0]?.trim() ?? "";
87
+ return line === "" ? null : line;
88
+ } catch {
89
+ // ps missing, the pid gone (exit 1), a permission error, or timed out.
90
+ return null;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * A drift probe over one recorded argv, with a per-pid cache of the live
96
+ * command line. Build it only when `steward.json` carries a `llama` block: with
97
+ * nothing recorded there is nothing to compare against, and the caller should
98
+ * omit the probe entirely rather than pass an empty argv.
99
+ */
100
+ export function createDriftProbe(options: DriftProbeOptions): DriftProbe {
101
+ const launchArgv = [...options.launchArgv];
102
+ const readArgv = options.readArgv ?? processArgv;
103
+ /**
104
+ * The command line of the pid we last read SUCCESSFULLY, so `ps` runs once
105
+ * per process. Only successes are cached: a failed read is a gap in what we
106
+ * know, and caching it would turn one timed-out `ps` into a check that never
107
+ * runs again for that process — silently, since `unknown` renders nothing.
108
+ */
109
+ let cache: { pid: number; argv: string } | null = null;
110
+ /** Consecutive failed reads of the current pid, and the polls left to skip. */
111
+ let failure: { pid: number; consecutive: number; skip: number } | null = null;
112
+
113
+ return async (pid: number | null): Promise<LaunchDrift> => {
114
+ if (pid === null) {
115
+ // A pid we never resolved is not evidence about the flags. Both caches go
116
+ // with it: a pid is reused, so the next one is a different process.
117
+ cache = null;
118
+ failure = null;
119
+ return unknownLaunchDrift("the listening process could not be identified");
120
+ }
121
+ if (cache !== null && cache.pid !== pid) cache = null;
122
+ if (failure !== null && failure.pid !== pid) failure = null;
123
+
124
+ if (cache !== null) return diffLaunchArgv(launchArgv, cache.argv);
125
+ if (failure !== null && failure.skip > 0) {
126
+ // Backing off, not giving up: the read is retried once the skips run out.
127
+ failure.skip -= 1;
128
+ return unknownLaunchDrift("the launch command line could not be read");
129
+ }
130
+
131
+ let argv: string | null;
132
+ try {
133
+ argv = await readArgv(pid);
134
+ } catch {
135
+ argv = null;
136
+ }
137
+ if (argv === null) {
138
+ // A timed-out `ps`, an EAGAIN, or the window between `lsof` finding the
139
+ // port and the process being inspectable while it starts up: all
140
+ // transient, all worth another look on a later poll.
141
+ const consecutive = (failure?.consecutive ?? 0) + 1;
142
+ failure = { pid, consecutive, skip: Math.min(consecutive - 1, MAX_RETRY_SKIP) };
143
+ return unknownLaunchDrift("the launch command line could not be read");
144
+ }
145
+
146
+ failure = null;
147
+ cache = { pid, argv };
148
+ return diffLaunchArgv(launchArgv, argv);
149
+ };
150
+ }