@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/CHANGELOG.md +37 -0
- package/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +100 -2
- package/bin/al-control.js +6 -0
- package/package.json +52 -4
- package/src/cli/_helpers.js +14 -0
- package/src/cli/factories/list.js +56 -0
- package/src/cli/hosts.js +59 -0
- package/src/cli/index.js +273 -0
- package/src/cli/lines/list.js +72 -0
- package/src/cli/lines/show.js +76 -0
- package/src/cli/lines/status.js +121 -0
- package/src/cli/procs/actions.js +95 -0
- package/src/cli/procs/list.js +25 -0
- package/src/cli/stations/status.js +79 -0
- package/src/cli/workpieces/_resolve.js +25 -0
- package/src/cli/workpieces/cat.js +20 -0
- package/src/cli/workpieces/clear.js +121 -0
- package/src/cli/workpieces/count.js +73 -0
- package/src/cli/workpieces/errors.js +79 -0
- package/src/cli/workpieces/list.js +140 -0
- package/src/cli/workpieces/open.js +25 -0
- package/src/cli/workpieces/show.js +126 -0
- package/src/cli/workpieces/watch.js +159 -0
- package/src/config.js +213 -0
- package/src/probe.js +591 -0
- package/src/transport.js +184 -0
- package/src/utils/conveyors.js +50 -0
- package/src/utils/factory-data.js +113 -0
- package/src/utils/failures.js +26 -0
- package/src/utils/format.js +160 -0
- package/src/utils/line-state.js +324 -0
- package/src/utils/watch-render.js +40 -0
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
|
+
}
|