@assemblyline/al-control 0.0.0-stage → 0.1.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,591 @@
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. Grouping lines, topo ordering and position resolution happen
9
+ * client-side (utils/line-state.js).
10
+ *
11
+ * Adapted from al-factory's station-files.js, line-state.js and
12
+ * factory-processes.js, with every cwd-relative path replaced by an explicit
13
+ * factory root.
14
+ */
15
+
16
+ import fs from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import { execSync } from 'node:child_process';
20
+
21
+ const ALL_BINS = ['input', 'doing', 'output', 'failed', 'done'];
22
+ const MAX_BIN_DEPTH = 4; // depth limit for sub-bin walks (output/invoices/hi-1/ = depth 2)
23
+ const ERROR_MESSAGE_CAP = 2000;
24
+ const READ_FILE_CAP = 20 * 1024 * 1024;
25
+
26
+ class ProbeError extends Error {
27
+ constructor(code, message) {
28
+ super(message);
29
+ this.code = code;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * @param {string} p - Path on this host; may start with `~`
35
+ * @returns {string} Absolute path
36
+ */
37
+ function resolveRoot(p) {
38
+ if (p === '~') return os.homedir();
39
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
40
+ return path.resolve(p);
41
+ }
42
+
43
+ function requireFactoryRoot(p) {
44
+ const root = resolveRoot(p);
45
+ if (!fs.existsSync(root)) throw new ProbeError('FACTORY_NOT_FOUND', `Factory directory does not exist: ${root}`);
46
+ return root;
47
+ }
48
+
49
+ function readJsonFiles(dir) {
50
+ if (!fs.existsSync(dir)) return [];
51
+ const out = [];
52
+ for (const f of fs.readdirSync(dir).sort()) {
53
+ if (!f.endsWith('.json')) continue;
54
+ try {
55
+ out.push(JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')));
56
+ } catch {
57
+ // skip malformed files
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /**
64
+ * Station and line definitions from `.orchestrator/`.
65
+ * @param {string} root
66
+ */
67
+ function readDefs(root) {
68
+ const stations = readJsonFiles(path.join(root, '.orchestrator', 'stations'))
69
+ .map((data) => ({ line: data.line ?? null, data }));
70
+ const lines = {};
71
+ for (const data of readJsonFiles(path.join(root, '.orchestrator', 'lines'))) {
72
+ if (data.code) lines[data.code] = data;
73
+ }
74
+ // Committed factory settings, e.g. { orchestrator_url, org } for work-record links.
75
+ let factory = null;
76
+ try {
77
+ factory = JSON.parse(fs.readFileSync(path.join(root, '.orchestrator', 'factory.json'), 'utf8'));
78
+ } catch {
79
+ // absent or not JSON
80
+ }
81
+ return { stations, lines, factory };
82
+ }
83
+
84
+ /**
85
+ * Walk a bin directory and find every workpiece — any directory containing a
86
+ * `pointer.json` marker — descending through organisational sub-bins
87
+ * (`output/invoices/`) up to MAX_BIN_DEPTH.
88
+ *
89
+ * With `orphans`, a directory with no `pointer.json` that holds files itself
90
+ * (sub-bins hold only folders) is recorded there instead of walked into: a
91
+ * failed folder that isn't a workpiece, so every other reader skips it.
92
+ *
93
+ * @param {string} bin_dir
94
+ * @param {Object<string, string>} [orphans] - filled with dir name → path relative to bin_dir
95
+ * @returns {Object<string, string>} workpiece_id → path relative to bin_dir
96
+ */
97
+ function scanBinWorkpieces(bin_dir, orphans) {
98
+ const found = {};
99
+ const walk = (dir, depth, relpath) => {
100
+ if (depth >= MAX_BIN_DEPTH) return;
101
+ let entries;
102
+ try {
103
+ entries = fs.readdirSync(dir, { withFileTypes: true });
104
+ } catch {
105
+ return;
106
+ }
107
+ for (const entry of entries) {
108
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
109
+ const child_dir = path.join(dir, entry.name);
110
+ const child_rel = relpath ? `${relpath}/${entry.name}` : entry.name;
111
+ if (fs.existsSync(path.join(child_dir, 'pointer.json'))) found[entry.name] = child_rel;
112
+ else if (orphans && holdsFiles(child_dir)) orphans[entry.name] = child_rel;
113
+ else walk(child_dir, depth + 1, child_rel);
114
+ }
115
+ };
116
+ walk(bin_dir, 0, '');
117
+ return found;
118
+ }
119
+
120
+ function holdsFiles(dir) {
121
+ try {
122
+ return fs.readdirSync(dir, { withFileTypes: true }).some((e) => e.isFile());
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Defs plus bin contents for every station assigned to a line. A bin whose
130
+ * directory doesn't exist is `null` (rendered as — by the client).
131
+ * `orphans[station]` lists failed folders without `pointer.json` (only
132
+ * stations that have any).
133
+ * @param {string} root
134
+ */
135
+ function readState(root) {
136
+ const defs = readDefs(root);
137
+ const stations_root = path.join(root, 'temp', 'stations');
138
+ const bins = {};
139
+ const orphans = {};
140
+ for (const s of defs.stations) {
141
+ if (!s.line) continue;
142
+ const code = s.data.short_code || s.data.id;
143
+ bins[code] = {};
144
+ for (const bin of ALL_BINS) {
145
+ const dir = path.join(stations_root, code, bin);
146
+ if (!fs.existsSync(dir)) { bins[code][bin] = null; continue; }
147
+ const found_orphans = {};
148
+ bins[code][bin] = scanBinWorkpieces(dir, bin === 'failed' ? found_orphans : undefined);
149
+ if (Object.keys(found_orphans).length > 0) orphans[code] = found_orphans;
150
+ }
151
+ }
152
+ return { ...defs, bins, orphans };
153
+ }
154
+
155
+ /**
156
+ * Absolute path of a workpiece dir given relative to temp/stations/.
157
+ * @param {string} root
158
+ * @param {string} rel
159
+ */
160
+ function stationsPath(root, rel) {
161
+ const stations_root = path.join(root, 'temp', 'stations');
162
+ const abs = path.join(stations_root, rel);
163
+ if (!abs.startsWith(stations_root + path.sep)) throw new ProbeError('BAD_PATH', `Refusing to touch outside temp/stations/: ${rel}`);
164
+ return abs;
165
+ }
166
+
167
+ function readErrorJson(dir) {
168
+ try {
169
+ return JSON.parse(fs.readFileSync(path.join(dir, 'error.json'), 'utf8'));
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Workpiece counts per bin for several factories in one call — what a UI
177
+ * polls (control-room's rail, every couple of seconds), so only numbers cross
178
+ * the wire, not ids. A factory that can't be read reports `error` instead of
179
+ * failing the rest.
180
+ * @param {Object<string, string>} paths - factory name → path
181
+ */
182
+ function readCounts(paths) {
183
+ const out = {};
184
+ for (const [name, p] of Object.entries(paths)) {
185
+ try {
186
+ const root = requireFactoryRoot(p);
187
+ const { stations, bins, orphans } = readState(root);
188
+ const lines = {};
189
+ for (const s of stations) if (s.line) lines[s.data.short_code || s.data.id] = s.line;
190
+ const counts = {};
191
+ for (const [code, by_bin] of Object.entries(bins)) {
192
+ counts[code] = {};
193
+ for (const [bin, ids] of Object.entries(by_bin)) counts[code][bin] = ids === null ? null : Object.keys(ids).length;
194
+ }
195
+ const orphan_counts = Object.fromEntries(Object.entries(orphans).map(([code, dirs]) => [code, Object.keys(dirs).length]));
196
+ out[name] = { root, lines, counts, orphans: orphan_counts };
197
+ } catch (err) {
198
+ out[name] = { error: err.message };
199
+ }
200
+ }
201
+ return out;
202
+ }
203
+
204
+ /**
205
+ * Last activity per workpiece dir, in ms since the epoch: the later of the
206
+ * folder's and its log.jsonl's mtime (a station appends to the log on every
207
+ * move, which doesn't touch the folder's own mtime). `null` if the dir is gone.
208
+ * @param {string} root
209
+ * @param {string[]} rel_dirs
210
+ */
211
+ function readAges(root, rel_dirs) {
212
+ const ages = {};
213
+ for (const rel of rel_dirs) {
214
+ const dir = stationsPath(root, rel);
215
+ const mtime = (p) => { try { return fs.statSync(p).mtimeMs; } catch { return null; } };
216
+ const dir_ms = mtime(dir);
217
+ ages[rel] = dir_ms === null ? null : Math.round(Math.max(dir_ms, mtime(path.join(dir, 'log.jsonl')) ?? 0));
218
+ }
219
+ return ages;
220
+ }
221
+
222
+ /**
223
+ * Parsed `log.jsonl` for each workpiece dir (relative to temp/stations/).
224
+ * Blank and malformed lines are dropped — the log is best-effort.
225
+ * @param {string} root
226
+ * @param {string[]} rel_dirs
227
+ */
228
+ function readLogs(root, rel_dirs) {
229
+ const logs = {};
230
+ for (const rel of rel_dirs) {
231
+ const log_path = path.join(stationsPath(root, rel), 'log.jsonl');
232
+ if (!fs.existsSync(log_path)) { logs[rel] = []; continue; }
233
+ logs[rel] = fs.readFileSync(log_path, 'utf8')
234
+ .split('\n')
235
+ .filter(Boolean)
236
+ .map((line) => { try { return JSON.parse(line); } catch { return null; } })
237
+ .filter(Boolean);
238
+ }
239
+ return logs;
240
+ }
241
+
242
+ /**
243
+ * error.json summaries (no stack; message capped) for grouping failures.
244
+ * `null` when a dir has no readable error.json.
245
+ * @param {string} root
246
+ * @param {string[]} rel_dirs
247
+ */
248
+ function readErrors(root, rel_dirs) {
249
+ const errors = {};
250
+ for (const rel of rel_dirs) {
251
+ const e = readErrorJson(stationsPath(root, rel));
252
+ errors[rel] = e && {
253
+ failed_at: e.failed_at ?? null,
254
+ name: e.name ?? null,
255
+ code: e.code ?? null,
256
+ message: String(e.message ?? '').slice(0, ERROR_MESSAGE_CAP),
257
+ };
258
+ }
259
+ return errors;
260
+ }
261
+
262
+ /**
263
+ * One workpiece dir: every file under it (relative paths, sizes) and its full
264
+ * error.json.
265
+ * @param {string} root
266
+ * @param {string} rel
267
+ */
268
+ function readWorkpiece(root, rel) {
269
+ const dir = stationsPath(root, rel);
270
+ if (!fs.existsSync(dir)) throw new ProbeError('NOT_FOUND', `No workpiece folder at temp/stations/${rel}`);
271
+ const files = [];
272
+ const walk = (d, prefix) => {
273
+ for (const entry of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
274
+ const child = path.join(d, entry.name);
275
+ const name = prefix + entry.name;
276
+ if (entry.isDirectory()) walk(child, `${name}/`);
277
+ else files.push({ name, size: fs.statSync(child).size });
278
+ }
279
+ };
280
+ walk(dir, '');
281
+ return { files, error: readErrorJson(dir) };
282
+ }
283
+
284
+ /**
285
+ * One file inside a workpiece dir. Text comes back as utf8, anything with a
286
+ * NUL byte as base64, so the client can write it out byte-for-byte.
287
+ * @param {string} root
288
+ * @param {string} rel
289
+ * @param {string} file - path relative to the workpiece dir
290
+ */
291
+ function readWorkpieceFile(root, rel, file) {
292
+ const dir = stationsPath(root, rel);
293
+ const abs = path.join(dir, file);
294
+ if (!abs.startsWith(dir + path.sep)) throw new ProbeError('BAD_PATH', `Refusing to read outside the workpiece folder: ${file}`);
295
+ let stat;
296
+ try {
297
+ stat = fs.statSync(abs);
298
+ } catch {
299
+ throw new ProbeError('NOT_FOUND', `No file '${file}' in temp/stations/${rel}`);
300
+ }
301
+ if (!stat.isFile()) throw new ProbeError('BAD_PATH', `'${file}' is not a file`);
302
+ 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.`);
303
+ const buf = fs.readFileSync(abs);
304
+ return buf.includes(0)
305
+ ? { encoding: 'base64', content: buf.toString('base64') }
306
+ : { encoding: 'utf8', content: buf.toString('utf8') };
307
+ }
308
+
309
+ /**
310
+ * Delete workpiece dirs. The client picks them; this re-checks every one and
311
+ * refuses the whole batch, before deleting anything, if any dir isn't a
312
+ * workpiece under a `done` or `output` bin, or sits in a kept bin.
313
+ * @param {string} root
314
+ * @param {string[]} rel_dirs - `STATION/BIN/...` relative to temp/stations/
315
+ * @param {string[]} keep - `STATION/BIN` entries never to clear
316
+ */
317
+ function removeWorkpieces(root, rel_dirs, keep) {
318
+ const kept = new Set(keep);
319
+ const targets = rel_dirs.map((rel) => {
320
+ const [station, bin] = rel.split('/');
321
+ if (bin !== 'done' && bin !== 'output') throw new ProbeError('REFUSED', `Only done and output bins can be cleared: ${rel}`);
322
+ if (kept.has(`${station}/${bin}`)) throw new ProbeError('REFUSED', `${station}/${bin} is in this factory's keep list`);
323
+ const abs = stationsPath(root, rel);
324
+ if (!fs.existsSync(path.join(abs, 'pointer.json'))) throw new ProbeError('REFUSED', `Not a workpiece (no pointer.json): ${rel}`);
325
+ return abs;
326
+ });
327
+ for (const abs of targets) fs.rmSync(abs, { recursive: true, force: true });
328
+ return { removed: rel_dirs };
329
+ }
330
+
331
+ /**
332
+ * Move workpiece dirs back to their own station's `input` for a retry. The
333
+ * whole batch is checked before anything moves: each dir is a workpiece
334
+ * (has pointer.json) in a `failed` or `done` bin, and `input/<id>` is free.
335
+ * lib-factory reads input flat, so a sub-bin source still lands at
336
+ * `input/<id>`. The stale error.json / error.txt go (lib-factory never clears
337
+ * them, so a successful retry would carry them into done/), and log.jsonl
338
+ * gets a `requeued` event.
339
+ * @param {string} root
340
+ * @param {string[]} rel_dirs - `STATION/BIN/...` relative to temp/stations/
341
+ */
342
+ function requeueWorkpieces(root, rel_dirs) {
343
+ const seen = new Set();
344
+ const moves = rel_dirs.map((rel) => {
345
+ const abs = stationsPath(root, rel);
346
+ // Split the normalised path, so `X/failed/../output/wp` can't pass as failed.
347
+ const [station, bin] = path.relative(path.join(root, 'temp', 'stations'), abs).split(path.sep);
348
+ if (bin !== 'failed' && bin !== 'done') throw new ProbeError('REFUSED', `Only failed and done workpieces can be retried: ${rel}`);
349
+ if (!fs.existsSync(path.join(abs, 'pointer.json'))) throw new ProbeError('REFUSED', `Not a workpiece (no pointer.json): ${rel}`);
350
+ const target = stationsPath(root, `${station}/input/${path.basename(abs)}`);
351
+ if (fs.existsSync(target) || seen.has(target)) throw new ProbeError('REFUSED', `${station}/input/${path.basename(abs)} already exists`);
352
+ seen.add(target);
353
+ return { abs, target, station, bin };
354
+ });
355
+ for (const { abs, target, station, bin } of moves) {
356
+ fs.rmSync(path.join(abs, 'error.json'), { force: true });
357
+ fs.rmSync(path.join(abs, 'error.txt'), { force: true });
358
+ const event = { ts: new Date().toISOString(), station, event: 'requeued', from: bin };
359
+ fs.appendFileSync(path.join(abs, 'log.jsonl'), `${JSON.stringify(event)}\n`);
360
+ fs.mkdirSync(path.dirname(target), { recursive: true });
361
+ fs.renameSync(abs, target);
362
+ }
363
+ return { requeued: rel_dirs };
364
+ }
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // Processes (macOS / Linux)
368
+ // ---------------------------------------------------------------------------
369
+
370
+ function sh(cmd) {
371
+ try {
372
+ return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 10 * 1024 * 1024 });
373
+ } catch {
374
+ return '';
375
+ }
376
+ }
377
+
378
+ function processSnapshot() {
379
+ // Leading `\s*` is load-bearing: `ps` right-aligns and space-pads pid/ppid.
380
+ 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+(.*)$/;
381
+ const procs = new Map();
382
+ for (const line of sh('ps -A -o pid=,ppid=,lstart=,etime=,command=').split('\n')) {
383
+ const m = line.match(re);
384
+ if (!m) continue;
385
+ const [, pid, ppid, lstart, etime, command] = m;
386
+ procs.set(pid, { pid, ppid, lstart, etime, command });
387
+ }
388
+ return procs;
389
+ }
390
+
391
+ /** Linux reads /proc (no lsof dependency); macOS has no /proc, so lsof. */
392
+ function cwdOf(pid) {
393
+ if (process.platform === 'linux') {
394
+ try {
395
+ return fs.readlinkSync(`/proc/${pid}/cwd`);
396
+ } catch {
397
+ return null;
398
+ }
399
+ }
400
+ const line = sh(`lsof -p ${pid} -a -d cwd -Fn`).split('\n').find((l) => l.startsWith('n'));
401
+ return line ? line.slice(1) : null;
402
+ }
403
+
404
+ /**
405
+ * @param {string} dir
406
+ * @returns {{ main: string } | null} package.json `main` when `dir` is a factory repo
407
+ */
408
+ function factoryPackageInfo(dir) {
409
+ try {
410
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
411
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
412
+ if (!('@assemblyline/lib-factory' in deps) || !pkg.main) return null;
413
+ return { main: pkg.main };
414
+ } catch {
415
+ return null;
416
+ }
417
+ }
418
+
419
+ function listeningPort(pid) {
420
+ const line = sh(`lsof -nP -a -p ${pid} -iTCP -sTCP:LISTEN`).split('\n').find((l) => l.includes('LISTEN'));
421
+ return line?.match(/:(\d+)\s*\(LISTEN\)/)?.[1] ?? '-';
422
+ }
423
+
424
+ /** @returns {Array<object>|null} null when pm2 isn't on PATH (or not running) */
425
+ function pm2Apps() {
426
+ try {
427
+ return JSON.parse(sh('pm2 jlist')).map((p) => ({
428
+ pid: String(p.pid),
429
+ name: p.name,
430
+ cwd: p.pm2_env?.pm_cwd,
431
+ status: p.pm2_env?.status,
432
+ cpu: p.monit?.cpu ?? null,
433
+ memory: p.monit?.memory ?? null,
434
+ }));
435
+ } catch {
436
+ return null;
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Every factory process on this host, pm2-managed or run directly.
442
+ * `pm2_found: false` means pm2 couldn't be queried, so pm2-managed factories
443
+ * show up as `direct` — the client warns rather than trusting the mode.
444
+ *
445
+ * pm2-managed factories come from `pm2 jlist`'s own `pm_cwd` rather than `ps`
446
+ * command text — pm2 rewrites the process title, which can corrupt argv as
447
+ * `ps` reports it.
448
+ */
449
+ function listRunningFactories() {
450
+ if (process.platform !== 'darwin' && process.platform !== 'linux') {
451
+ throw new ProbeError('UNSUPPORTED_PLATFORM', `Process detection requires macOS or Linux (host is ${process.platform}).`);
452
+ }
453
+
454
+ const procs = processSnapshot();
455
+ const apps = pm2Apps();
456
+ const rows = [];
457
+ const claimed_cwds = new Set();
458
+
459
+ for (const app of apps ?? []) {
460
+ if (app.status !== 'online' || !app.cwd) continue;
461
+ if (!factoryPackageInfo(app.cwd)) continue; // some other, pm2 app that is not a factory
462
+ // A wrapped pm2 app ("npm run start") runs the real `node <main>` as a
463
+ // grandchild — claim the cwd so it isn't double-counted as "direct".
464
+ claimed_cwds.add(app.cwd);
465
+ const proc = procs.get(app.pid);
466
+ rows.push({
467
+ pid: app.pid,
468
+ factory: path.basename(app.cwd),
469
+ mode: 'pm2',
470
+ pm2: app.name,
471
+ cwd: app.cwd,
472
+ port: listeningPort(app.pid),
473
+ uptime: proc?.etime ?? '-',
474
+ started: proc?.lstart ?? '-',
475
+ cpu: app.cpu,
476
+ memory: app.memory,
477
+ });
478
+ }
479
+
480
+ for (const proc of procs.values()) {
481
+ if (path.basename(proc.command.split(/\s+/)[0]) !== 'node') continue;
482
+ const cwd = cwdOf(proc.pid);
483
+ if (!cwd || claimed_cwds.has(cwd)) continue;
484
+ const info = factoryPackageInfo(cwd);
485
+ if (!info || !proc.command.includes(info.main)) continue;
486
+ // `node --watch <main>` is a supervisor; its child does the real work.
487
+ if (proc.command.includes('--watch')) continue;
488
+ rows.push({
489
+ pid: proc.pid,
490
+ factory: path.basename(cwd),
491
+ mode: 'direct',
492
+ pm2: '-',
493
+ cwd,
494
+ port: listeningPort(proc.pid),
495
+ uptime: proc.etime,
496
+ started: proc.lstart,
497
+ cpu: null,
498
+ memory: null,
499
+ });
500
+ }
501
+
502
+ rows.sort((a, b) => a.factory.localeCompare(b.factory) || Number(a.pid) - Number(b.pid));
503
+ return { procs: rows, pm2_found: apps !== null };
504
+ }
505
+
506
+ // ---------------------------------------------------------------------------
507
+ // Entry points
508
+ // ---------------------------------------------------------------------------
509
+
510
+ /**
511
+ * @typedef {{type: 'defs', path: string}
512
+ * | {type: 'state', path: string}
513
+ * | {type: 'logs', path: string, dirs: string[]}
514
+ * | {type: 'errors', path: string, dirs: string[]}
515
+ * | {type: 'ages', path: string, dirs: string[]}
516
+ * | {type: 'workpiece', path: string, dir: string}
517
+ * | {type: 'read', path: string, dir: string, file: string}
518
+ * | {type: 'remove', path: string, dirs: string[], keep: string[]}
519
+ * | {type: 'requeue', path: string, dirs: string[]}
520
+ * | {type: 'counts', paths: Object<string, string>}
521
+ * | {type: 'procs'}
522
+ * | {type: 'factories', paths: Object<string, string>}} ProbeQuery
523
+ */
524
+
525
+ /**
526
+ * @param {ProbeQuery} query
527
+ * @returns {object} JSON-serialisable result
528
+ */
529
+ export function runQuery(query) {
530
+ switch (query.type) {
531
+ case 'defs': {
532
+ const root = requireFactoryRoot(query.path);
533
+ return { root, ...readDefs(root) };
534
+ }
535
+ case 'state': {
536
+ const root = requireFactoryRoot(query.path);
537
+ return { root, ...readState(root) };
538
+ }
539
+ case 'logs': {
540
+ const root = requireFactoryRoot(query.path);
541
+ return { root, logs: readLogs(root, query.dirs) };
542
+ }
543
+ case 'errors': {
544
+ const root = requireFactoryRoot(query.path);
545
+ return { root, errors: readErrors(root, query.dirs) };
546
+ }
547
+ case 'ages': {
548
+ const root = requireFactoryRoot(query.path);
549
+ return { root, ages: readAges(root, query.dirs) };
550
+ }
551
+ case 'workpiece': {
552
+ const root = requireFactoryRoot(query.path);
553
+ return { root, ...readWorkpiece(root, query.dir) };
554
+ }
555
+ case 'read':
556
+ return readWorkpieceFile(requireFactoryRoot(query.path), query.dir, query.file);
557
+ case 'remove':
558
+ return removeWorkpieces(requireFactoryRoot(query.path), query.dirs, query.keep ?? []);
559
+ case 'requeue':
560
+ return requeueWorkpieces(requireFactoryRoot(query.path), query.dirs);
561
+ case 'counts':
562
+ return readCounts(query.paths);
563
+ case 'procs':
564
+ return listRunningFactories();
565
+ case 'factories': {
566
+ const factories = Object.entries(query.paths).map(([name, p]) => {
567
+ const root = resolveRoot(p);
568
+ return { name, root, exists: fs.existsSync(root), main: factoryPackageInfo(root)?.main ?? null };
569
+ });
570
+ return { factories, ...listRunningFactories() };
571
+ }
572
+ default:
573
+ throw new ProbeError('BAD_QUERY', `Unknown probe query: ${query.type}`);
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Remote entry: the transport appends `main(<query as a JSON literal>)` when
579
+ * piping this file to `node -`. Result is one JSON envelope on stdout, so the
580
+ * client never has to parse a stack trace.
581
+ * @param {ProbeQuery} query
582
+ */
583
+ export function main(query) {
584
+ let out;
585
+ try {
586
+ out = { ok: true, data: runQuery(query) };
587
+ } catch (err) {
588
+ out = { ok: false, error: { code: err.code || 'PROBE_ERROR', message: err.message } };
589
+ }
590
+ process.stdout.write(JSON.stringify(out));
591
+ }