@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,1398 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `steward-setup.mjs` — the deterministic half of the `/initialize-steward` skill.
|
|
4
|
+
*
|
|
5
|
+
* The skill's judgement (which collector fits this machine, which launch
|
|
6
|
+
* mechanism it uses, what to propose to the operator) belongs to the model. The
|
|
7
|
+
* parts that must NOT be improvised — the consent hashes, the file mode, the
|
|
8
|
+
* atomic write, the empirical proof that a collector actually streams — live
|
|
9
|
+
* here, where they are testable and cannot drift with the wording of a prompt.
|
|
10
|
+
*
|
|
11
|
+
* Nothing in this file starts, stops, restarts, or reconfigures a service, and
|
|
12
|
+
* nothing writes outside the Steward config path it is given. `apply` is the
|
|
13
|
+
* only subcommand that writes at all, it always backs up first, and it always
|
|
14
|
+
* prints the exact revert command.
|
|
15
|
+
*
|
|
16
|
+
* check-argv --argv-json <json> | --pid <n> compliance of a launch argv
|
|
17
|
+
* check-plist --plist <path> --expect-log <path> redirect, before applying
|
|
18
|
+
* check-log --log <path> both streams in one file
|
|
19
|
+
* probe-collector --command-json <json> [...] run a collector, prove it streams
|
|
20
|
+
* plan --input <file|-> validated config + hashes + diff
|
|
21
|
+
* apply --input <file|-> backup + atomic 0600 write
|
|
22
|
+
* verify [--config <path>] [...] re-check everything, post-apply
|
|
23
|
+
*
|
|
24
|
+
* Run `node steward-setup.mjs help` for the full flag list.
|
|
25
|
+
*
|
|
26
|
+
* Exit codes: 0 = all checks passed, 1 = at least one FAIL, 2 = usage error.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
30
|
+
import { createHash } from "node:crypto";
|
|
31
|
+
import {
|
|
32
|
+
chmodSync,
|
|
33
|
+
copyFileSync,
|
|
34
|
+
existsSync,
|
|
35
|
+
mkdirSync,
|
|
36
|
+
readFileSync,
|
|
37
|
+
renameSync,
|
|
38
|
+
statSync,
|
|
39
|
+
writeFileSync,
|
|
40
|
+
} from "node:fs";
|
|
41
|
+
import { homedir } from "node:os";
|
|
42
|
+
import { dirname, join } from "node:path";
|
|
43
|
+
|
|
44
|
+
/** The schema tag every collector line must carry (see `core/host-metrics.ts`). */
|
|
45
|
+
const HOST_METRICS_SCHEMA = "steward.hostmetrics/1";
|
|
46
|
+
|
|
47
|
+
/** The metric fields the host band can render. Every one is `number | null`. */
|
|
48
|
+
const METRIC_FIELDS = [
|
|
49
|
+
"gpuUtil",
|
|
50
|
+
"gpuTempC",
|
|
51
|
+
"cpuUtil",
|
|
52
|
+
"cpuTempC",
|
|
53
|
+
"ramUsedGB",
|
|
54
|
+
"ramTotalGB",
|
|
55
|
+
"vramUsedGB",
|
|
56
|
+
"vramTotalGB",
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/** VRAM is never synthesised on unified memory — these must be absent there. */
|
|
60
|
+
const VRAM_FIELDS = ["vramUsedGB", "vramTotalGB"];
|
|
61
|
+
|
|
62
|
+
/** The control actions `steward.json` declares together. */
|
|
63
|
+
const CONTROL_ACTIONS = ["start", "stop", "restart"];
|
|
64
|
+
|
|
65
|
+
/** Longest collector line assembled before it is discarded, mirroring the reader's cap. */
|
|
66
|
+
const MAX_LINE_LENGTH = 64 * 1024;
|
|
67
|
+
|
|
68
|
+
/** Grace between the SIGTERM that ends a probe and the SIGKILL that follows it. */
|
|
69
|
+
const KILL_ESCALATION_MS = 750;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A router line that llama.cpp wrote to **stderr**: its own levelled output,
|
|
73
|
+
* `0.08.955.549 I srv load: …`. Verified against a 172k-line live combined log
|
|
74
|
+
* (36,918 matches). Its presence is proof stderr reached the file.
|
|
75
|
+
*/
|
|
76
|
+
const ROUTER_STDERR_LINE = /^\d+\.\d{2}\.\d{3}\.\d{3} [A-Z] /u;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A child line the router FORWARDED, which it emits at `GGML_LOG_LEVEL_NONE` —
|
|
80
|
+
* i.e. to **stdout** — prefixed with the child's port: `[54241] …`. Verified on
|
|
81
|
+
* the same log (135,347 matches). Its presence is proof stdout reached the file.
|
|
82
|
+
*/
|
|
83
|
+
const ROUTER_STDOUT_LINE = /^\[\d+\] /u;
|
|
84
|
+
|
|
85
|
+
/** Flags that put `llama-server` in single-model mode, which Pi cannot drive. */
|
|
86
|
+
const SINGLE_MODEL_FLAGS = new Set(["-m", "--model", "-hf", "--hf-repo", "-hfr"]);
|
|
87
|
+
|
|
88
|
+
/* ------------------------------------------------------------------ *
|
|
89
|
+
* findings
|
|
90
|
+
* ------------------------------------------------------------------ */
|
|
91
|
+
|
|
92
|
+
/** One check's outcome. `fail` sets the exit code; `warn` and `ok` do not. */
|
|
93
|
+
function finding(level, message, detail) {
|
|
94
|
+
return { level, message, detail: detail ?? null };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const fail = (message, detail) => finding("fail", message, detail);
|
|
98
|
+
const warn = (message, detail) => finding("warn", message, detail);
|
|
99
|
+
const ok = (message, detail) => finding("ok", message, detail);
|
|
100
|
+
|
|
101
|
+
const LEVEL_MARK = { ok: " ok ", warn: " WARN ", fail: " FAIL " };
|
|
102
|
+
|
|
103
|
+
/** Prints findings as a readable block and reports whether any of them failed. */
|
|
104
|
+
function report(title, findings) {
|
|
105
|
+
process.stdout.write(`\n${title}\n${"-".repeat(title.length)}\n`);
|
|
106
|
+
if (findings.length === 0) process.stdout.write(" (nothing to check)\n");
|
|
107
|
+
for (const item of findings) {
|
|
108
|
+
process.stdout.write(`[${LEVEL_MARK[item.level]}] ${item.message}\n`);
|
|
109
|
+
if (item.detail !== null && item.detail !== "") {
|
|
110
|
+
for (const line of String(item.detail).split("\n")) {
|
|
111
|
+
process.stdout.write(` ${line}\n`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return findings.some((item) => item.level === "fail");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* ------------------------------------------------------------------ *
|
|
119
|
+
* argv parsing
|
|
120
|
+
* ------------------------------------------------------------------ */
|
|
121
|
+
|
|
122
|
+
function parseFlags(argv) {
|
|
123
|
+
const flags = new Map();
|
|
124
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
125
|
+
const token = argv[index];
|
|
126
|
+
if (!token.startsWith("--")) throw new UsageError(`unexpected argument: ${token}`);
|
|
127
|
+
const equals = token.indexOf("=");
|
|
128
|
+
if (equals > 0) {
|
|
129
|
+
flags.set(token.slice(2, equals), token.slice(equals + 1));
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const next = argv[index + 1];
|
|
133
|
+
if (next === undefined || next.startsWith("--")) {
|
|
134
|
+
flags.set(token.slice(2), "true");
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
flags.set(token.slice(2), next);
|
|
138
|
+
index += 1;
|
|
139
|
+
}
|
|
140
|
+
return flags;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
class UsageError extends Error {}
|
|
144
|
+
|
|
145
|
+
/** A required flag, or a usage error naming it. */
|
|
146
|
+
function required(flags, name) {
|
|
147
|
+
const value = flags.get(name);
|
|
148
|
+
if (value === undefined) throw new UsageError(`--${name} is required`);
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A positive integer flag with a default. */
|
|
153
|
+
function numberFlag(flags, name, fallback) {
|
|
154
|
+
const raw = flags.get(name);
|
|
155
|
+
if (raw === undefined) return fallback;
|
|
156
|
+
const value = Number(raw);
|
|
157
|
+
if (!Number.isFinite(value) || value <= 0) throw new UsageError(`--${name} must be a number > 0`);
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Parses a JSON flag value, or reads it from a file when it names one. */
|
|
162
|
+
function jsonFlag(flags, name) {
|
|
163
|
+
const raw = required(flags, name);
|
|
164
|
+
const text =
|
|
165
|
+
raw.trimStart().startsWith("[") || raw.trimStart().startsWith("{")
|
|
166
|
+
? raw
|
|
167
|
+
: readFileSync(raw, "utf8");
|
|
168
|
+
try {
|
|
169
|
+
return JSON.parse(text);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
throw new UsageError(`--${name} is not valid JSON: ${error.message}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Reads the proposal document from a file path, or from stdin for `-`. */
|
|
176
|
+
function readInput(spec) {
|
|
177
|
+
const text = spec === "-" ? readFileSync(0, "utf8") : readFileSync(spec, "utf8");
|
|
178
|
+
try {
|
|
179
|
+
return JSON.parse(text);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
throw new UsageError(`input is not valid JSON: ${error.message}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* ------------------------------------------------------------------ *
|
|
186
|
+
* shared validation
|
|
187
|
+
* ------------------------------------------------------------------ */
|
|
188
|
+
|
|
189
|
+
function isRecord(value) {
|
|
190
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A non-empty array of strings, or `null`. */
|
|
194
|
+
function asArgv(value) {
|
|
195
|
+
if (!Array.isArray(value) || value.length === 0) return null;
|
|
196
|
+
if (!value.every((part) => typeof part === "string")) return null;
|
|
197
|
+
return [...value];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The canonical consent hash: sha256 of the argv joined on single spaces.
|
|
202
|
+
* This MUST stay identical to `hashCommand` in `server/steward-config.ts` — a
|
|
203
|
+
* different join here would write consent entries Steward never matches, and
|
|
204
|
+
* every gauge and button would silently stay dark.
|
|
205
|
+
*/
|
|
206
|
+
export function hashCommand(command) {
|
|
207
|
+
return createHash("sha256").update(command.join(" ")).digest("hex");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Compliance verdict for a `llama-server` launch argv.
|
|
212
|
+
*
|
|
213
|
+
* Detection is from the ARGV, never from endpoint probing: in router mode with
|
|
214
|
+
* no model loaded, `/metrics?model=`, `/slots?model=` and `/props?model=` all
|
|
215
|
+
* return 400, so a live probe cannot tell a missing flag from an unloaded model.
|
|
216
|
+
*/
|
|
217
|
+
export function checkLaunchArgv(argv) {
|
|
218
|
+
const findings = [];
|
|
219
|
+
const tokens = argv.map((token) => String(token));
|
|
220
|
+
const flagName = (token) => (token.includes("=") ? token.slice(0, token.indexOf("=")) : token);
|
|
221
|
+
const names = new Set(tokens.map(flagName));
|
|
222
|
+
|
|
223
|
+
const single = tokens.filter((token) => SINGLE_MODEL_FLAGS.has(flagName(token)));
|
|
224
|
+
if (single.length > 0) {
|
|
225
|
+
findings.push(
|
|
226
|
+
fail(
|
|
227
|
+
"not router mode — a single-model flag is present",
|
|
228
|
+
`Found ${single.join(", ")}. Pi hard-requires router mode: start llama-server with\n` +
|
|
229
|
+
"--models-dir / --models-preset and no -m / --model / -hf.",
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
} else if (names.has("--models-dir") || names.has("--models-preset")) {
|
|
233
|
+
findings.push(ok("router mode — serves a model directory or preset file"));
|
|
234
|
+
} else {
|
|
235
|
+
// Absence of -m is not presence of a router. A bare `llama-server` has
|
|
236
|
+
// neither, and used to pass this check as "router mode" on the strength of
|
|
237
|
+
// what it did NOT contain — which reads as compliant to the operator and
|
|
238
|
+
// leaves Steward with an empty model catalogue.
|
|
239
|
+
findings.push(
|
|
240
|
+
warn(
|
|
241
|
+
"no model source in the argv — this may not be a router",
|
|
242
|
+
"Router mode needs --models-dir and/or --models-preset. Neither is present,\n" +
|
|
243
|
+
"and neither is a single-model flag, so what this server serves is unclear.",
|
|
244
|
+
),
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (names.has("--metrics")) {
|
|
249
|
+
findings.push(ok("--metrics is present (it is OFF by default)"));
|
|
250
|
+
} else {
|
|
251
|
+
findings.push(
|
|
252
|
+
fail(
|
|
253
|
+
"--metrics is missing",
|
|
254
|
+
"Throughput and request counters need it, and it defaults to disabled.\n" +
|
|
255
|
+
"Equivalent: the LLAMA_ARG_ENDPOINT_METRICS=1 environment variable.",
|
|
256
|
+
),
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (names.has("--no-slots")) {
|
|
261
|
+
findings.push(
|
|
262
|
+
fail(
|
|
263
|
+
"--no-slots disables the slots endpoint",
|
|
264
|
+
"The slots panel reads /slots. Slots are ON by default — remove this flag.",
|
|
265
|
+
),
|
|
266
|
+
);
|
|
267
|
+
} else {
|
|
268
|
+
findings.push(ok("slots are not disabled (--slots is ON by default)"));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (names.has("--log-file") || tokens.some((token) => token.includes("LLAMA_ARG_LOG_FILE"))) {
|
|
272
|
+
findings.push(
|
|
273
|
+
fail(
|
|
274
|
+
"--log-file is present and MUST be removed",
|
|
275
|
+
"unset_reserved_args does not strip LLAMA_ARG_LOG_FILE, so the router copies\n" +
|
|
276
|
+
"--log-file into every child's spawn args, and set_file opens it with\n" +
|
|
277
|
+
'fopen(path, "w") — truncate, not append. The router and N children then each\n' +
|
|
278
|
+
"truncate the same file and write at independent offsets, which duplicates and\n" +
|
|
279
|
+
"corrupts lines. Redirect the process's stdout AND stderr to one file instead.",
|
|
280
|
+
),
|
|
281
|
+
);
|
|
282
|
+
} else {
|
|
283
|
+
findings.push(ok("--log-file is absent (it corrupts the log in router mode)"));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return findings;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Reads one process's live command line as `ps` prints it — the argv joined on
|
|
291
|
+
* single spaces. Read-only: it inspects a process, it never signals one.
|
|
292
|
+
*/
|
|
293
|
+
function processArgv(pid) {
|
|
294
|
+
const result = spawnSync("ps", ["-ww", "-o", "args=", "-p", String(pid)], {
|
|
295
|
+
encoding: "utf8",
|
|
296
|
+
timeout: 5000,
|
|
297
|
+
});
|
|
298
|
+
if (result.status !== 0) return null;
|
|
299
|
+
const line = (result.stdout ?? "").split("\n")[0]?.trim() ?? "";
|
|
300
|
+
return line === "" ? null : line;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** A `ps` line that is a placeholder rather than a command (`(python3)`, `[kthread]`). */
|
|
304
|
+
const PLACEHOLDER_COMMAND = /^\(.*\)$|^\[.*\]$/u;
|
|
305
|
+
|
|
306
|
+
function tokenizeArgv(line) {
|
|
307
|
+
return line.split(/\s+/u).filter((token) => token !== "");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Splits an argv into flag groups so `--port 8080` is one unit. Mirrors
|
|
312
|
+
* `groupArgv` in `core/drift.ts` — see the parity test in
|
|
313
|
+
* `steward-setup.test.ts`, which is what keeps the two from diverging again.
|
|
314
|
+
*/
|
|
315
|
+
function groupArgv(tokens) {
|
|
316
|
+
const groups = [];
|
|
317
|
+
const leading = [];
|
|
318
|
+
let current = null;
|
|
319
|
+
|
|
320
|
+
const flush = () => {
|
|
321
|
+
if (current !== null) groups.push(current.join(" "));
|
|
322
|
+
current = null;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
for (const token of tokens) {
|
|
326
|
+
if (token.startsWith("-") && token !== "-" && token !== "--") {
|
|
327
|
+
flush();
|
|
328
|
+
const equals = token.indexOf("=");
|
|
329
|
+
current = equals > 0 ? [token.slice(0, equals), token.slice(equals + 1)] : [token];
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (current === null) leading.push(token);
|
|
333
|
+
else current.push(token);
|
|
334
|
+
}
|
|
335
|
+
flush();
|
|
336
|
+
|
|
337
|
+
return { program: leading.join(" "), groups };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function missingFrom(a, b) {
|
|
341
|
+
const remaining = new Map();
|
|
342
|
+
for (const group of b) remaining.set(group, (remaining.get(group) ?? 0) + 1);
|
|
343
|
+
|
|
344
|
+
const missing = [];
|
|
345
|
+
for (const group of a) {
|
|
346
|
+
const count = remaining.get(group) ?? 0;
|
|
347
|
+
if (count > 0) remaining.set(group, count - 1);
|
|
348
|
+
else missing.push(group);
|
|
349
|
+
}
|
|
350
|
+
return missing;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Compares a recorded argv against a live `ps` line the way Steward's dashboard
|
|
355
|
+
* does, returning `clean` | `drifted` | `unknown`.
|
|
356
|
+
*
|
|
357
|
+
* This exists because the obvious implementation — `recorded.join(" ") === live`
|
|
358
|
+
* — fabricates drift on two machines that are configured correctly: one whose
|
|
359
|
+
* argv carries a quoted value with a space in it (`--alias "Fast Model"`, which
|
|
360
|
+
* `ps` hands back with the quoting gone), and one where a flag was re-ordered
|
|
361
|
+
* without changing what the server does. Reporting FAIL there contradicts the
|
|
362
|
+
* dashboard in the same session, and `core/drift.ts` is explicit that a false
|
|
363
|
+
* alarm costs exactly as much trust as a missed one.
|
|
364
|
+
*/
|
|
365
|
+
export function diffRecordedArgv(recorded, observed) {
|
|
366
|
+
const expectedTokens = recorded.filter((token) => token !== "");
|
|
367
|
+
if (expectedTokens.length === 0) {
|
|
368
|
+
return { status: "unknown", reason: "no launch command was recorded for this machine" };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const line = observed.trim();
|
|
372
|
+
if (line === "" || PLACEHOLDER_COMMAND.test(line)) {
|
|
373
|
+
return { status: "unknown", reason: "the process list reported no command line" };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const expected = expectedTokens.join(" ");
|
|
377
|
+
if (line === expected) return { status: "clean", added: [], removed: [], program: null };
|
|
378
|
+
|
|
379
|
+
// Cut mid-token: `ps` gave us less than the process holds, so there is no
|
|
380
|
+
// verdict to reach. A line that stops exactly at a token boundary is NOT
|
|
381
|
+
// truncation — that is the most likely real edit (dropping the last flag).
|
|
382
|
+
if (expected.startsWith(line) && expected.charAt(line.length) !== " ") {
|
|
383
|
+
return { status: "unknown", reason: "the process list truncated the command line" };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const before = groupArgv(expectedTokens);
|
|
387
|
+
const after = groupArgv(tokenizeArgv(line));
|
|
388
|
+
const added = missingFrom(after.groups, before.groups);
|
|
389
|
+
const removed = missingFrom(before.groups, after.groups);
|
|
390
|
+
const program = before.program === after.program ? null : after.program;
|
|
391
|
+
|
|
392
|
+
if (added.length === 0 && removed.length === 0 && program === null) {
|
|
393
|
+
return { status: "clean", added: [], removed: [], program: null };
|
|
394
|
+
}
|
|
395
|
+
return { status: "drifted", added, removed, program };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/* ------------------------------------------------------------------ *
|
|
399
|
+
* proposal validation
|
|
400
|
+
* ------------------------------------------------------------------ */
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Validates a proposal document and derives the `steward.json` Steward will
|
|
404
|
+
* read, with the consent map computed from the commands it declares.
|
|
405
|
+
*
|
|
406
|
+
* The proposal is the same shape as `steward.json` MINUS `consent`: consent is
|
|
407
|
+
* never authored by hand, because a hand-written hash that does not match its
|
|
408
|
+
* command is indistinguishable — to the dashboard — from a command nobody
|
|
409
|
+
* approved, and it fails silently.
|
|
410
|
+
*/
|
|
411
|
+
export function buildConfig(proposal) {
|
|
412
|
+
const findings = [];
|
|
413
|
+
if (!isRecord(proposal)) {
|
|
414
|
+
return { config: null, findings: [fail("the proposal is not a JSON object")] };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const topology = proposal.memoryTopology;
|
|
418
|
+
if (topology !== "unified" && topology !== "discrete") {
|
|
419
|
+
findings.push(fail('memoryTopology must be "unified" or "discrete"'));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const collectorBlock = isRecord(proposal.hostCollector) ? proposal.hostCollector : null;
|
|
423
|
+
const collector = collectorBlock === null ? null : asArgv(collectorBlock.command);
|
|
424
|
+
const intervalMs = collectorBlock === null ? undefined : collectorBlock.intervalMs;
|
|
425
|
+
if (collector === null) {
|
|
426
|
+
findings.push(fail("hostCollector.command must be a non-empty array of strings"));
|
|
427
|
+
}
|
|
428
|
+
if (typeof intervalMs !== "number" || !Number.isFinite(intervalMs) || intervalMs <= 0) {
|
|
429
|
+
findings.push(fail("hostCollector.intervalMs must be a number > 0"));
|
|
430
|
+
}
|
|
431
|
+
if (collector !== null) findings.push(...checkCollectorCommand(collector));
|
|
432
|
+
|
|
433
|
+
let control = null;
|
|
434
|
+
if (proposal.control !== undefined) {
|
|
435
|
+
const block = isRecord(proposal.control) ? proposal.control : null;
|
|
436
|
+
const parsed = {};
|
|
437
|
+
let bad = false;
|
|
438
|
+
for (const action of CONTROL_ACTIONS) {
|
|
439
|
+
const argv = block === null ? null : asArgv(block[action]);
|
|
440
|
+
if (argv === null) bad = true;
|
|
441
|
+
else parsed[action] = argv;
|
|
442
|
+
}
|
|
443
|
+
if (bad) {
|
|
444
|
+
findings.push(
|
|
445
|
+
fail(
|
|
446
|
+
"control needs all three of start, stop and restart",
|
|
447
|
+
"Steward drops a half-written control block entirely. A machine that can only\n" +
|
|
448
|
+
"be restarted is expressed by consenting to restart alone, not by omitting keys.",
|
|
449
|
+
),
|
|
450
|
+
);
|
|
451
|
+
} else {
|
|
452
|
+
control = parsed;
|
|
453
|
+
findings.push(...checkControl(parsed));
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
let llama = null;
|
|
458
|
+
if (proposal.llama !== undefined) {
|
|
459
|
+
const block = isRecord(proposal.llama) ? proposal.llama : null;
|
|
460
|
+
const launchArgv = block === null ? null : asArgv(block.launchArgv);
|
|
461
|
+
if (launchArgv === null) {
|
|
462
|
+
findings.push(fail("llama.launchArgv must be a non-empty array of strings"));
|
|
463
|
+
} else {
|
|
464
|
+
llama = {
|
|
465
|
+
launchArgv,
|
|
466
|
+
mechanism: typeof block.mechanism === "string" ? block.mechanism : null,
|
|
467
|
+
label: typeof block.label === "string" ? block.label : null,
|
|
468
|
+
};
|
|
469
|
+
findings.push(...checkLaunchArgv(launchArgv));
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
let log = null;
|
|
474
|
+
if (proposal.log !== undefined) {
|
|
475
|
+
const block = isRecord(proposal.log) ? proposal.log : null;
|
|
476
|
+
const path = block !== null && typeof block.path === "string" ? block.path.trim() : "";
|
|
477
|
+
if (path === "") findings.push(fail("log.path must be a non-empty string"));
|
|
478
|
+
else {
|
|
479
|
+
log = { path };
|
|
480
|
+
if (path.startsWith("/tmp/")) {
|
|
481
|
+
findings.push(
|
|
482
|
+
warn(
|
|
483
|
+
`the log lives under /tmp (${path})`,
|
|
484
|
+
"macOS's com.apple.tmp_cleaner deletes /tmp files untouched for ~3 days, so a\n" +
|
|
485
|
+
"router stopped over a long weekend loses its log. Somewhere durable\n" +
|
|
486
|
+
"(~/Library/Logs/llama/router.log) survives; mention rotation while you are there.",
|
|
487
|
+
),
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (findings.some((item) => item.level === "fail")) return { config: null, findings };
|
|
494
|
+
|
|
495
|
+
const consent = {};
|
|
496
|
+
consent[hashCommand(collector)] = true;
|
|
497
|
+
if (control !== null) {
|
|
498
|
+
for (const action of CONTROL_ACTIONS) consent[hashCommand(control[action])] = true;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Key order is chosen for a readable artifact, not for the reader — it ignores
|
|
502
|
+
// unknown keys and does not care about order.
|
|
503
|
+
const config = { memoryTopology: topology };
|
|
504
|
+
if (typeof proposal.baseUrl === "string" && proposal.baseUrl.trim() !== "") {
|
|
505
|
+
config.baseUrl = proposal.baseUrl.trim();
|
|
506
|
+
}
|
|
507
|
+
config.hostCollector = { command: collector, intervalMs };
|
|
508
|
+
if (log !== null) config.log = log;
|
|
509
|
+
if (control !== null) config.control = control;
|
|
510
|
+
if (llama !== null) config.llama = llama;
|
|
511
|
+
config.consent = consent;
|
|
512
|
+
|
|
513
|
+
return { config, findings };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Static smells in a collector command. The probe is the real test; these are hints. */
|
|
517
|
+
function checkCollectorCommand(command) {
|
|
518
|
+
const findings = [];
|
|
519
|
+
const joined = command.join(" ");
|
|
520
|
+
|
|
521
|
+
if (joined.includes("|") && /\bjq\b/u.test(joined)) {
|
|
522
|
+
if (!/--unbuffered/u.test(joined) && !/\bstdbuf\b/u.test(joined)) {
|
|
523
|
+
findings.push(
|
|
524
|
+
warn(
|
|
525
|
+
"a jq stage in a pipeline without --unbuffered",
|
|
526
|
+
"jq block-buffers when its stdout is a pipe, so `macmon … | jq -c …` emits ZERO\n" +
|
|
527
|
+
"lines to Steward while looking perfectly healthy. Add --unbuffered (or wrap the\n" +
|
|
528
|
+
"producer in stdbuf -oL). `probe-collector` will catch it either way.",
|
|
529
|
+
),
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (command.length === 1) {
|
|
535
|
+
findings.push(
|
|
536
|
+
warn(
|
|
537
|
+
"the collector is a bare binary with no transform",
|
|
538
|
+
"No tool emits steward.hostmetrics/1 natively, so a collector is normally a\n" +
|
|
539
|
+
"wrapper: the raw tool plus a transform. If this really does emit the schema,\n" +
|
|
540
|
+
"`probe-collector` will confirm it.",
|
|
541
|
+
),
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (/\bmacmon\b/u.test(joined) && !/-s\s*0/u.test(joined)) {
|
|
546
|
+
findings.push(
|
|
547
|
+
warn(
|
|
548
|
+
"macmon without `-s 0`",
|
|
549
|
+
"Steward reads a PERSISTENT stream. `macmon pipe -s 1` emits one line and exits,\n" +
|
|
550
|
+
"which turns into a respawn every sample and trips the respawn cap.",
|
|
551
|
+
),
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return findings;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** launchd-specific advice on the declared control commands. */
|
|
559
|
+
function checkControl(control) {
|
|
560
|
+
const findings = [];
|
|
561
|
+
const joined = Object.values(control)
|
|
562
|
+
.map((argv) => argv.join(" "))
|
|
563
|
+
.join("\n");
|
|
564
|
+
|
|
565
|
+
if (/\blaunchctl\b/u.test(joined) && /\bbootout\b/u.test(joined)) {
|
|
566
|
+
findings.push(
|
|
567
|
+
warn(
|
|
568
|
+
"stop uses `launchctl bootout`",
|
|
569
|
+
'bootout UNREGISTERS the job, so a later `kickstart` fails with "no such\n' +
|
|
570
|
+
'process" and Start stays dead until something bootstraps it again.\n' +
|
|
571
|
+
"`launchctl kill SIGTERM gui/<uid>/<label>` stops the process and leaves the\n" +
|
|
572
|
+
"agent registered, which keeps start and restart working.",
|
|
573
|
+
),
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
return findings;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/* ------------------------------------------------------------------ *
|
|
580
|
+
* config file reading (mirrors server/steward-config.ts)
|
|
581
|
+
* ------------------------------------------------------------------ */
|
|
582
|
+
|
|
583
|
+
/** The path Steward reads, honouring `STEWARD_CONFIG`. */
|
|
584
|
+
export function stewardConfigPath(env = process.env) {
|
|
585
|
+
const override = env.STEWARD_CONFIG;
|
|
586
|
+
if (override !== undefined && override.trim() !== "") return override;
|
|
587
|
+
return join(homedir(), ".config", "steward", "steward.json");
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Re-checks a written `steward.json` the way the server does — ownership, mode,
|
|
592
|
+
* JSON, schema, and then the consent map — so `verify` fails here rather than
|
|
593
|
+
* leaving the operator to work out why every panel is empty.
|
|
594
|
+
*/
|
|
595
|
+
export function inspectConfigFile(path, uid) {
|
|
596
|
+
const findings = [];
|
|
597
|
+
let stat;
|
|
598
|
+
try {
|
|
599
|
+
stat = statSync(path);
|
|
600
|
+
} catch {
|
|
601
|
+
return { findings: [fail(`no config at ${path}`)], config: null };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (uid !== null && stat.uid !== uid) {
|
|
605
|
+
findings.push(
|
|
606
|
+
fail(
|
|
607
|
+
`${path} is not owned by the current user`,
|
|
608
|
+
"Steward refuses a config another user could have planted. Fix with `chown`.",
|
|
609
|
+
),
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
if ((stat.mode & 0o002) !== 0) {
|
|
613
|
+
findings.push(fail(`${path} is world-writable`, "Steward refuses it. Fix with `chmod 600`."));
|
|
614
|
+
}
|
|
615
|
+
const mode = (stat.mode & 0o777).toString(8).padStart(3, "0");
|
|
616
|
+
if (mode !== "600") findings.push(warn(`mode is ${mode}; 600 is what the skill writes`));
|
|
617
|
+
else findings.push(ok("mode 600, owned by the current user"));
|
|
618
|
+
|
|
619
|
+
let config;
|
|
620
|
+
try {
|
|
621
|
+
config = JSON.parse(readFileSync(path, "utf8"));
|
|
622
|
+
} catch (error) {
|
|
623
|
+
return {
|
|
624
|
+
findings: [...findings, fail(`${path} is not valid JSON`, error.message)],
|
|
625
|
+
config: null,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
if (!isRecord(config)) {
|
|
629
|
+
return { findings: [...findings, fail(`${path} is not a JSON object`)], config: null };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (config.memoryTopology !== "unified" && config.memoryTopology !== "discrete") {
|
|
633
|
+
findings.push(
|
|
634
|
+
fail('memoryTopology must be "unified" or "discrete" — the whole config is refused'),
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const collector = isRecord(config.hostCollector) ? asArgv(config.hostCollector.command) : null;
|
|
639
|
+
if (collector === null) {
|
|
640
|
+
findings.push(
|
|
641
|
+
fail("hostCollector.command is missing or invalid — the whole config is refused"),
|
|
642
|
+
);
|
|
643
|
+
} else if (config.consent?.[hashCommand(collector)] !== true) {
|
|
644
|
+
findings.push(
|
|
645
|
+
fail(
|
|
646
|
+
"the collector command carries no matching consent hash",
|
|
647
|
+
"Steward will not spawn it, and the host band stays dark. Re-run `plan`/`apply`\n" +
|
|
648
|
+
"so the hash is recomputed from the exact command.",
|
|
649
|
+
),
|
|
650
|
+
);
|
|
651
|
+
} else {
|
|
652
|
+
findings.push(ok("the collector command is consented"));
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (isRecord(config.control)) {
|
|
656
|
+
for (const action of CONTROL_ACTIONS) {
|
|
657
|
+
const argv = asArgv(config.control[action]);
|
|
658
|
+
if (argv === null) {
|
|
659
|
+
findings.push(
|
|
660
|
+
fail(`control.${action} is missing or invalid — the whole control block is dropped`),
|
|
661
|
+
);
|
|
662
|
+
} else if (config.consent?.[hashCommand(argv)] !== true) {
|
|
663
|
+
findings.push(
|
|
664
|
+
fail(`control.${action} carries no matching consent hash — the button is not offered`),
|
|
665
|
+
);
|
|
666
|
+
} else {
|
|
667
|
+
findings.push(ok(`control.${action} is consented`));
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return { findings, config };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/* ------------------------------------------------------------------ *
|
|
676
|
+
* collector probe
|
|
677
|
+
* ------------------------------------------------------------------ */
|
|
678
|
+
|
|
679
|
+
/** Splits decoded stdout into complete lines, discarding a newline-less flood. */
|
|
680
|
+
function createLineSplitter(onLine) {
|
|
681
|
+
let buffer = "";
|
|
682
|
+
let discarding = false;
|
|
683
|
+
return (chunk) => {
|
|
684
|
+
buffer += chunk;
|
|
685
|
+
let index = buffer.indexOf("\n");
|
|
686
|
+
while (index !== -1) {
|
|
687
|
+
const line = buffer.slice(0, index).replace(/\r$/u, "");
|
|
688
|
+
buffer = buffer.slice(index + 1);
|
|
689
|
+
if (discarding) discarding = false;
|
|
690
|
+
else onLine(line);
|
|
691
|
+
index = buffer.indexOf("\n");
|
|
692
|
+
}
|
|
693
|
+
if (buffer.length > MAX_LINE_LENGTH) {
|
|
694
|
+
buffer = "";
|
|
695
|
+
discarding = true;
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Runs a collector for a bounded window and reports what it actually emitted.
|
|
702
|
+
*
|
|
703
|
+
* This is the empirical gate the whole host band rests on. It catches, in
|
|
704
|
+
* particular, the two failures a static reading of the command cannot: a
|
|
705
|
+
* producer that block-buffers and emits nothing at all, and a unified-memory
|
|
706
|
+
* machine whose transform synthesises VRAM figures out of RAM.
|
|
707
|
+
*/
|
|
708
|
+
export function probeCollector({ command, seconds, topology, intervalMs }) {
|
|
709
|
+
return new Promise((resolve) => {
|
|
710
|
+
const started = Date.now();
|
|
711
|
+
const stats = {
|
|
712
|
+
total: 0,
|
|
713
|
+
valid: 0,
|
|
714
|
+
malformed: 0,
|
|
715
|
+
foreign: 0,
|
|
716
|
+
firstLineMs: null,
|
|
717
|
+
firstValidMs: null,
|
|
718
|
+
present: Object.fromEntries(METRIC_FIELDS.map((field) => [field, 0])),
|
|
719
|
+
stderr: "",
|
|
720
|
+
exited: null,
|
|
721
|
+
timestamps: [],
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
let child;
|
|
725
|
+
try {
|
|
726
|
+
child = spawn(command[0], command.slice(1), {
|
|
727
|
+
detached: true,
|
|
728
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
729
|
+
});
|
|
730
|
+
} catch (error) {
|
|
731
|
+
resolve({ stats, findings: [fail(`the collector could not be spawned: ${error.message}`)] });
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
child.on("error", (error) => {
|
|
736
|
+
stats.spawnError = error.message;
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
const push = createLineSplitter((line) => {
|
|
740
|
+
if (line.trim() === "") return;
|
|
741
|
+
stats.total += 1;
|
|
742
|
+
if (stats.firstLineMs === null) stats.firstLineMs = Date.now() - started;
|
|
743
|
+
let parsed;
|
|
744
|
+
try {
|
|
745
|
+
parsed = JSON.parse(line);
|
|
746
|
+
} catch {
|
|
747
|
+
stats.malformed += 1;
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
if (!isRecord(parsed) || parsed.schema !== HOST_METRICS_SCHEMA) {
|
|
751
|
+
stats.foreign += 1;
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (typeof parsed.ts !== "number" || !Number.isFinite(parsed.ts)) {
|
|
755
|
+
stats.malformed += 1;
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
stats.valid += 1;
|
|
759
|
+
stats.timestamps.push(Date.now());
|
|
760
|
+
if (stats.firstValidMs === null) stats.firstValidMs = Date.now() - started;
|
|
761
|
+
for (const field of METRIC_FIELDS) {
|
|
762
|
+
const value = parsed[field];
|
|
763
|
+
if (typeof value === "number" && Number.isFinite(value)) stats.present[field] += 1;
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
child.stdout?.setEncoding("utf8");
|
|
768
|
+
child.stdout?.on("data", push);
|
|
769
|
+
child.stderr?.setEncoding("utf8");
|
|
770
|
+
child.stderr?.on("data", (chunk) => {
|
|
771
|
+
if (stats.stderr.length < 2048) stats.stderr += chunk;
|
|
772
|
+
});
|
|
773
|
+
child.on("exit", (code, signal) => {
|
|
774
|
+
stats.exited = signal !== null ? `signal ${signal}` : `code ${code}`;
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
const finish = () => {
|
|
778
|
+
const pid = child.pid;
|
|
779
|
+
if (pid !== undefined && stats.exited === null) {
|
|
780
|
+
try {
|
|
781
|
+
process.kill(-pid, "SIGTERM");
|
|
782
|
+
} catch {
|
|
783
|
+
// Already gone, or never grouped — the SIGKILL below is the backstop.
|
|
784
|
+
}
|
|
785
|
+
setTimeout(() => {
|
|
786
|
+
try {
|
|
787
|
+
process.kill(-pid, "SIGKILL");
|
|
788
|
+
} catch {
|
|
789
|
+
// Nothing left to reap.
|
|
790
|
+
}
|
|
791
|
+
}, KILL_ESCALATION_MS).unref();
|
|
792
|
+
}
|
|
793
|
+
resolve({ stats, findings: judgeProbe(stats, { seconds, topology, intervalMs }) });
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
setTimeout(finish, Math.round(seconds * 1000));
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** Turns raw probe counters into findings an operator can act on. */
|
|
801
|
+
function judgeProbe(stats, { seconds, topology, intervalMs }) {
|
|
802
|
+
const findings = [];
|
|
803
|
+
|
|
804
|
+
if (stats.spawnError !== undefined) {
|
|
805
|
+
findings.push(fail(`the collector could not be spawned: ${stats.spawnError}`));
|
|
806
|
+
return findings;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (stats.total === 0) {
|
|
810
|
+
findings.push(
|
|
811
|
+
fail(
|
|
812
|
+
`the collector emitted NOTHING in ${seconds}s`,
|
|
813
|
+
"This is the block-buffering trap: a pipeline like `macmon … | jq -c …` spawns\n" +
|
|
814
|
+
"cleanly, stays alive, and never writes a line, so Steward would sit in\n" +
|
|
815
|
+
"`warming` and then report the collector failed. Add `jq --unbuffered`, or wrap\n" +
|
|
816
|
+
"the producer in `stdbuf -oL`.\n" +
|
|
817
|
+
(stats.stderr === "" ? "" : `Collector stderr:\n${stats.stderr.trim()}`),
|
|
818
|
+
),
|
|
819
|
+
);
|
|
820
|
+
return findings;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (stats.valid === 0) {
|
|
824
|
+
findings.push(
|
|
825
|
+
fail(
|
|
826
|
+
`${stats.total} line(s) emitted, none of them a ${HOST_METRICS_SCHEMA} reading`,
|
|
827
|
+
`${stats.malformed} unparseable, ${stats.foreign} parsed but wrong/absent schema tag.\n` +
|
|
828
|
+
`Every line needs "schema":"${HOST_METRICS_SCHEMA}" and a numeric epoch-ms "ts".` +
|
|
829
|
+
(stats.stderr === "" ? "" : `\nCollector stderr:\n${stats.stderr.trim()}`),
|
|
830
|
+
),
|
|
831
|
+
);
|
|
832
|
+
return findings;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
findings.push(
|
|
836
|
+
ok(
|
|
837
|
+
`${stats.valid} valid reading(s) in ${seconds}s (first after ${stats.firstValidMs}ms)`,
|
|
838
|
+
stats.malformed + stats.foreign > 0
|
|
839
|
+
? `${stats.malformed} malformed and ${stats.foreign} foreign line(s) were dropped.`
|
|
840
|
+
: null,
|
|
841
|
+
),
|
|
842
|
+
);
|
|
843
|
+
|
|
844
|
+
if (stats.exited !== null) {
|
|
845
|
+
findings.push(
|
|
846
|
+
warn(
|
|
847
|
+
`the collector exited (${stats.exited}) before the window closed`,
|
|
848
|
+
"Steward needs a PERSISTENT stream. A one-shot producer is respawned with\n" +
|
|
849
|
+
"backoff and trips the respawn cap, which surfaces as `collector-failed`.",
|
|
850
|
+
),
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (stats.valid >= 3 && typeof intervalMs === "number") {
|
|
855
|
+
const span = stats.timestamps[stats.timestamps.length - 1] - stats.timestamps[0];
|
|
856
|
+
const measured = Math.round(span / (stats.valid - 1));
|
|
857
|
+
if (measured > intervalMs * 2 || measured < intervalMs / 2) {
|
|
858
|
+
findings.push(
|
|
859
|
+
warn(
|
|
860
|
+
`measured cadence ~${measured}ms, but intervalMs says ${intervalMs}`,
|
|
861
|
+
"intervalMs is the staleness clock: a sample is stale past ~3x it. Recording a\n" +
|
|
862
|
+
"cadence the collector does not keep makes the host band flap to `last-seen`.",
|
|
863
|
+
),
|
|
864
|
+
);
|
|
865
|
+
} else {
|
|
866
|
+
findings.push(ok(`measured cadence ~${measured}ms matches the declared intervalMs`));
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const measured = METRIC_FIELDS.filter((field) => stats.present[field] > 0);
|
|
871
|
+
const missing = METRIC_FIELDS.filter((field) => stats.present[field] === 0);
|
|
872
|
+
findings.push(
|
|
873
|
+
ok(
|
|
874
|
+
`measured: ${measured.length === 0 ? "(none)" : measured.join(", ")}`,
|
|
875
|
+
missing.length === 0
|
|
876
|
+
? null
|
|
877
|
+
: `always null: ${missing.join(", ")} — these render as no-reading gauges, not zeros.`,
|
|
878
|
+
),
|
|
879
|
+
);
|
|
880
|
+
|
|
881
|
+
if (topology === "unified") {
|
|
882
|
+
const synthesised = VRAM_FIELDS.filter((field) => stats.present[field] > 0);
|
|
883
|
+
if (synthesised.length > 0) {
|
|
884
|
+
findings.push(
|
|
885
|
+
fail(
|
|
886
|
+
`VRAM is being reported on unified memory (${synthesised.join(", ")})`,
|
|
887
|
+
"There is no separate VRAM on unified memory and no readable GPU ceiling, so any\n" +
|
|
888
|
+
'figure here is invented. Report memoryTopology "unified" with ramUsedGB /\n' +
|
|
889
|
+
"ramTotalGB and omit the VRAM fields entirely; Steward renders one Unified\n" +
|
|
890
|
+
"Memory gauge.",
|
|
891
|
+
),
|
|
892
|
+
);
|
|
893
|
+
} else {
|
|
894
|
+
findings.push(ok("no VRAM fields on unified memory, as required"));
|
|
895
|
+
}
|
|
896
|
+
} else if (topology === "discrete" && stats.present.vramTotalGB === 0) {
|
|
897
|
+
findings.push(
|
|
898
|
+
warn(
|
|
899
|
+
"discrete topology, but vramTotalGB never had a reading",
|
|
900
|
+
"The VRAM gauge will render as no-reading. If this machine's VRAM total really\n" +
|
|
901
|
+
"cannot be read, `unified` is not the answer either — leave it null and say so.",
|
|
902
|
+
),
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
return findings;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/* ------------------------------------------------------------------ *
|
|
910
|
+
* log inspection
|
|
911
|
+
* ------------------------------------------------------------------ */
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Reads the tail of the recorded log and reports which of the two streams
|
|
915
|
+
* reached it.
|
|
916
|
+
*
|
|
917
|
+
* llama.cpp splits its output: the router's own levelled lines (`I`/`W`/`E`) go
|
|
918
|
+
* to STDERR, while the child lines it forwards are emitted at
|
|
919
|
+
* `GGML_LOG_LEVEL_NONE` and go to STDOUT. Redirecting one stream silently loses
|
|
920
|
+
* half the log — and stdout-only loses every error. So the presence of each line
|
|
921
|
+
* shape is direct evidence that each stream was captured.
|
|
922
|
+
*/
|
|
923
|
+
export function inspectLog(path, { bytes = 256 * 1024 } = {}) {
|
|
924
|
+
const findings = [];
|
|
925
|
+
let stat;
|
|
926
|
+
try {
|
|
927
|
+
stat = statSync(path);
|
|
928
|
+
} catch {
|
|
929
|
+
findings.push(
|
|
930
|
+
warn(
|
|
931
|
+
`the recorded log ${path} does not exist yet`,
|
|
932
|
+
"Steward reports this as `missing` and picks the file up the moment it appears;\n" +
|
|
933
|
+
"it is only a problem if the service has been running and still wrote nothing.",
|
|
934
|
+
),
|
|
935
|
+
);
|
|
936
|
+
return { findings, stderrLines: 0, stdoutLines: 0 };
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const start = Math.max(0, stat.size - bytes);
|
|
940
|
+
let text = "";
|
|
941
|
+
try {
|
|
942
|
+
const buffer = readFileSync(path);
|
|
943
|
+
text = buffer.subarray(start).toString("utf8");
|
|
944
|
+
} catch (error) {
|
|
945
|
+
findings.push(fail(`the log at ${path} could not be read`, error.message));
|
|
946
|
+
return { findings, stderrLines: 0, stdoutLines: 0 };
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
let stderrLines = 0;
|
|
950
|
+
let stdoutLines = 0;
|
|
951
|
+
for (const line of text.split("\n")) {
|
|
952
|
+
if (ROUTER_STDERR_LINE.test(line)) stderrLines += 1;
|
|
953
|
+
else if (ROUTER_STDOUT_LINE.test(line)) stdoutLines += 1;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
if (stderrLines > 0) {
|
|
957
|
+
findings.push(ok(`stderr IS captured (${stderrLines} levelled router line(s) in the tail)`));
|
|
958
|
+
} else if (stat.size === 0) {
|
|
959
|
+
findings.push(warn("the log is empty — nothing has been written since it was created"));
|
|
960
|
+
} else {
|
|
961
|
+
findings.push(
|
|
962
|
+
fail(
|
|
963
|
+
"no levelled router lines — stderr does NOT look captured",
|
|
964
|
+
"llama.cpp writes every I/W/E line to stderr. A redirect that only captures\n" +
|
|
965
|
+
"stdout loses all of them, including every error. launchd needs BOTH\n" +
|
|
966
|
+
"StandardOutPath and StandardErrorPath set to this same path; a shell wrapper\n" +
|
|
967
|
+
"needs `> file 2>&1`.",
|
|
968
|
+
),
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
if (stdoutLines > 0) {
|
|
973
|
+
findings.push(
|
|
974
|
+
ok(`stdout IS captured (${stdoutLines} forwarded [port] child line(s) in the tail)`),
|
|
975
|
+
);
|
|
976
|
+
} else {
|
|
977
|
+
findings.push(
|
|
978
|
+
warn(
|
|
979
|
+
"no forwarded [port] child lines — stdout capture is unproven",
|
|
980
|
+
"The router forwards child output on stdout, but only once a model has been\n" +
|
|
981
|
+
"loaded. On a router that has never spawned a child this is expected; confirm\n" +
|
|
982
|
+
"the redirect from the launch mechanism instead (both paths must be the file).",
|
|
983
|
+
),
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
return { findings, stderrLines, stdoutLines };
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Confirms a launchd plist redirects BOTH streams to the recorded log.
|
|
992
|
+
*
|
|
993
|
+
* Parsed as text rather than with `plutil` on purpose: this must work on a
|
|
994
|
+
* plist that is a symlink into a dotfiles repo, on a machine where the skill is
|
|
995
|
+
* only allowed to read, and without shelling out.
|
|
996
|
+
*/
|
|
997
|
+
export function inspectPlist(plistPath, expectedLog) {
|
|
998
|
+
const findings = [];
|
|
999
|
+
let xml;
|
|
1000
|
+
try {
|
|
1001
|
+
xml = readFileSync(plistPath, "utf8");
|
|
1002
|
+
} catch (error) {
|
|
1003
|
+
findings.push(fail(`the plist at ${plistPath} could not be read`, error.message));
|
|
1004
|
+
return findings;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const read = (key) => {
|
|
1008
|
+
const match = new RegExp(`<key>${key}</key>\\s*<string>([^<]*)</string>`, "u").exec(xml);
|
|
1009
|
+
return match === null ? null : match[1].trim();
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
const out = read("StandardOutPath");
|
|
1013
|
+
const err = read("StandardErrorPath");
|
|
1014
|
+
|
|
1015
|
+
if (out === null || err === null) {
|
|
1016
|
+
findings.push(
|
|
1017
|
+
fail(
|
|
1018
|
+
"the plist does not set both StandardOutPath and StandardErrorPath",
|
|
1019
|
+
`StandardOutPath=${out ?? "(unset)"}, StandardErrorPath=${err ?? "(unset)"}.\n` +
|
|
1020
|
+
"Both are required, and both must name the SAME file: the router's own I/W/E\n" +
|
|
1021
|
+
"lines go to stderr and the child lines it forwards go to stdout.",
|
|
1022
|
+
),
|
|
1023
|
+
);
|
|
1024
|
+
return findings;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
if (out !== err) {
|
|
1028
|
+
findings.push(
|
|
1029
|
+
fail(
|
|
1030
|
+
"StandardOutPath and StandardErrorPath name different files",
|
|
1031
|
+
`stdout -> ${out}\nstderr -> ${err}\n` +
|
|
1032
|
+
"Steward follows one file. Split across two, the console shows half the log.",
|
|
1033
|
+
),
|
|
1034
|
+
);
|
|
1035
|
+
} else {
|
|
1036
|
+
findings.push(ok(`both streams redirect to ${out}`));
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
if (expectedLog !== undefined && out !== expectedLog) {
|
|
1040
|
+
findings.push(
|
|
1041
|
+
fail(
|
|
1042
|
+
"the plist's redirect does not match the recorded log.path",
|
|
1043
|
+
`plist -> ${out}\nsteward.json -> ${expectedLog}`,
|
|
1044
|
+
),
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
return findings;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/* ------------------------------------------------------------------ *
|
|
1052
|
+
* diff + write
|
|
1053
|
+
* ------------------------------------------------------------------ */
|
|
1054
|
+
|
|
1055
|
+
/** A minimal LCS line diff, enough to show an operator exactly what changes. */
|
|
1056
|
+
export function diffLines(before, after) {
|
|
1057
|
+
const a = before === "" ? [] : before.split("\n");
|
|
1058
|
+
const b = after === "" ? [] : after.split("\n");
|
|
1059
|
+
const table = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
|
|
1060
|
+
for (let i = a.length - 1; i >= 0; i -= 1) {
|
|
1061
|
+
for (let j = b.length - 1; j >= 0; j -= 1) {
|
|
1062
|
+
table[i][j] =
|
|
1063
|
+
a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
const out = [];
|
|
1067
|
+
let i = 0;
|
|
1068
|
+
let j = 0;
|
|
1069
|
+
while (i < a.length && j < b.length) {
|
|
1070
|
+
if (a[i] === b[j]) {
|
|
1071
|
+
out.push(` ${a[i]}`);
|
|
1072
|
+
i += 1;
|
|
1073
|
+
j += 1;
|
|
1074
|
+
} else if (table[i + 1][j] >= table[i][j + 1]) {
|
|
1075
|
+
out.push(`- ${a[i]}`);
|
|
1076
|
+
i += 1;
|
|
1077
|
+
} else {
|
|
1078
|
+
out.push(`+ ${b[j]}`);
|
|
1079
|
+
j += 1;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
while (i < a.length) {
|
|
1083
|
+
out.push(`- ${a[i]}`);
|
|
1084
|
+
i += 1;
|
|
1085
|
+
}
|
|
1086
|
+
while (j < b.length) {
|
|
1087
|
+
out.push(`+ ${b[j]}`);
|
|
1088
|
+
j += 1;
|
|
1089
|
+
}
|
|
1090
|
+
return out.join("\n");
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/** The serialised artifact, newline-terminated. */
|
|
1094
|
+
function serialise(config) {
|
|
1095
|
+
return `${JSON.stringify(config, null, 2)}\n`;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Writes `steward.json`: back up first, write through a temp file in the same
|
|
1100
|
+
* directory, and land it with `rename` so a reader never sees a half-written
|
|
1101
|
+
* config. Mode 0600 and a 0700 parent, because the reader refuses anything
|
|
1102
|
+
* world-writable or owned by someone else.
|
|
1103
|
+
*/
|
|
1104
|
+
function writeConfig(path, config) {
|
|
1105
|
+
const dir = dirname(path);
|
|
1106
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1107
|
+
|
|
1108
|
+
let backup = null;
|
|
1109
|
+
if (existsSync(path)) {
|
|
1110
|
+
const stat = statSync(path);
|
|
1111
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
1112
|
+
if (uid !== null && stat.uid !== uid) {
|
|
1113
|
+
throw new Error(`${path} is owned by uid ${stat.uid}, not ${uid} — refusing to overwrite it`);
|
|
1114
|
+
}
|
|
1115
|
+
backup = `${path}.bak.${new Date().toISOString().replace(/[:.]/gu, "-")}`;
|
|
1116
|
+
copyFileSync(path, backup);
|
|
1117
|
+
chmodSync(backup, 0o600);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const temporary = join(dir, `.steward.json.${process.pid}.tmp`);
|
|
1121
|
+
writeFileSync(temporary, serialise(config), { mode: 0o600 });
|
|
1122
|
+
renameSync(temporary, path);
|
|
1123
|
+
chmodSync(path, 0o600);
|
|
1124
|
+
return backup;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/* ------------------------------------------------------------------ *
|
|
1128
|
+
* subcommands
|
|
1129
|
+
* ------------------------------------------------------------------ */
|
|
1130
|
+
|
|
1131
|
+
function commandCheckArgv(flags) {
|
|
1132
|
+
let argv;
|
|
1133
|
+
let source;
|
|
1134
|
+
if (flags.has("pid")) {
|
|
1135
|
+
const pid = numberFlag(flags, "pid", 0);
|
|
1136
|
+
const line = processArgv(pid);
|
|
1137
|
+
if (line === null) {
|
|
1138
|
+
process.stdout.write(`could not read the command line of pid ${pid}\n`);
|
|
1139
|
+
return 1;
|
|
1140
|
+
}
|
|
1141
|
+
argv = line.split(/\s+/u).filter((token) => token !== "");
|
|
1142
|
+
source = `pid ${pid} (via ps; quoting is lost, so a quoted value is split)`;
|
|
1143
|
+
} else {
|
|
1144
|
+
argv = jsonFlag(flags, "argv-json");
|
|
1145
|
+
if (asArgv(argv) === null)
|
|
1146
|
+
throw new UsageError("--argv-json must be a non-empty array of strings");
|
|
1147
|
+
source = "--argv-json";
|
|
1148
|
+
}
|
|
1149
|
+
process.stdout.write(`llama-server launch argv, from ${source}:\n ${argv.join(" ")}\n`);
|
|
1150
|
+
return report("Contract 1 — llama.cpp compliance", checkLaunchArgv(argv)) ? 1 : 0;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
async function commandProbeCollector(flags) {
|
|
1154
|
+
const command = jsonFlag(flags, "command-json");
|
|
1155
|
+
if (asArgv(command) === null) {
|
|
1156
|
+
throw new UsageError("--command-json must be a non-empty array of strings");
|
|
1157
|
+
}
|
|
1158
|
+
const seconds = numberFlag(flags, "seconds", 6);
|
|
1159
|
+
const intervalMs = flags.has("interval-ms") ? numberFlag(flags, "interval-ms", 1000) : undefined;
|
|
1160
|
+
const topology = flags.get("topology");
|
|
1161
|
+
if (topology !== undefined && topology !== "unified" && topology !== "discrete") {
|
|
1162
|
+
throw new UsageError('--topology must be "unified" or "discrete"');
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
process.stdout.write(`running for ${seconds}s: ${command.join(" ")}\n`);
|
|
1166
|
+
const statics = report("Collector command (static review)", checkCollectorCommand(command));
|
|
1167
|
+
const { findings } = await probeCollector({ command, seconds, topology, intervalMs });
|
|
1168
|
+
const live = report("Collector stream (measured)", findings);
|
|
1169
|
+
return statics || live ? 1 : 0;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function commandPlan(flags) {
|
|
1173
|
+
const proposal = readInput(required(flags, "input"));
|
|
1174
|
+
const path = flags.get("config") ?? stewardConfigPath();
|
|
1175
|
+
const { config, findings } = buildConfig(proposal);
|
|
1176
|
+
const failed = report("Proposal review", findings);
|
|
1177
|
+
if (config === null) {
|
|
1178
|
+
process.stdout.write("\nThe proposal has errors; nothing would be written.\n");
|
|
1179
|
+
return 1;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
const before = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
1183
|
+
process.stdout.write(`\nTarget: ${path}\n`);
|
|
1184
|
+
process.stdout.write(
|
|
1185
|
+
before === "" ? "(no config exists today)\n" : "(an existing config will be backed up)\n",
|
|
1186
|
+
);
|
|
1187
|
+
process.stdout.write("\nDiff (- current, + proposed)\n----------------------------\n");
|
|
1188
|
+
process.stdout.write(`${diffLines(before.trimEnd(), serialise(config).trimEnd())}\n`);
|
|
1189
|
+
process.stdout.write(
|
|
1190
|
+
"\nNothing has been written. To apply, re-run with `apply` — it will back the file\n" +
|
|
1191
|
+
"up first and print the exact revert command.\n",
|
|
1192
|
+
);
|
|
1193
|
+
return failed ? 1 : 0;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function commandApply(flags) {
|
|
1197
|
+
const proposal = readInput(required(flags, "input"));
|
|
1198
|
+
const path = flags.get("config") ?? stewardConfigPath();
|
|
1199
|
+
const { config, findings } = buildConfig(proposal);
|
|
1200
|
+
const failed = report("Proposal review", findings);
|
|
1201
|
+
if (config === null) {
|
|
1202
|
+
process.stdout.write("\nThe proposal has errors; nothing was written.\n");
|
|
1203
|
+
return 1;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
const backup = writeConfig(path, config);
|
|
1207
|
+
process.stdout.write(`\nWrote ${path} (mode 600).\n`);
|
|
1208
|
+
if (backup === null) {
|
|
1209
|
+
process.stdout.write(`Revert with:\n rm ${path}\n`);
|
|
1210
|
+
} else {
|
|
1211
|
+
process.stdout.write(
|
|
1212
|
+
`Backed up the previous config to ${backup}.\nRevert with:\n cp ${backup} ${path}\n`,
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
return failed ? 1 : 0;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
async function commandVerify(flags) {
|
|
1219
|
+
const path = flags.get("config") ?? stewardConfigPath();
|
|
1220
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
1221
|
+
const { findings: configFindings, config } = inspectConfigFile(path, uid);
|
|
1222
|
+
let failed = report(`Config artifact — ${path}`, configFindings);
|
|
1223
|
+
if (config === null) return 1;
|
|
1224
|
+
|
|
1225
|
+
if (Array.isArray(config.llama?.launchArgv)) {
|
|
1226
|
+
failed =
|
|
1227
|
+
report("Contract 1 — recorded launch argv", checkLaunchArgv(config.llama.launchArgv)) ||
|
|
1228
|
+
failed;
|
|
1229
|
+
if (flags.has("pid")) {
|
|
1230
|
+
const pid = numberFlag(flags, "pid", 0);
|
|
1231
|
+
const live = processArgv(pid);
|
|
1232
|
+
const recorded = config.llama.launchArgv.join(" ");
|
|
1233
|
+
let drift;
|
|
1234
|
+
if (live === null) {
|
|
1235
|
+
drift = warn(`the command line of pid ${pid} could not be read — no verdict`);
|
|
1236
|
+
} else {
|
|
1237
|
+
const result = diffRecordedArgv(config.llama.launchArgv, live);
|
|
1238
|
+
if (result.status === "unknown") {
|
|
1239
|
+
drift = warn(`no verdict on pid ${pid} — ${result.reason}`);
|
|
1240
|
+
} else if (result.status === "clean") {
|
|
1241
|
+
drift = ok(`the live process matches the recorded launch argv (pid ${pid})`);
|
|
1242
|
+
} else {
|
|
1243
|
+
const detail = [
|
|
1244
|
+
result.program !== null
|
|
1245
|
+
? `program: recorded ${config.llama.launchArgv[0]}, observed ${result.program}`
|
|
1246
|
+
: null,
|
|
1247
|
+
result.removed.length > 0
|
|
1248
|
+
? `recorded but not running: ${result.removed.join(", ")}`
|
|
1249
|
+
: null,
|
|
1250
|
+
result.added.length > 0 ? `running but not recorded: ${result.added.join(", ")}` : null,
|
|
1251
|
+
].filter((line) => line !== null);
|
|
1252
|
+
drift = fail(
|
|
1253
|
+
`the live process does not match the recorded launch argv (pid ${pid})`,
|
|
1254
|
+
`${detail.join("\n")}\n\nrecorded: ${recorded}\nobserved: ${live}\n` +
|
|
1255
|
+
"Steward's drift notice will say the same thing. Re-run the skill so the\n" +
|
|
1256
|
+
"record matches the machine, or put the flag back.",
|
|
1257
|
+
);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
failed = report("Contract 1 — live process vs record", [drift]) || failed;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const logPath = typeof config.log?.path === "string" ? config.log.path : null;
|
|
1265
|
+
if (logPath !== null) {
|
|
1266
|
+
failed =
|
|
1267
|
+
report(`Contract 1 — log capture (${logPath})`, inspectLog(logPath).findings) || failed;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// Outside the `logPath` guard on purpose. An operator who declined the log
|
|
1271
|
+
// redirect still gets a plist verdict — the old nesting silently produced no
|
|
1272
|
+
// finding and exit 0, which reads as "checked, fine" for a check never run.
|
|
1273
|
+
const plist = flags.get("plist");
|
|
1274
|
+
if (plist !== undefined) {
|
|
1275
|
+
failed =
|
|
1276
|
+
logPath === null
|
|
1277
|
+
? report(`Contract 1 — launchd redirect (${plist})`, [
|
|
1278
|
+
warn(
|
|
1279
|
+
"no verdict — the config records no log.path to compare the plist against",
|
|
1280
|
+
"Record a log.path, or re-run with the redirect target you expect.",
|
|
1281
|
+
),
|
|
1282
|
+
]) || failed
|
|
1283
|
+
: report(`Contract 1 — launchd redirect (${plist})`, inspectPlist(plist, logPath)) ||
|
|
1284
|
+
failed;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
if (!flags.has("skip-collector") && Array.isArray(config.hostCollector?.command)) {
|
|
1288
|
+
const seconds = numberFlag(flags, "seconds", 6);
|
|
1289
|
+
const { findings } = await probeCollector({
|
|
1290
|
+
command: config.hostCollector.command,
|
|
1291
|
+
seconds,
|
|
1292
|
+
topology: config.memoryTopology,
|
|
1293
|
+
intervalMs: config.hostCollector.intervalMs,
|
|
1294
|
+
});
|
|
1295
|
+
failed = report("Contract 2 — host-metrics stream (measured)", findings) || failed;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
process.stdout.write(
|
|
1299
|
+
failed
|
|
1300
|
+
? "\nSome checks FAILED. Report exactly which, and what is still missing.\n"
|
|
1301
|
+
: "\nAll checks passed.\n",
|
|
1302
|
+
);
|
|
1303
|
+
return failed ? 1 : 0;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
const USAGE = `steward-setup.mjs — deterministic helpers for /initialize-steward
|
|
1307
|
+
|
|
1308
|
+
check-argv --argv-json <json|file> | --pid <n>
|
|
1309
|
+
Contract-1 compliance of a llama-server launch argv.
|
|
1310
|
+
|
|
1311
|
+
probe-collector --command-json <json|file> [--seconds 6]
|
|
1312
|
+
[--topology unified|discrete] [--interval-ms <n>]
|
|
1313
|
+
Runs a collector for a bounded window and reports what it
|
|
1314
|
+
really emitted. Catches block-buffered producers and VRAM
|
|
1315
|
+
synthesised on unified memory. Kills the process group after.
|
|
1316
|
+
|
|
1317
|
+
plan --input <file|-> [--config <path>]
|
|
1318
|
+
Validates a proposal, derives the consent hashes, and prints
|
|
1319
|
+
the exact diff against the current config. Writes nothing.
|
|
1320
|
+
|
|
1321
|
+
apply --input <file|-> [--config <path>]
|
|
1322
|
+
Backs the current config up, then writes the new one
|
|
1323
|
+
atomically at mode 600. Prints the revert command.
|
|
1324
|
+
|
|
1325
|
+
verify [--config <path>] [--pid <n>] [--plist <path>]
|
|
1326
|
+
[--seconds 6] [--skip-collector]
|
|
1327
|
+
Re-checks the written artifact the way Steward reads it, the
|
|
1328
|
+
recorded argv, the log capture, and the live collector.
|
|
1329
|
+
|
|
1330
|
+
The proposal is a steward.json WITHOUT its consent map: consent is always
|
|
1331
|
+
derived here, from the exact commands, so a hash can never disagree with the
|
|
1332
|
+
command it approves.
|
|
1333
|
+
`;
|
|
1334
|
+
|
|
1335
|
+
/**
|
|
1336
|
+
* Checks a launchd plist's redirect BEFORE anything is applied.
|
|
1337
|
+
*
|
|
1338
|
+
* `verify` can only reach this once a `steward.json` exists, which is after the
|
|
1339
|
+
* plist has been edited and the service restarted — so the contract line with
|
|
1340
|
+
* the worst failure mode (capturing stdout alone looks alive and throws every
|
|
1341
|
+
* error away) was the one that could not be checked while it was still cheap to
|
|
1342
|
+
* fix. `--expect-log` is the path both streams should point at.
|
|
1343
|
+
*/
|
|
1344
|
+
function commandCheckPlist(flags) {
|
|
1345
|
+
const plist = flags.get("plist");
|
|
1346
|
+
if (plist === undefined) throw new UsageError("check-plist needs --plist <path>");
|
|
1347
|
+
const expected = flags.get("expect-log");
|
|
1348
|
+
if (expected === undefined) throw new UsageError("check-plist needs --expect-log <path>");
|
|
1349
|
+
return report(`launchd redirect (${plist})`, inspectPlist(plist, expected)) ? 1 : 0;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/** Checks a log file for evidence of both streams, before or after an edit. */
|
|
1353
|
+
function commandCheckLog(flags) {
|
|
1354
|
+
const path = flags.get("log");
|
|
1355
|
+
if (path === undefined) throw new UsageError("check-log needs --log <path>");
|
|
1356
|
+
return report(`log capture (${path})`, inspectLog(path).findings) ? 1 : 0;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
async function main(argv) {
|
|
1360
|
+
const command = argv[0];
|
|
1361
|
+
if (command === undefined || command === "help" || command === "--help") {
|
|
1362
|
+
process.stdout.write(USAGE);
|
|
1363
|
+
return 0;
|
|
1364
|
+
}
|
|
1365
|
+
const flags = parseFlags(argv.slice(1));
|
|
1366
|
+
switch (command) {
|
|
1367
|
+
case "check-argv":
|
|
1368
|
+
return commandCheckArgv(flags);
|
|
1369
|
+
case "probe-collector":
|
|
1370
|
+
return await commandProbeCollector(flags);
|
|
1371
|
+
case "plan":
|
|
1372
|
+
return commandPlan(flags);
|
|
1373
|
+
case "apply":
|
|
1374
|
+
return commandApply(flags);
|
|
1375
|
+
case "verify":
|
|
1376
|
+
return await commandVerify(flags);
|
|
1377
|
+
case "check-plist":
|
|
1378
|
+
return commandCheckPlist(flags);
|
|
1379
|
+
case "check-log":
|
|
1380
|
+
return commandCheckLog(flags);
|
|
1381
|
+
default:
|
|
1382
|
+
throw new UsageError(`unknown subcommand: ${command}`);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
main(process.argv.slice(2))
|
|
1387
|
+
.then((code) => {
|
|
1388
|
+
process.exitCode = code;
|
|
1389
|
+
})
|
|
1390
|
+
.catch((error) => {
|
|
1391
|
+
if (error instanceof UsageError) {
|
|
1392
|
+
process.stderr.write(`${error.message}\n\n${USAGE}`);
|
|
1393
|
+
process.exitCode = 2;
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
process.stderr.write(`${error.stack ?? error.message}\n`);
|
|
1397
|
+
process.exitCode = 1;
|
|
1398
|
+
});
|