mandrel-platform 0.20.1 → 0.24.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.
@@ -0,0 +1,469 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-runner-health.mjs
4
+ *
5
+ * Scheduled runner-fleet health monitor for mandrel-platform (Story #258).
6
+ *
7
+ * All nine self-hosted runners across the fleet (domio, athportal, swarm-os)
8
+ * are co-resident on ONE operator Mac (2026-07-03 runner audit, repo-ops
9
+ * matrix §1a). If that host sleeps, reboots for an OS update, fills its disk,
10
+ * or a launchd service dies, every consumer's CI and deploy-trigger jobs
11
+ * silently queue ("waiting for a runner") with no alert — nothing watches the
12
+ * fleet today. This script is the standing check that closes that gap.
13
+ *
14
+ * Modeled on `check-pin-drift.mjs` (same GitHub-hosted, config-driven,
15
+ * `GITHUB_STEP_SUMMARY`-rendering shape via the `scripts/lib/gh-json.mjs`
16
+ * seam) with two differences: it must run FREQUENTLY (not weekly) and it must
17
+ * ALERT (not just render a dashboard), because the whole point is catching an
18
+ * offline fleet fast.
19
+ *
20
+ * For each repo in `scripts/runner-fleet-consumers.json` it:
21
+ * 1. Calls `GET /repos/{owner}/{repo}/actions/runners` and flags any runner
22
+ * whose `status !== "online"`.
23
+ * 2. Flags a count shortfall: fewer online runners matching the expected
24
+ * `labels` set than `expectedCount`.
25
+ * 3. Flags queue-staleness: a `queued`/`waiting` workflow run older than
26
+ * `staleQueuedMinutes` with no online runner matching its labels
27
+ * (a wedged fleet accepts jobs into the queue but never claims them).
28
+ * 4. Renders a per-repo dashboard to `GITHUB_STEP_SUMMARY`.
29
+ *
30
+ * Alerting (no external dependency, on-ethos default): the CLI exits
31
+ * non-zero when any repo is unhealthy, so GitHub's native failed-workflow
32
+ * notification fires. That is the only alert channel — no tracking issues
33
+ * are filed.
34
+ *
35
+ * GitHub access is via the `gh` CLI (`gh api`), through the same injectable
36
+ * `runGh` seam `check-pin-drift.mjs` uses (`scripts/lib/gh-json.mjs`), so the
37
+ * whole pipeline is exercised offline in tests with canned responses.
38
+ *
39
+ * Usage:
40
+ * node scripts/check-runner-health.mjs
41
+ * node scripts/check-runner-health.mjs --config scripts/runner-fleet-consumers.json
42
+ * node scripts/check-runner-health.mjs --json # machine-readable envelope
43
+ *
44
+ * Exit codes:
45
+ * 0 — every repo healthy.
46
+ * 1 — at least one repo unhealthy (offline runner, count shortfall, stale
47
+ * queued run, or a fetch error), OR a fatal error (bad config).
48
+ *
49
+ * GitHub Actions: when GITHUB_STEP_SUMMARY is set, the human-readable report
50
+ * is also appended there so it renders on the job summary page.
51
+ */
52
+
53
+ import { readFileSync, appendFileSync } from "node:fs";
54
+ import { resolve } from "node:path";
55
+
56
+ import { defaultGhRunner, ghApiJson, isNotFound } from "./lib/gh-json.mjs";
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Arg parsing
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /**
63
+ * @param {string[]} argv
64
+ * @returns {{ config: string, json: boolean }}
65
+ */
66
+ export function parseArgv(argv = []) {
67
+ let config = "scripts/runner-fleet-consumers.json";
68
+ let json = false;
69
+ for (let i = 0; i < argv.length; i += 1) {
70
+ const a = argv[i];
71
+ if (a === "--config") {
72
+ const next = argv[i + 1];
73
+ if (next && !next.startsWith("--")) {
74
+ config = next;
75
+ i += 1;
76
+ }
77
+ } else if (a === "--json") {
78
+ json = true;
79
+ }
80
+ }
81
+ return { config, json };
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Pure helpers (exported for unit-style probing without GitHub access)
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /**
89
+ * Does a runner's label set satisfy the expected label roster? A runner
90
+ * "matches" when every expected label is present among the runner's labels
91
+ * (order-independent, extra labels on the runner are fine).
92
+ *
93
+ * @param {string[]} runnerLabels
94
+ * @param {string[]} expectedLabels
95
+ * @returns {boolean}
96
+ */
97
+ export function runnerMatchesLabels(runnerLabels, expectedLabels) {
98
+ const have = new Set((runnerLabels || []).map((l) => l.toLowerCase()));
99
+ return (expectedLabels || []).every((l) => have.has(l.toLowerCase()));
100
+ }
101
+
102
+ /**
103
+ * Classify one repo's live runner list against its expected roster.
104
+ *
105
+ * @param {Array<{ id: number, name: string, status: string, busy?: boolean, labels: Array<{ name: string }> }>} runners
106
+ * @param {{ expectedCount: number, labels: string[] }} expected
107
+ * @returns {{
108
+ * total: number,
109
+ * online: number,
110
+ * offline: Array<{ id: number, name: string, status: string }>,
111
+ * matchingOnline: number,
112
+ * shortfall: number,
113
+ * hasShortfall: boolean,
114
+ * hasOffline: boolean,
115
+ * }}
116
+ */
117
+ export function classifyRunners(runners, expected) {
118
+ const list = Array.isArray(runners) ? runners : [];
119
+ const offline = list
120
+ .filter((r) => r.status !== "online")
121
+ .map((r) => ({ id: r.id, name: r.name, status: r.status }));
122
+ const online = list.filter((r) => r.status === "online");
123
+ const matchingOnline = online.filter((r) =>
124
+ runnerMatchesLabels(
125
+ (r.labels || []).map((l) => (typeof l === "string" ? l : l.name)),
126
+ expected.labels,
127
+ ),
128
+ ).length;
129
+ const expectedCount =
130
+ typeof expected.expectedCount === "number" ? expected.expectedCount : 0;
131
+ const shortfall = Math.max(0, expectedCount - matchingOnline);
132
+ return {
133
+ total: list.length,
134
+ online: online.length,
135
+ offline,
136
+ matchingOnline,
137
+ shortfall,
138
+ hasShortfall: shortfall > 0,
139
+ hasOffline: offline.length > 0,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Is a workflow run "stuck in queue"? True when its status is `queued` or
145
+ * `waiting`, it is older than `staleMinutes`, and no online runner in
146
+ * `onlineLabelSets` matches every one of its `labels` (i.e. nothing could ever
147
+ * pick it up).
148
+ *
149
+ * @param {{ status: string, created_at: string, labels?: string[] }} run A workflow run summary (labels resolved from its job requirements when available).
150
+ * @param {string[][]} onlineLabelSets Each online runner's label list.
151
+ * @param {number} staleMinutes
152
+ * @param {number} [nowMs] Injectable current epoch ms (for tests).
153
+ * @returns {boolean}
154
+ */
155
+ export function isStaleQueuedRun(run, onlineLabelSets, staleMinutes, nowMs = Date.now()) {
156
+ if (run.status !== "queued" && run.status !== "waiting") return false;
157
+ const createdMs = Date.parse(run.created_at);
158
+ if (Number.isNaN(createdMs)) return false;
159
+ const ageMinutes = (nowMs - createdMs) / 60000;
160
+ if (ageMinutes < staleMinutes) return false;
161
+ const runLabels = Array.isArray(run.labels) ? run.labels : [];
162
+ if (runLabels.length === 0) {
163
+ // No label info to match against — can't assert "nothing could pick this
164
+ // up" without over-claiming, so don't flag it as stale from labels alone.
165
+ return false;
166
+ }
167
+ const anyOnlineMatches = onlineLabelSets.some((labels) =>
168
+ runnerMatchesLabels(labels, runLabels),
169
+ );
170
+ return !anyOnlineMatches;
171
+ }
172
+
173
+ /**
174
+ * Combine the three health signals into a single per-repo health verdict.
175
+ *
176
+ * @param {ReturnType<typeof classifyRunners>} runnerVerdict
177
+ * @param {Array<object>} staleRuns
178
+ * @returns {boolean} true when the repo is healthy (no offline, no shortfall, no stale queued runs).
179
+ */
180
+ export function isRepoHealthy(runnerVerdict, staleRuns) {
181
+ return (
182
+ !runnerVerdict.hasOffline &&
183
+ !runnerVerdict.hasShortfall &&
184
+ (!Array.isArray(staleRuns) || staleRuns.length === 0)
185
+ );
186
+ }
187
+
188
+ /**
189
+ * Render the human-readable dashboard report.
190
+ *
191
+ * @param {{
192
+ * results: Array<{
193
+ * name: string,
194
+ * repo: string,
195
+ * error?: string,
196
+ * verdict?: ReturnType<typeof classifyRunners>,
197
+ * staleRuns?: Array<{ id: number, html_url?: string, created_at: string }>,
198
+ * healthy?: boolean,
199
+ * }>,
200
+ * }} report
201
+ * @returns {string}
202
+ */
203
+ export function renderReport(report) {
204
+ const out = [];
205
+ out.push("## Runner-fleet health dashboard");
206
+ out.push("");
207
+ out.push(
208
+ "All self-hosted runners are co-resident on one operator Mac — a wedged " +
209
+ "host silently stalls every listed repo's CI with no other alert.",
210
+ );
211
+ out.push("");
212
+ out.push("| Repo | Online / Expected | Offline | Stale queued | Status |");
213
+ out.push("| ---- | ------------------ | ------- | ------------- | ------ |");
214
+
215
+ const problemLines = [];
216
+ for (const r of report.results) {
217
+ if (r.error) {
218
+ out.push(`| \`${r.name}\` | — | — | — | ⚠️ error |`);
219
+ problemLines.push(`- \`${r.name}\` (${r.repo}): error — ${r.error}`);
220
+ continue;
221
+ }
222
+ const v = r.verdict;
223
+ const stale = Array.isArray(r.staleRuns) ? r.staleRuns : [];
224
+ const healthy = r.healthy === true;
225
+ const status = healthy ? "✅ healthy" : "❌ degraded";
226
+ out.push(
227
+ `| \`${r.name}\` | ${v.matchingOnline}/${v.matchingOnline + v.shortfall} | ${v.offline.length} | ${stale.length} | ${status} |`,
228
+ );
229
+ if (!healthy) {
230
+ if (v.hasOffline) {
231
+ const names = v.offline.map((o) => `\`${o.name}\` (${o.status})`).join(", ");
232
+ problemLines.push(
233
+ `- \`${r.name}\` (${r.repo}): OFFLINE runner(s) — ${names}.`,
234
+ );
235
+ }
236
+ if (v.hasShortfall) {
237
+ problemLines.push(
238
+ `- \`${r.name}\` (${r.repo}): SHORTFALL — ${v.matchingOnline} online runner(s) matching labels, expected at least ${v.matchingOnline + v.shortfall}.`,
239
+ );
240
+ }
241
+ if (stale.length > 0) {
242
+ const runs = stale
243
+ .map((run) => (run.html_url ? `[#${run.id}](${run.html_url})` : `#${run.id}`))
244
+ .join(", ");
245
+ problemLines.push(
246
+ `- \`${r.name}\` (${r.repo}): STALE QUEUED RUN(S) — ${runs} queued with no matching online runner.`,
247
+ );
248
+ }
249
+ }
250
+ }
251
+
252
+ out.push("");
253
+ if (problemLines.length > 0) {
254
+ out.push("### Degraded");
255
+ out.push("");
256
+ out.push(...problemLines);
257
+ out.push("");
258
+ out.push(
259
+ "**Operator response:** wake/reboot the Mac, check disk space, and " +
260
+ "restart the launchd runner services. See " +
261
+ "`templates/runbooks/runner-fleet-health.md`.",
262
+ );
263
+ } else {
264
+ out.push("### ✅ Fleet healthy");
265
+ out.push("");
266
+ out.push("Every configured repo has its expected online runner count and no stale queued runs.");
267
+ }
268
+ out.push("");
269
+ return out.join("\n");
270
+ }
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // GitHub access — runners + workflow runs.
274
+ // ---------------------------------------------------------------------------
275
+
276
+ /**
277
+ * Fetch a repo's live self-hosted runner list.
278
+ *
279
+ * @param {string} repo "owner/name".
280
+ * @param {(args: string[]) => string} runGh
281
+ * @returns {Array<{ id: number, name: string, status: string, busy?: boolean, labels: Array<{ name: string }> }>}
282
+ */
283
+ export function fetchRunners(repo, runGh) {
284
+ let obj;
285
+ try {
286
+ obj = ghApiJson(`repos/${repo}/actions/runners?per_page=100`, runGh);
287
+ } catch (err) {
288
+ if (isNotFound(err)) return [];
289
+ throw err;
290
+ }
291
+ return Array.isArray(obj?.runners) ? obj.runners : [];
292
+ }
293
+
294
+ /**
295
+ * Fetch a repo's currently queued/waiting workflow runs (for stale-queue
296
+ * detection). Returns [] on a 404 (repo unreadable, or genuinely no runs);
297
+ * any other error propagates so the caller records an `error` row.
298
+ *
299
+ * @param {string} repo
300
+ * @param {(args: string[]) => string} runGh
301
+ * @returns {Array<{ id: number, status: string, created_at: string, html_url?: string, labels?: string[] }>}
302
+ */
303
+ export function fetchQueuedRuns(repo, runGh) {
304
+ let obj;
305
+ try {
306
+ obj = ghApiJson(
307
+ `repos/${repo}/actions/runs?status=queued&per_page=50`,
308
+ runGh,
309
+ );
310
+ } catch (err) {
311
+ if (isNotFound(err)) return [];
312
+ throw err;
313
+ }
314
+ const queued = Array.isArray(obj?.workflow_runs) ? obj.workflow_runs : [];
315
+ let waitingObj;
316
+ try {
317
+ waitingObj = ghApiJson(
318
+ `repos/${repo}/actions/runs?status=waiting&per_page=50`,
319
+ runGh,
320
+ );
321
+ } catch (err) {
322
+ if (isNotFound(err)) return queued;
323
+ throw err;
324
+ }
325
+ const waiting = Array.isArray(waitingObj?.workflow_runs)
326
+ ? waitingObj.workflow_runs
327
+ : [];
328
+ return [...queued, ...waiting];
329
+ }
330
+
331
+ // ---------------------------------------------------------------------------
332
+ // Orchestration
333
+ // ---------------------------------------------------------------------------
334
+
335
+ /**
336
+ * Build the full health report for the configured repos.
337
+ *
338
+ * @param {{
339
+ * repos: Array<{ name: string, repo: string, expectedCount: number, labels: string[], staleQueuedMinutes?: number }>,
340
+ * defaultStaleQueuedMinutes?: number,
341
+ * }} config
342
+ * @param {(args: string[]) => string} runGh
343
+ * @param {number} [nowMs]
344
+ * @returns {{ results: Array<object> }}
345
+ */
346
+ export function buildReport(config, runGh, nowMs = Date.now()) {
347
+ const defaultStale =
348
+ typeof config.defaultStaleQueuedMinutes === "number"
349
+ ? config.defaultStaleQueuedMinutes
350
+ : 20;
351
+ const results = [];
352
+ for (const entry of config.repos) {
353
+ try {
354
+ const runners = fetchRunners(entry.repo, runGh);
355
+ const verdict = classifyRunners(runners, entry);
356
+ const onlineLabelSets = runners
357
+ .filter((r) => r.status === "online")
358
+ .map((r) => (r.labels || []).map((l) => (typeof l === "string" ? l : l.name)));
359
+ const staleMinutes =
360
+ typeof entry.staleQueuedMinutes === "number"
361
+ ? entry.staleQueuedMinutes
362
+ : defaultStale;
363
+ const queuedRuns = fetchQueuedRuns(entry.repo, runGh);
364
+ const staleRuns = queuedRuns.filter((run) =>
365
+ isStaleQueuedRun(run, onlineLabelSets, staleMinutes, nowMs),
366
+ );
367
+ results.push({
368
+ name: entry.name,
369
+ repo: entry.repo,
370
+ verdict,
371
+ staleRuns,
372
+ healthy: isRepoHealthy(verdict, staleRuns),
373
+ });
374
+ } catch (err) {
375
+ results.push({
376
+ name: entry.name,
377
+ repo: entry.repo,
378
+ error: err instanceof Error ? err.message : String(err),
379
+ healthy: false,
380
+ });
381
+ }
382
+ }
383
+ return { results };
384
+ }
385
+
386
+ /**
387
+ * @param {{ results: Array<{ error?: string, healthy?: boolean }> }} report
388
+ * @returns {boolean} true when any repo is unhealthy or errored.
389
+ */
390
+ export function hasUnhealthy(report) {
391
+ return report.results.some((r) => r.error || r.healthy === false);
392
+ }
393
+
394
+ // ---------------------------------------------------------------------------
395
+ // CLI entry
396
+ // ---------------------------------------------------------------------------
397
+
398
+ /**
399
+ * @param {{
400
+ * argv?: string[],
401
+ * cwd?: string,
402
+ * stdout?: { write: (s: string) => void },
403
+ * stderr?: { write: (s: string) => void },
404
+ * runGh?: (args: string[]) => string,
405
+ * summaryPath?: string | undefined,
406
+ * nowMs?: number,
407
+ * }} [opts]
408
+ * @returns {number} exit code
409
+ */
410
+ export function runCli({
411
+ argv = process.argv.slice(2),
412
+ cwd = process.cwd(),
413
+ stdout = process.stdout,
414
+ stderr = process.stderr,
415
+ runGh = defaultGhRunner,
416
+ summaryPath = process.env.GITHUB_STEP_SUMMARY,
417
+ nowMs = Date.now(),
418
+ } = {}) {
419
+ const { config: configRel, json } = parseArgv(argv);
420
+ const configPath = resolve(cwd, configRel);
421
+
422
+ let config;
423
+ try {
424
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
425
+ } catch (err) {
426
+ stderr.write(
427
+ `[runner-health] ❌ failed to read config ${configPath}: ${err instanceof Error ? err.message : String(err)}\n`,
428
+ );
429
+ return 1;
430
+ }
431
+ if (!Array.isArray(config.repos)) {
432
+ stderr.write(`[runner-health] ❌ config must define { repos: [] }\n`);
433
+ return 1;
434
+ }
435
+
436
+ const report = buildReport(config, runGh, nowMs);
437
+ const unhealthy = hasUnhealthy(report);
438
+
439
+ if (json) {
440
+ stdout.write(
441
+ `${JSON.stringify({ kind: "runner-fleet-health-report", unhealthy, ...report }, null, 2)}\n`,
442
+ );
443
+ } else {
444
+ const text = renderReport(report);
445
+ stdout.write(`${text}\n`);
446
+ if (summaryPath) {
447
+ try {
448
+ appendFileSync(summaryPath, `${text}\n`);
449
+ } catch (err) {
450
+ stderr.write(
451
+ `[runner-health] ⚠ could not write job summary: ${err instanceof Error ? err.message : String(err)}\n`,
452
+ );
453
+ }
454
+ }
455
+ }
456
+
457
+ if (unhealthy) {
458
+ stderr.write(`[runner-health] ❌ fleet degraded\n`);
459
+ return 1;
460
+ }
461
+ return 0;
462
+ }
463
+
464
+ // Direct-invocation guard (matches the repo's other scripts/*.mjs entry style).
465
+ const invokedDirectly =
466
+ process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
467
+ if (invokedDirectly) {
468
+ process.exit(runCli());
469
+ }