@assemblyline/al-control 0.0.0-stage → 0.3.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/src/probe.js ADDED
@@ -0,0 +1,806 @@
1
+ /**
2
+ * Probe — the only code that touches a host's filesystem or process table.
3
+ *
4
+ * Self-contained by design: imports nothing but Node built-ins, so the client
5
+ * can send this file verbatim over SSH and run it with `node -` on a host that
6
+ * has no al-control installed (see transport.js). Locally the client imports
7
+ * `runQuery` in-process. Either way the result is plain JSON — no Maps, no
8
+ * formatting.
9
+ *
10
+ * It also owns the line model (station order, terminal station, per-line
11
+ * summary) so every client renders the same answer from the `lines` query: the
12
+ * CLI imports these functions, and the control-room desktop app vendors this
13
+ * file byte for byte instead of porting the logic. Positions and formatting stay
14
+ * client-side (utils/line-state.js).
15
+ *
16
+ * Adapted from al-factory's station-files.js, line-state.js and
17
+ * factory-processes.js, with every cwd-relative path replaced by an explicit
18
+ * factory root.
19
+ */
20
+
21
+ import fs from 'node:fs';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+ import { execSync } from 'node:child_process';
25
+
26
+ export const ALL_BINS = ['input', 'doing', 'output', 'failed', 'done'];
27
+ export const LIVE_BINS = ['input', 'doing', 'output', 'failed'];
28
+ const MAX_BIN_DEPTH = 4; // depth limit for sub-bin walks (output/invoices/hi-1/ = depth 2)
29
+ const ERROR_MESSAGE_CAP = 2000;
30
+ const READ_FILE_CAP = 20 * 1024 * 1024;
31
+
32
+ class ProbeError extends Error {
33
+ constructor(code, message) {
34
+ super(message);
35
+ this.code = code;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * @param {string} p - Path on this host; may start with `~`
41
+ * @returns {string} Absolute path
42
+ */
43
+ function resolveRoot(p) {
44
+ if (p === '~') return os.homedir();
45
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
46
+ return path.resolve(p);
47
+ }
48
+
49
+ function requireFactoryRoot(p) {
50
+ const root = resolveRoot(p);
51
+ if (!fs.existsSync(root)) throw new ProbeError('FACTORY_NOT_FOUND', `Factory directory does not exist: ${root}`);
52
+ return root;
53
+ }
54
+
55
+ function readJsonFiles(dir) {
56
+ if (!fs.existsSync(dir)) return [];
57
+ const out = [];
58
+ for (const f of fs.readdirSync(dir).sort()) {
59
+ if (!f.endsWith('.json')) continue;
60
+ try {
61
+ out.push(JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')));
62
+ } catch {
63
+ // skip malformed files
64
+ }
65
+ }
66
+ return out;
67
+ }
68
+
69
+ /**
70
+ * Station and line definitions from `.orchestrator/`.
71
+ * @param {string} root
72
+ */
73
+ function readDefs(root) {
74
+ const stations = readJsonFiles(path.join(root, '.orchestrator', 'stations'))
75
+ .map((data) => ({ line: data.line ?? null, data }));
76
+ const lines = {};
77
+ for (const data of readJsonFiles(path.join(root, '.orchestrator', 'lines'))) {
78
+ if (data.code) lines[data.code] = data;
79
+ }
80
+ // Committed factory settings, e.g. { orchestrator_url, org } for work-record links.
81
+ let factory = null;
82
+ try {
83
+ factory = JSON.parse(fs.readFileSync(path.join(root, '.orchestrator', 'factory.json'), 'utf8'));
84
+ } catch {
85
+ // absent or not JSON
86
+ }
87
+ return { stations, lines, factory };
88
+ }
89
+
90
+ /**
91
+ * Walk a bin directory and find every workpiece — any directory containing a
92
+ * `pointer.json` marker — descending through organisational sub-bins
93
+ * (`output/invoices/`) up to MAX_BIN_DEPTH.
94
+ *
95
+ * With `orphans`, a directory with no `pointer.json` that holds files itself
96
+ * (sub-bins hold only folders) is recorded there instead of walked into: a
97
+ * failed folder that isn't a workpiece, so every other reader skips it.
98
+ *
99
+ * @param {string} bin_dir
100
+ * @param {Object<string, string>} [orphans] - filled with dir name → path relative to bin_dir
101
+ * @returns {Object<string, string>} workpiece_id → path relative to bin_dir
102
+ */
103
+ function scanBinWorkpieces(bin_dir, orphans) {
104
+ const found = {};
105
+ const walk = (dir, depth, relpath) => {
106
+ if (depth >= MAX_BIN_DEPTH) return;
107
+ let entries;
108
+ try {
109
+ entries = fs.readdirSync(dir, { withFileTypes: true });
110
+ } catch {
111
+ return;
112
+ }
113
+ for (const entry of entries) {
114
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
115
+ const child_dir = path.join(dir, entry.name);
116
+ const child_rel = relpath ? `${relpath}/${entry.name}` : entry.name;
117
+ if (fs.existsSync(path.join(child_dir, 'pointer.json'))) found[entry.name] = child_rel;
118
+ else if (orphans && holdsFiles(child_dir)) orphans[entry.name] = child_rel;
119
+ else walk(child_dir, depth + 1, child_rel);
120
+ }
121
+ };
122
+ walk(bin_dir, 0, '');
123
+ return found;
124
+ }
125
+
126
+ function holdsFiles(dir) {
127
+ try {
128
+ return fs.readdirSync(dir, { withFileTypes: true }).some((e) => e.isFile());
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Defs plus bin contents for every station assigned to a line. A bin whose
136
+ * directory doesn't exist is `null` (rendered as — by the client).
137
+ * `orphans[station]` lists failed folders without `pointer.json` (only
138
+ * stations that have any).
139
+ * @param {string} root
140
+ */
141
+ function readState(root) {
142
+ const defs = readDefs(root);
143
+ const stations_root = path.join(root, 'temp', 'stations');
144
+ const bins = {};
145
+ const orphans = {};
146
+ for (const s of defs.stations) {
147
+ if (!s.line) continue;
148
+ const code = s.data.short_code || s.data.id;
149
+ bins[code] = {};
150
+ for (const bin of ALL_BINS) {
151
+ const dir = path.join(stations_root, code, bin);
152
+ if (!fs.existsSync(dir)) { bins[code][bin] = null; continue; }
153
+ const found_orphans = {};
154
+ bins[code][bin] = scanBinWorkpieces(dir, bin === 'failed' ? found_orphans : undefined);
155
+ if (Object.keys(found_orphans).length > 0) orphans[code] = found_orphans;
156
+ }
157
+ }
158
+ return { ...defs, bins, orphans };
159
+ }
160
+
161
+ /**
162
+ * Absolute path of a workpiece dir given relative to temp/stations/.
163
+ * @param {string} root
164
+ * @param {string} rel
165
+ */
166
+ function stationsPath(root, rel) {
167
+ const stations_root = path.join(root, 'temp', 'stations');
168
+ const abs = path.join(stations_root, rel);
169
+ if (!abs.startsWith(stations_root + path.sep)) throw new ProbeError('BAD_PATH', `Refusing to touch outside temp/stations/: ${rel}`);
170
+ return abs;
171
+ }
172
+
173
+ function readErrorJson(dir) {
174
+ try {
175
+ return JSON.parse(fs.readFileSync(path.join(dir, 'error.json'), 'utf8'));
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Workpiece counts per bin for several factories in one call — what a UI
183
+ * polls (control-room's rail, every couple of seconds), so only numbers cross
184
+ * the wire, not ids. A factory that can't be read reports `error` instead of
185
+ * failing the rest.
186
+ * @param {Object<string, string>} paths - factory name → path
187
+ */
188
+ function readCounts(paths) {
189
+ const out = {};
190
+ for (const [name, p] of Object.entries(paths)) {
191
+ try {
192
+ const root = requireFactoryRoot(p);
193
+ const { stations, bins, orphans } = readState(root);
194
+ const lines = {};
195
+ for (const s of stations) if (s.line) lines[s.data.short_code || s.data.id] = s.line;
196
+ const counts = {};
197
+ for (const [code, by_bin] of Object.entries(bins)) {
198
+ counts[code] = {};
199
+ for (const [bin, ids] of Object.entries(by_bin)) counts[code][bin] = ids === null ? null : Object.keys(ids).length;
200
+ }
201
+ const orphan_counts = Object.fromEntries(Object.entries(orphans).map(([code, dirs]) => [code, Object.keys(dirs).length]));
202
+ out[name] = { root, lines, counts, orphans: orphan_counts };
203
+ } catch (err) {
204
+ out[name] = { error: err.message };
205
+ }
206
+ }
207
+ return out;
208
+ }
209
+
210
+ /**
211
+ * Last activity per workpiece dir, in ms since the epoch: the later of the
212
+ * folder's and its log.jsonl's mtime (a station appends to the log on every
213
+ * move, which doesn't touch the folder's own mtime). `null` if the dir is gone.
214
+ * @param {string} root
215
+ * @param {string[]} rel_dirs
216
+ */
217
+ function readAges(root, rel_dirs) {
218
+ const ages = {};
219
+ for (const rel of rel_dirs) {
220
+ const dir = stationsPath(root, rel);
221
+ const mtime = (p) => { try { return fs.statSync(p).mtimeMs; } catch { return null; } };
222
+ const dir_ms = mtime(dir);
223
+ ages[rel] = dir_ms === null ? null : Math.round(Math.max(dir_ms, mtime(path.join(dir, 'log.jsonl')) ?? 0));
224
+ }
225
+ return ages;
226
+ }
227
+
228
+ /**
229
+ * Parsed `log.jsonl` for each workpiece dir (relative to temp/stations/).
230
+ * Blank and malformed lines are dropped — the log is best-effort.
231
+ * @param {string} root
232
+ * @param {string[]} rel_dirs
233
+ */
234
+ function readLogs(root, rel_dirs) {
235
+ const logs = {};
236
+ for (const rel of rel_dirs) {
237
+ const log_path = path.join(stationsPath(root, rel), 'log.jsonl');
238
+ if (!fs.existsSync(log_path)) { logs[rel] = []; continue; }
239
+ logs[rel] = fs.readFileSync(log_path, 'utf8')
240
+ .split('\n')
241
+ .filter(Boolean)
242
+ .map((line) => { try { return JSON.parse(line); } catch { return null; } })
243
+ .filter(Boolean);
244
+ }
245
+ return logs;
246
+ }
247
+
248
+ /**
249
+ * error.json summaries (no stack; message capped) for grouping failures.
250
+ * `null` when a dir has no readable error.json.
251
+ * @param {string} root
252
+ * @param {string[]} rel_dirs
253
+ */
254
+ function readErrors(root, rel_dirs) {
255
+ const errors = {};
256
+ for (const rel of rel_dirs) {
257
+ const e = readErrorJson(stationsPath(root, rel));
258
+ errors[rel] = e && {
259
+ failed_at: e.failed_at ?? null,
260
+ name: e.name ?? null,
261
+ code: e.code ?? null,
262
+ message: String(e.message ?? '').slice(0, ERROR_MESSAGE_CAP),
263
+ };
264
+ }
265
+ return errors;
266
+ }
267
+
268
+ /**
269
+ * One workpiece dir: every file under it (relative paths, sizes) and its full
270
+ * error.json.
271
+ * @param {string} root
272
+ * @param {string} rel
273
+ */
274
+ function readWorkpiece(root, rel) {
275
+ const dir = stationsPath(root, rel);
276
+ if (!fs.existsSync(dir)) throw new ProbeError('NOT_FOUND', `No workpiece folder at temp/stations/${rel}`);
277
+ const files = [];
278
+ const walk = (d, prefix) => {
279
+ for (const entry of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
280
+ const child = path.join(d, entry.name);
281
+ const name = prefix + entry.name;
282
+ if (entry.isDirectory()) walk(child, `${name}/`);
283
+ else files.push({ name, size: fs.statSync(child).size });
284
+ }
285
+ };
286
+ walk(dir, '');
287
+ return { files, error: readErrorJson(dir) };
288
+ }
289
+
290
+ /**
291
+ * One file inside a workpiece dir. Text comes back as utf8, anything with a
292
+ * NUL byte as base64, so the client can write it out byte-for-byte.
293
+ * @param {string} root
294
+ * @param {string} rel
295
+ * @param {string} file - path relative to the workpiece dir
296
+ */
297
+ function readWorkpieceFile(root, rel, file) {
298
+ const dir = stationsPath(root, rel);
299
+ const abs = path.join(dir, file);
300
+ if (!abs.startsWith(dir + path.sep)) throw new ProbeError('BAD_PATH', `Refusing to read outside the workpiece folder: ${file}`);
301
+ let stat;
302
+ try {
303
+ stat = fs.statSync(abs);
304
+ } catch {
305
+ throw new ProbeError('NOT_FOUND', `No file '${file}' in temp/stations/${rel}`);
306
+ }
307
+ if (!stat.isFile()) throw new ProbeError('BAD_PATH', `'${file}' is not a file`);
308
+ if (stat.size > READ_FILE_CAP) throw new ProbeError('TOO_LARGE', `'${file}' is ${stat.size} bytes; the limit is ${READ_FILE_CAP}. Use \`workpieces open\` instead.`);
309
+ const buf = fs.readFileSync(abs);
310
+ return buf.includes(0)
311
+ ? { encoding: 'base64', content: buf.toString('base64') }
312
+ : { encoding: 'utf8', content: buf.toString('utf8') };
313
+ }
314
+
315
+ /**
316
+ * Delete workpiece dirs. The client picks them; this re-checks every one and
317
+ * refuses the whole batch, before deleting anything, if any dir isn't a
318
+ * workpiece under a `done` or `output` bin, or sits in a kept bin.
319
+ * @param {string} root
320
+ * @param {string[]} rel_dirs - `STATION/BIN/...` relative to temp/stations/
321
+ * @param {string[]} keep - `STATION/BIN` entries never to clear
322
+ */
323
+ function removeWorkpieces(root, rel_dirs, keep) {
324
+ const kept = new Set(keep);
325
+ const targets = rel_dirs.map((rel) => {
326
+ const [station, bin] = rel.split('/');
327
+ if (bin !== 'done' && bin !== 'output') throw new ProbeError('REFUSED', `Only done and output bins can be cleared: ${rel}`);
328
+ if (kept.has(`${station}/${bin}`)) throw new ProbeError('REFUSED', `${station}/${bin} is in this factory's keep list`);
329
+ const abs = stationsPath(root, rel);
330
+ if (!fs.existsSync(path.join(abs, 'pointer.json'))) throw new ProbeError('REFUSED', `Not a workpiece (no pointer.json): ${rel}`);
331
+ return abs;
332
+ });
333
+ for (const abs of targets) fs.rmSync(abs, { recursive: true, force: true });
334
+ return { removed: rel_dirs };
335
+ }
336
+
337
+ /**
338
+ * Move workpiece dirs back to their own station's `input` for a retry. The
339
+ * whole batch is checked before anything moves: each dir is a workpiece
340
+ * (has pointer.json) in a `failed` or `done` bin, and `input/<id>` is free.
341
+ * lib-factory reads input flat, so a sub-bin source still lands at
342
+ * `input/<id>`. The stale error.json / error.txt go (lib-factory never clears
343
+ * them, so a successful retry would carry them into done/), and log.jsonl
344
+ * gets a `requeued` event.
345
+ * @param {string} root
346
+ * @param {string[]} rel_dirs - `STATION/BIN/...` relative to temp/stations/
347
+ */
348
+ function requeueWorkpieces(root, rel_dirs) {
349
+ const seen = new Set();
350
+ const moves = rel_dirs.map((rel) => {
351
+ const abs = stationsPath(root, rel);
352
+ // Split the normalised path, so `X/failed/../output/wp` can't pass as failed.
353
+ const [station, bin] = path.relative(path.join(root, 'temp', 'stations'), abs).split(path.sep);
354
+ if (bin !== 'failed' && bin !== 'done') throw new ProbeError('REFUSED', `Only failed and done workpieces can be retried: ${rel}`);
355
+ if (!fs.existsSync(path.join(abs, 'pointer.json'))) throw new ProbeError('REFUSED', `Not a workpiece (no pointer.json): ${rel}`);
356
+ const target = stationsPath(root, `${station}/input/${path.basename(abs)}`);
357
+ if (fs.existsSync(target) || seen.has(target)) throw new ProbeError('REFUSED', `${station}/input/${path.basename(abs)} already exists`);
358
+ seen.add(target);
359
+ return { abs, target, station, bin };
360
+ });
361
+ for (const { abs, target, station, bin } of moves) {
362
+ fs.rmSync(path.join(abs, 'error.json'), { force: true });
363
+ fs.rmSync(path.join(abs, 'error.txt'), { force: true });
364
+ const event = { ts: new Date().toISOString(), station, event: 'requeued', from: bin };
365
+ fs.appendFileSync(path.join(abs, 'log.jsonl'), `${JSON.stringify(event)}\n`);
366
+ fs.mkdirSync(path.dirname(target), { recursive: true });
367
+ fs.renameSync(abs, target);
368
+ }
369
+ return { requeued: rel_dirs };
370
+ }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // Processes (macOS / Linux)
374
+ // ---------------------------------------------------------------------------
375
+
376
+ function sh(cmd) {
377
+ try {
378
+ return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 10 * 1024 * 1024 });
379
+ } catch {
380
+ return '';
381
+ }
382
+ }
383
+
384
+ function processSnapshot() {
385
+ // Leading `\s*` is load-bearing: `ps` right-aligns and space-pads pid/ppid.
386
+ const re = /^\s*(\d+)\s+(\d+)\s+(\w{3}\s+\w{3}\s+\d+\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(\S+)\s+(.*)$/;
387
+ const procs = new Map();
388
+ for (const line of sh('ps -A -o pid=,ppid=,lstart=,etime=,command=').split('\n')) {
389
+ const m = line.match(re);
390
+ if (!m) continue;
391
+ const [, pid, ppid, lstart, etime, command] = m;
392
+ procs.set(pid, { pid, ppid, lstart, etime, command });
393
+ }
394
+ return procs;
395
+ }
396
+
397
+ /** Linux reads /proc (no lsof dependency); macOS has no /proc, so lsof. */
398
+ function cwdOf(pid) {
399
+ if (process.platform === 'linux') {
400
+ try {
401
+ return fs.readlinkSync(`/proc/${pid}/cwd`);
402
+ } catch {
403
+ return null;
404
+ }
405
+ }
406
+ const line = sh(`lsof -p ${pid} -a -d cwd -Fn`).split('\n').find((l) => l.startsWith('n'));
407
+ return line ? line.slice(1) : null;
408
+ }
409
+
410
+ /**
411
+ * @param {string} dir
412
+ * @returns {{ main: string } | null} package.json `main` when `dir` is a factory repo
413
+ */
414
+ function factoryPackageInfo(dir) {
415
+ try {
416
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
417
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
418
+ if (!('@assemblyline/lib-factory' in deps) || !pkg.main) return null;
419
+ return { main: pkg.main };
420
+ } catch {
421
+ return null;
422
+ }
423
+ }
424
+
425
+ function listeningPort(pid) {
426
+ const line = sh(`lsof -nP -a -p ${pid} -iTCP -sTCP:LISTEN`).split('\n').find((l) => l.includes('LISTEN'));
427
+ return line?.match(/:(\d+)\s*\(LISTEN\)/)?.[1] ?? '-';
428
+ }
429
+
430
+ /** @returns {Array<object>|null} null when pm2 isn't on PATH (or not running) */
431
+ function pm2Apps() {
432
+ try {
433
+ return JSON.parse(sh('pm2 jlist')).map((p) => ({
434
+ pid: String(p.pid),
435
+ name: p.name,
436
+ cwd: p.pm2_env?.pm_cwd,
437
+ status: p.pm2_env?.status,
438
+ cpu: p.monit?.cpu ?? null,
439
+ memory: p.monit?.memory ?? null,
440
+ }));
441
+ } catch {
442
+ return null;
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Every factory process on this host, pm2-managed or run directly.
448
+ * `pm2_found: false` means pm2 couldn't be queried, so pm2-managed factories
449
+ * show up as `direct` — the client warns rather than trusting the mode.
450
+ *
451
+ * pm2-managed factories come from `pm2 jlist`'s own `pm_cwd` rather than `ps`
452
+ * command text — pm2 rewrites the process title, which can corrupt argv as
453
+ * `ps` reports it.
454
+ */
455
+ function listRunningFactories() {
456
+ if (process.platform !== 'darwin' && process.platform !== 'linux') {
457
+ throw new ProbeError('UNSUPPORTED_PLATFORM', `Process detection requires macOS or Linux (host is ${process.platform}).`);
458
+ }
459
+
460
+ const procs = processSnapshot();
461
+ const apps = pm2Apps();
462
+ const rows = [];
463
+ const claimed_cwds = new Set();
464
+
465
+ for (const app of apps ?? []) {
466
+ if (app.status !== 'online' || !app.cwd) continue;
467
+ if (!factoryPackageInfo(app.cwd)) continue; // some other, pm2 app that is not a factory
468
+ // A wrapped pm2 app ("npm run start") runs the real `node <main>` as a
469
+ // grandchild — claim the cwd so it isn't double-counted as "direct".
470
+ claimed_cwds.add(app.cwd);
471
+ const proc = procs.get(app.pid);
472
+ rows.push({
473
+ pid: app.pid,
474
+ factory: path.basename(app.cwd),
475
+ mode: 'pm2',
476
+ pm2: app.name,
477
+ cwd: app.cwd,
478
+ port: listeningPort(app.pid),
479
+ uptime: proc?.etime ?? '-',
480
+ started: proc?.lstart ?? '-',
481
+ cpu: app.cpu,
482
+ memory: app.memory,
483
+ });
484
+ }
485
+
486
+ for (const proc of procs.values()) {
487
+ if (path.basename(proc.command.split(/\s+/)[0]) !== 'node') continue;
488
+ const cwd = cwdOf(proc.pid);
489
+ if (!cwd || claimed_cwds.has(cwd)) continue;
490
+ const info = factoryPackageInfo(cwd);
491
+ if (!info || !proc.command.includes(info.main)) continue;
492
+ // `node --watch <main>` is a supervisor; its child does the real work.
493
+ if (proc.command.includes('--watch')) continue;
494
+ rows.push({
495
+ pid: proc.pid,
496
+ factory: path.basename(cwd),
497
+ mode: 'direct',
498
+ pm2: '-',
499
+ cwd,
500
+ port: listeningPort(proc.pid),
501
+ uptime: proc.etime,
502
+ started: proc.lstart,
503
+ cpu: null,
504
+ memory: null,
505
+ });
506
+ }
507
+
508
+ rows.sort((a, b) => a.factory.localeCompare(b.factory) || Number(a.pid) - Number(b.pid));
509
+ return { procs: rows, pm2_found: apps !== null };
510
+ }
511
+
512
+ // ---------------------------------------------------------------------------
513
+ // Line model (pure; shared with the CLI and vendored by control-room)
514
+ // ---------------------------------------------------------------------------
515
+
516
+ /**
517
+ * @typedef {{line: string|null, data: any}} StationDef
518
+ */
519
+
520
+ /**
521
+ * Stable code for a station def — short_code, falling back to id.
522
+ * @param {StationDef} s
523
+ * @returns {string}
524
+ */
525
+ export const codeOf = (s) => s.data.short_code || s.data.id;
526
+
527
+ /**
528
+ * Index station defs by every reference form they answer to, so a dependency
529
+ * edge written as short_code or id finds its peer.
530
+ * @param {StationDef[]} members
531
+ * @returns {Map<string, StationDef>}
532
+ */
533
+ export function indexStationsByRef(members) {
534
+ const by_ref = new Map();
535
+ for (const s of members) {
536
+ by_ref.set(codeOf(s), s);
537
+ if (s.data.id) by_ref.set(s.data.id, s);
538
+ if (s.data.short_code) by_ref.set(s.data.short_code, s);
539
+ }
540
+ return by_ref;
541
+ }
542
+
543
+ /**
544
+ * Normalise one `dependencies` entry (bare id string, or object with
545
+ * short_code/id) to a reference string.
546
+ * @param {any} dep
547
+ * @returns {string|undefined}
548
+ */
549
+ export function dependencyRef(dep) {
550
+ return typeof dep === 'string' ? dep : dep?.short_code || dep?.id;
551
+ }
552
+
553
+ /**
554
+ * Resolve a station reference (short_code or orchestrator id) to its display
555
+ * code; unchanged when the peer isn't among `stations`.
556
+ * @param {string} ref
557
+ * @param {Map<string, StationDef>|StationDef[]} stations
558
+ * @returns {string}
559
+ */
560
+ export function resolveStationRef(ref, stations) {
561
+ const by_ref = stations instanceof Map ? stations : indexStationsByRef(stations);
562
+ const match = by_ref.get(ref);
563
+ return match ? codeOf(match) : ref;
564
+ }
565
+
566
+ /**
567
+ * Topologically sort one line's station defs by `dependencies`, with an
568
+ * alphabetical pre-sort for stable sibling order.
569
+ * @param {StationDef[]} members
570
+ * @returns {StationDef[]}
571
+ */
572
+ export function topoSortStations(members) {
573
+ const by_ref = indexStationsByRef(members);
574
+ /** @param {StationDef} s */
575
+ const depsOf = (s) => (s.data.dependencies || [])
576
+ .map(dependencyRef)
577
+ .map((/** @type {string} */ ref) => by_ref.get(ref))
578
+ .filter(Boolean);
579
+
580
+ const sorted = [...members].sort((a, b) => codeOf(a).localeCompare(codeOf(b)));
581
+ const visited = new Set();
582
+ /** @type {StationDef[]} */
583
+ const result = [];
584
+ /** @param {StationDef} s */
585
+ const visit = (s) => {
586
+ const code = codeOf(s);
587
+ if (visited.has(code)) return;
588
+ visited.add(code);
589
+ for (const dep of depsOf(s)) visit(dep);
590
+ result.push(s);
591
+ };
592
+ for (const s of sorted) visit(s);
593
+ return result;
594
+ }
595
+
596
+ /**
597
+ * Terminal = station no in-line peer depends on. With several sinks, the one
598
+ * latest in topo order.
599
+ * @param {StationDef[]} ordered - From topoSortStations
600
+ * @returns {string}
601
+ */
602
+ export function computeTerminal(ordered) {
603
+ if (ordered.length === 0) return '';
604
+ const by_ref = indexStationsByRef(ordered);
605
+ const depended_on = new Set();
606
+ for (const s of ordered) {
607
+ for (const d of s.data.dependencies || []) {
608
+ const ref = dependencyRef(d);
609
+ if (ref && by_ref.has(ref)) depended_on.add(resolveStationRef(ref, by_ref));
610
+ }
611
+ }
612
+ for (let i = ordered.length - 1; i >= 0; i--) {
613
+ const c = codeOf(ordered[i]);
614
+ if (!depended_on.has(c)) return c;
615
+ }
616
+ return codeOf(ordered[ordered.length - 1]);
617
+ }
618
+
619
+ /**
620
+ * Station defs grouped by line, each line's stations in dependency order.
621
+ * @param {StationDef[]} stations
622
+ * @returns {Object<string, {stations: string[], terminal: string}>}
623
+ */
624
+ export function groupLines(stations) {
625
+ /** @type {Map<string, StationDef[]>} */
626
+ const grouped = new Map();
627
+ for (const def of stations) {
628
+ if (!def.line) continue;
629
+ if (!grouped.has(def.line)) grouped.set(def.line, []);
630
+ grouped.get(def.line)?.push(def);
631
+ }
632
+ /** @type {Object<string, {stations: string[], terminal: string}>} */
633
+ const lines = {};
634
+ for (const [code, members] of grouped) {
635
+ const ordered = topoSortStations(members);
636
+ lines[code] = { stations: ordered.map(codeOf), terminal: computeTerminal(ordered) };
637
+ }
638
+ return lines;
639
+ }
640
+
641
+ /**
642
+ * @typedef {object} LineSummary
643
+ * @property {number} in_flight - input + doing + output, excluding terminal output
644
+ * @property {number} stuck - failed across the line
645
+ * @property {number} finished - terminal-station output
646
+ * @property {string[]} stuck_locations - e.g. '3 at VM2/failed'
647
+ * @property {{count: number, at: string|null}} biggest_flow - fullest in-flight bin
648
+ */
649
+
650
+ /**
651
+ * Per-line totals from live bin counts (`done` ignored).
652
+ * @param {{stations: string[], terminal: string}} line
653
+ * @param {Object<string, Object<string, number|null>>} counts - counts[station][bin], null when the bin is missing
654
+ * @returns {LineSummary}
655
+ */
656
+ export function summarizeCounts(line, counts) {
657
+ let in_flight = 0;
658
+ let stuck = 0;
659
+ let finished = 0;
660
+ /** @type {string[]} */
661
+ const stuck_locations = [];
662
+ /** @type {{count: number, at: string|null}} */
663
+ let biggest_flow = { count: 0, at: null };
664
+
665
+ for (const station of line.stations) {
666
+ const is_terminal = station === line.terminal;
667
+ for (const bin of LIVE_BINS) {
668
+ const n = counts[station]?.[bin];
669
+ if (n === null || n === undefined) continue;
670
+ if (bin === 'failed') {
671
+ stuck += n;
672
+ if (n > 0) stuck_locations.push(`${n} at ${station}/failed`);
673
+ } else if (is_terminal && bin === 'output') {
674
+ finished += n;
675
+ } else {
676
+ in_flight += n;
677
+ if (n > biggest_flow.count) biggest_flow = { count: n, at: `${station}/${bin}` };
678
+ }
679
+ }
680
+ }
681
+ return { in_flight, stuck, finished, stuck_locations, biggest_flow };
682
+ }
683
+
684
+ /**
685
+ * The `lines` query: every line with its stations in order, terminal, live bin
686
+ * counts and summary, from one read of the factory. Counts only, no workpiece
687
+ * ids, so it stays small enough to poll.
688
+ * @param {string} root
689
+ */
690
+ function readLines(root) {
691
+ const { stations, lines: line_defs, factory, bins, orphans } = readState(root);
692
+ /** @type {Object<string, Object<string, number|null>>} */
693
+ const counts = {};
694
+ for (const [code, by_bin] of Object.entries(bins)) {
695
+ counts[code] = {};
696
+ for (const [bin, ids] of Object.entries(by_bin)) counts[code][bin] = ids === null ? null : Object.keys(ids).length;
697
+ }
698
+ const lines = {};
699
+ for (const [code, line] of Object.entries(groupLines(stations))) {
700
+ const def = line_defs[code] ?? {};
701
+ const line_counts = Object.fromEntries(line.stations.map((st) => [st, counts[st] ?? {}]));
702
+ lines[code] = {
703
+ code,
704
+ name: def.name ?? null,
705
+ location: def.location ?? null,
706
+ stations: line.stations,
707
+ terminal: line.terminal,
708
+ counts: line_counts,
709
+ orphans: Object.fromEntries(line.stations.filter((st) => orphans[st]).map((st) => [st, Object.keys(orphans[st]).length])),
710
+ summary: summarizeCounts(line, line_counts),
711
+ };
712
+ }
713
+ return { factory, lines };
714
+ }
715
+
716
+ // ---------------------------------------------------------------------------
717
+ // Entry points
718
+ // ---------------------------------------------------------------------------
719
+
720
+ /**
721
+ * @typedef {{type: 'defs', path: string}
722
+ * | {type: 'state', path: string}
723
+ * | {type: 'lines', path: string}
724
+ * | {type: 'logs', path: string, dirs: string[]}
725
+ * | {type: 'errors', path: string, dirs: string[]}
726
+ * | {type: 'ages', path: string, dirs: string[]}
727
+ * | {type: 'workpiece', path: string, dir: string}
728
+ * | {type: 'read', path: string, dir: string, file: string}
729
+ * | {type: 'remove', path: string, dirs: string[], keep: string[]}
730
+ * | {type: 'requeue', path: string, dirs: string[]}
731
+ * | {type: 'counts', paths: Object<string, string>}
732
+ * | {type: 'procs'}
733
+ * | {type: 'factories', paths: Object<string, string>}} ProbeQuery
734
+ */
735
+
736
+ /**
737
+ * @param {ProbeQuery} query
738
+ * @returns {object} JSON-serialisable result
739
+ */
740
+ export function runQuery(query) {
741
+ switch (query.type) {
742
+ case 'defs': {
743
+ const root = requireFactoryRoot(query.path);
744
+ return { root, ...readDefs(root) };
745
+ }
746
+ case 'state': {
747
+ const root = requireFactoryRoot(query.path);
748
+ return { root, ...readState(root) };
749
+ }
750
+ case 'lines': {
751
+ const root = requireFactoryRoot(query.path);
752
+ return { root, ...readLines(root) };
753
+ }
754
+ case 'logs': {
755
+ const root = requireFactoryRoot(query.path);
756
+ return { root, logs: readLogs(root, query.dirs) };
757
+ }
758
+ case 'errors': {
759
+ const root = requireFactoryRoot(query.path);
760
+ return { root, errors: readErrors(root, query.dirs) };
761
+ }
762
+ case 'ages': {
763
+ const root = requireFactoryRoot(query.path);
764
+ return { root, ages: readAges(root, query.dirs) };
765
+ }
766
+ case 'workpiece': {
767
+ const root = requireFactoryRoot(query.path);
768
+ return { root, ...readWorkpiece(root, query.dir) };
769
+ }
770
+ case 'read':
771
+ return readWorkpieceFile(requireFactoryRoot(query.path), query.dir, query.file);
772
+ case 'remove':
773
+ return removeWorkpieces(requireFactoryRoot(query.path), query.dirs, query.keep ?? []);
774
+ case 'requeue':
775
+ return requeueWorkpieces(requireFactoryRoot(query.path), query.dirs);
776
+ case 'counts':
777
+ return readCounts(query.paths);
778
+ case 'procs':
779
+ return listRunningFactories();
780
+ case 'factories': {
781
+ const factories = Object.entries(query.paths).map(([name, p]) => {
782
+ const root = resolveRoot(p);
783
+ return { name, root, exists: fs.existsSync(root), main: factoryPackageInfo(root)?.main ?? null };
784
+ });
785
+ return { factories, ...listRunningFactories() };
786
+ }
787
+ default:
788
+ throw new ProbeError('BAD_QUERY', `Unknown probe query: ${query.type}`);
789
+ }
790
+ }
791
+
792
+ /**
793
+ * Remote entry: the transport appends `main(<query as a JSON literal>)` when
794
+ * piping this file to `node -`. Result is one JSON envelope on stdout, so the
795
+ * client never has to parse a stack trace.
796
+ * @param {ProbeQuery} query
797
+ */
798
+ export function main(query) {
799
+ let out;
800
+ try {
801
+ out = { ok: true, data: runQuery(query) };
802
+ } catch (err) {
803
+ out = { ok: false, error: { code: err.code || 'PROBE_ERROR', message: err.message } };
804
+ }
805
+ process.stdout.write(JSON.stringify(out));
806
+ }