@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.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/core/disconnected-source.ts +110 -0
- package/core/drift.ts +247 -0
- package/core/format.ts +317 -0
- package/core/host-metrics.ts +121 -0
- package/core/llama-config.ts +72 -0
- package/core/llama-connection.ts +215 -0
- package/core/llama-models.ts +261 -0
- package/core/llama-slots.ts +104 -0
- package/core/llama-source.ts +1523 -0
- package/core/log-parse.ts +440 -0
- package/core/model-color.ts +59 -0
- package/core/select.ts +2923 -0
- package/core/slot-activity.ts +658 -0
- package/core/source.ts +84 -0
- package/core/state.ts +609 -0
- package/core/status-widget.ts +222 -0
- package/core/temperature.ts +149 -0
- package/core/types.ts +431 -0
- package/index.ts +503 -0
- package/package.json +51 -0
- package/server/api.ts +216 -0
- package/server/assets.ts +198 -0
- package/server/config-wiring.ts +490 -0
- package/server/drift-probe.ts +150 -0
- package/server/host-collector.ts +272 -0
- package/server/index.ts +228 -0
- package/server/log-tailer.ts +432 -0
- package/server/service-control.ts +337 -0
- package/server/service-probe.ts +71 -0
- package/server/steward-config.ts +430 -0
- package/setup/init-prompt.ts +214 -0
- package/setup/steward-setup.d.mts +16 -0
- package/setup/steward-setup.mjs +1398 -0
- package/ui/components/console.ts +511 -0
- package/ui/components/gauges.ts +120 -0
- package/ui/components/metrics.ts +63 -0
- package/ui/components/models.ts +296 -0
- package/ui/components/service.ts +358 -0
- package/ui/components/slots.ts +114 -0
- package/ui/components/sparkline.ts +59 -0
- package/ui/components/toolbar.ts +211 -0
- package/ui/dom.ts +120 -0
- package/ui/favicon.svg +17 -0
- package/ui/index.html +34 -0
- package/ui/main.ts +678 -0
- 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
|
+
}
|