@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/CHANGELOG.md +61 -0
- package/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +102 -2
- package/bin/al-control.js +6 -0
- package/package.json +56 -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 +288 -0
- package/src/cli/lines/list.js +72 -0
- package/src/cli/lines/show.js +76 -0
- package/src/cli/lines/status.js +124 -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/requeue.js +98 -0
- package/src/cli/workpieces/show.js +126 -0
- package/src/cli/workpieces/watch.js +159 -0
- package/src/config.js +259 -0
- package/src/probe.js +806 -0
- package/src/transport.js +184 -0
- package/src/utils/conveyors.js +50 -0
- package/src/utils/factory-data.js +123 -0
- package/src/utils/failures.js +26 -0
- package/src/utils/format.js +160 -0
- package/src/utils/line-state.js +225 -0
- package/src/utils/watch-render.js +40 -0
package/src/transport.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport — runs probe queries and pm2 commands on a host.
|
|
3
|
+
*
|
|
4
|
+
* - `local`: probe runs in-process; pm2 via execFileSync.
|
|
5
|
+
* - remote: the system `ssh` binary (so ~/.ssh/config aliases, ProxyJump,
|
|
6
|
+
* keys and the agent all apply). Probe queries pipe probe.js on stdin to
|
|
7
|
+
* `node -`, so the host needs only node and pm2 — no al-control install,
|
|
8
|
+
* no config file, and client and probe are always the same version.
|
|
9
|
+
*
|
|
10
|
+
* The remote command string is interpreted by the host's shell, so every
|
|
11
|
+
* interpolated value goes through shellQuote().
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
15
|
+
import { mkdirSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { runQuery } from './probe.js';
|
|
20
|
+
|
|
21
|
+
const PROBE_SOURCE = readFileSync(new URL('./probe.js', import.meta.url), 'utf8');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* POSIX single-quote a string for the remote shell (works in sh, bash, zsh).
|
|
25
|
+
* @param {string} s
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function shellQuote(s) {
|
|
29
|
+
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* ssh argv up to and including the destination.
|
|
34
|
+
*
|
|
35
|
+
* BatchMode: fail instead of hanging on a password prompt. ControlMaster +
|
|
36
|
+
* ControlPersist: reuse one connection across calls so watch loops don't pay
|
|
37
|
+
* a handshake per tick. `--` stops option parsing before the alias.
|
|
38
|
+
*
|
|
39
|
+
* @param {import('./config.js').HostContext} host
|
|
40
|
+
* @param {{tty?: boolean}} [opts]
|
|
41
|
+
* @returns {string[]}
|
|
42
|
+
*/
|
|
43
|
+
export function sshArgs(host, { tty = false } = {}) {
|
|
44
|
+
// Sockets live under the home dir even when AL_CONTROL_CONFIG_DIR points
|
|
45
|
+
// elsewhere: unix socket paths cap at 104 bytes, and %C expands to 40 hex
|
|
46
|
+
// chars. If even that is too long, go without multiplexing.
|
|
47
|
+
const socket_dir = path.join(homedir(), '.assemblyline', 'al-control');
|
|
48
|
+
const control_path = path.join(socket_dir, 'ssh-%C');
|
|
49
|
+
const multiplex = control_path.length - 2 + 40 < 104;
|
|
50
|
+
if (multiplex) mkdirSync(socket_dir, { recursive: true });
|
|
51
|
+
return [
|
|
52
|
+
'-o', 'BatchMode=yes',
|
|
53
|
+
...(multiplex ? ['-o', 'ControlMaster=auto', '-o', `ControlPath=${control_path}`, '-o', 'ControlPersist=10m'] : []),
|
|
54
|
+
...(tty ? ['-t'] : []),
|
|
55
|
+
'--', host.ssh,
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Non-interactive SSH shells skip .bashrc/.zshrc, so nvm-installed node/pm2
|
|
61
|
+
* are often off PATH. A configured `node:` path fixes both: its directory is
|
|
62
|
+
* prepended to PATH, and pm2 (a node script) lives alongside it.
|
|
63
|
+
* @param {import('./config.js').HostContext} host
|
|
64
|
+
*/
|
|
65
|
+
function remoteEnvPrefix(host) {
|
|
66
|
+
return host.node ? `PATH=${shellQuote(path.posix.dirname(host.node))}:"$PATH" ` : '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function remoteNode(host) {
|
|
70
|
+
return host.node ? shellQuote(host.node) : 'node';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Run a probe query on the host.
|
|
75
|
+
* @param {import('./config.js').HostContext} host
|
|
76
|
+
* @param {import('./probe.js').ProbeQuery} query
|
|
77
|
+
* @returns {any} The query's result
|
|
78
|
+
*/
|
|
79
|
+
export function probe(host, query) {
|
|
80
|
+
if (host.is_local) return runQuery(query);
|
|
81
|
+
|
|
82
|
+
// The query rides in the piped script, not in argv: a long dir list would
|
|
83
|
+
// exceed Linux's 128 KB per-argument limit.
|
|
84
|
+
const remote_cmd = `${remoteEnvPrefix(host)}${remoteNode(host)} --input-type=module -`;
|
|
85
|
+
// stderr goes straight to the terminal: ssh (or Tailscale SSH's check mode)
|
|
86
|
+
// may print an auth prompt / login URL there and wait for the user. Captured,
|
|
87
|
+
// it would look like a hang. It also means ssh's own errors show verbatim.
|
|
88
|
+
const res = spawnSync('ssh', [...sshArgs(host), remote_cmd], {
|
|
89
|
+
input: `${PROBE_SOURCE}\nmain(${JSON.stringify(query)});\n`,
|
|
90
|
+
encoding: 'utf8',
|
|
91
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
92
|
+
stdio: ['pipe', 'pipe', 'inherit'],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// EPIPE = ssh exited before reading the probe from stdin; its message is above.
|
|
96
|
+
if (res.error && res.error.code !== 'EPIPE') throw new Error(`Could not run ssh: ${res.error.message}`);
|
|
97
|
+
if (res.status === 255) throw new Error(`ssh to ${host.name} (${host.ssh}) failed — see ssh's message above.`);
|
|
98
|
+
if (res.status === 127) {
|
|
99
|
+
throw new Error(`node not found on ${host.name}. Set 'node:' for this host in the config to its absolute path (e.g. the output of \`which node\` in an interactive shell there).`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let envelope;
|
|
103
|
+
try {
|
|
104
|
+
envelope = JSON.parse(res.stdout);
|
|
105
|
+
} catch {
|
|
106
|
+
throw new Error(`Unexpected output from ${host.name} (exit ${res.status}):\n${res.stdout.slice(0, 2000)}`);
|
|
107
|
+
}
|
|
108
|
+
if (!envelope.ok) {
|
|
109
|
+
const err = new Error(`${host.name}: ${envelope.error.message}`);
|
|
110
|
+
err.code = envelope.error.code;
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
return envelope.data;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* How to make pm2 reachable on a host.
|
|
118
|
+
* @param {import('./config.js').HostContext} host
|
|
119
|
+
*/
|
|
120
|
+
export function pm2Hint(host) {
|
|
121
|
+
return host.is_local
|
|
122
|
+
? 'Install it with `npm i -g pm2`.'
|
|
123
|
+
: `Set 'node:' for host ${host.name} in the config to the node that pm2 was installed with (\`which node\` in an interactive shell there), so its bin dir is on PATH.`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Probe a host's processes (`procs` or `factories` query). When pm2 couldn't be
|
|
128
|
+
* queried, pm2-managed factories look like `direct` ones — warn instead of
|
|
129
|
+
* letting that pass silently.
|
|
130
|
+
* @param {import('./config.js').HostContext} host
|
|
131
|
+
* @param {import('./probe.js').ProbeQuery} query
|
|
132
|
+
*/
|
|
133
|
+
export function probeProcesses(host, query) {
|
|
134
|
+
const result = probe(host, query);
|
|
135
|
+
if (!result.pm2_found) console.error(`Warning: pm2 not found on ${host.name}; pm2-managed factories will show as "direct". ${pm2Hint(host)}`);
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Run pm2 on the host with inherited stdio.
|
|
141
|
+
*
|
|
142
|
+
* @param {import('./config.js').HostContext} host
|
|
143
|
+
* @param {string[]} args - pm2 arguments
|
|
144
|
+
* @param {object} [opts]
|
|
145
|
+
* @param {string} [opts.cwd] - absolute dir on the host to run from
|
|
146
|
+
* @param {boolean} [opts.interactive] - long-running / TUI (logs, monit):
|
|
147
|
+
* Ctrl-C is a normal exit, and remote runs get a tty so it reaches pm2
|
|
148
|
+
*/
|
|
149
|
+
export function pm2(host, args, { cwd, interactive = false } = {}) {
|
|
150
|
+
if (host.is_local) {
|
|
151
|
+
try {
|
|
152
|
+
execFileSync('pm2', args, { cwd, stdio: 'inherit' });
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (err.code === 'ENOENT') throw new Error(`pm2 not found. ${pm2Hint(host)}`);
|
|
155
|
+
if (!interactive) throw new Error(`pm2 ${args[0]} failed (exit ${err.status})`);
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const cd = cwd ? `cd ${shellQuote(cwd)} && ` : '';
|
|
161
|
+
const remote_cmd = `${cd}${remoteEnvPrefix(host)}pm2 ${args.map(shellQuote).join(' ')}`;
|
|
162
|
+
const tty = interactive && Boolean(process.stdin.isTTY);
|
|
163
|
+
const res = spawnSync('ssh', [...sshArgs(host, { tty }), remote_cmd], { stdio: 'inherit' });
|
|
164
|
+
|
|
165
|
+
if (res.error) throw new Error(`Could not run ssh: ${res.error.message}`);
|
|
166
|
+
if (res.status === 255) throw new Error(`ssh to ${host.name} (${host.ssh}) failed`);
|
|
167
|
+
if (res.status === 127) {
|
|
168
|
+
throw new Error(`pm2 not found on ${host.name}. ${pm2Hint(host)}`);
|
|
169
|
+
}
|
|
170
|
+
if (res.status !== 0 && !interactive) throw new Error(`pm2 ${args[0]} on ${host.name} failed (exit ${res.status})`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Interactive login shell on the host, started in `cwd`. Login (`-l`) so the
|
|
175
|
+
* user's PATH and nvm are set up as in a normal ssh session.
|
|
176
|
+
* @param {import('./config.js').HostContext} host
|
|
177
|
+
* @param {string} cwd - absolute dir on the host
|
|
178
|
+
*/
|
|
179
|
+
export function shell(host, cwd) {
|
|
180
|
+
const remote_cmd = `cd ${shellQuote(cwd)} && exec "\${SHELL:-/bin/sh}" -l`;
|
|
181
|
+
const res = spawnSync('ssh', [...sshArgs(host, { tty: true }), remote_cmd], { stdio: 'inherit' });
|
|
182
|
+
if (res.error) throw new Error(`Could not run ssh: ${res.error.message}`);
|
|
183
|
+
if (res.status === 255) throw new Error(`ssh to ${host.name} (${host.ssh}) failed`);
|
|
184
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Conveyors: `lib-factory:move_files` steps, which move workpieces from one station's bin to
|
|
5
|
+
* another's. The step takes either one move (`source_bin`, `target_bin`) or a `moves` array of
|
|
6
|
+
* them, so every reader goes through here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const CONVEYOR_SLUG = 'lib-factory:move_files';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} ConveyorMove
|
|
13
|
+
* @property {string} source_bin - `STATION/BIN`, possibly with a sub-bin (`VM3/output/approved`)
|
|
14
|
+
* @property {string} target_bin
|
|
15
|
+
* @property {string} [mode]
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Every move a station's conveyor steps make.
|
|
20
|
+
*
|
|
21
|
+
* @param {{ steps?: any[] }} station - A station definition
|
|
22
|
+
* @returns {ConveyorMove[]}
|
|
23
|
+
*/
|
|
24
|
+
export function conveyorMoves(station) {
|
|
25
|
+
const moves = [];
|
|
26
|
+
for (const step of station.steps || []) {
|
|
27
|
+
if (step.slug !== CONVEYOR_SLUG || !step.config) continue;
|
|
28
|
+
for (const move of step.config.moves ?? [step.config]) {
|
|
29
|
+
if (move?.source_bin) moves.push(move);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return moves;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `STATION/BIN` → the bin a conveyor moves it to, for every bin some conveyor drains. A sub-bin
|
|
37
|
+
* source counts as its station bin. Clearing must skip these bins: another line still reads them.
|
|
38
|
+
*
|
|
39
|
+
* @param {{ steps?: any[] }[]} stations - Station definitions
|
|
40
|
+
* @returns {Map<string, string>}
|
|
41
|
+
*/
|
|
42
|
+
export function drainedBins(stations) {
|
|
43
|
+
const drained = new Map();
|
|
44
|
+
for (const station of stations) {
|
|
45
|
+
for (const move of conveyorMoves(station)) {
|
|
46
|
+
drained.set(move.source_bin.split('/').slice(0, 2).join('/'), move.target_bin || '?');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return drained;
|
|
50
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Factory data — what command handlers read, wherever the factory lives.
|
|
3
|
+
* Each call is one probe round trip (in-process locally, one ssh exec remotely).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { probe } from '../transport.js';
|
|
9
|
+
import { buildLineState } from './line-state.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Station + line definitions from the factory's `.orchestrator/`.
|
|
13
|
+
* @param {import('../config.js').FactoryContext} factory
|
|
14
|
+
* @returns {{root: string, stations: import('./line-state.js').StationDef[], lines: Object<string, object>}}
|
|
15
|
+
*/
|
|
16
|
+
export function loadDefs(factory) {
|
|
17
|
+
return probe(factory.host, { type: 'defs', path: factory.path });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Every line's station order, terminal, live bin counts and summary — the
|
|
22
|
+
* probe's `lines` query. Counts only, so it is cheap to poll over SSH.
|
|
23
|
+
* @param {import('../config.js').FactoryContext} factory
|
|
24
|
+
* @returns {{root: string, factory: object|null, lines: Object<string, {code: string, name: string|null, location: string|null, stations: string[], terminal: string, counts: Object<string, Object<string, number|null>>, orphans: Object<string, number>, summary: import('../probe.js').LineSummary}>}}
|
|
25
|
+
*/
|
|
26
|
+
export function loadLineSummaries(factory) {
|
|
27
|
+
return probe(factory.host, { type: 'lines', path: factory.path });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Live per-line state, plus the station defs it was built from and the
|
|
32
|
+
* orphaned failed folders (no pointer.json) per station.
|
|
33
|
+
* @param {import('../config.js').FactoryContext} factory
|
|
34
|
+
* @returns {{root: string, lines: Object<string, import('./line-state.js').LineState>, stations: import('./line-state.js').StationDef[], orphans: Object<string, Object<string, string>>}}
|
|
35
|
+
*/
|
|
36
|
+
export function loadLines(factory) {
|
|
37
|
+
const state = probe(factory.host, { type: 'state', path: factory.path });
|
|
38
|
+
return { root: state.root, lines: buildLineState(state), stations: state.stations, orphans: state.orphans ?? {} };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parsed log.jsonl per workpiece dir (relative to temp/stations/).
|
|
43
|
+
* @param {import('../config.js').FactoryContext} factory
|
|
44
|
+
* @param {string[]} rel_dirs
|
|
45
|
+
* @returns {Object<string, Array<{ts: string, station: string, event: string}>>}
|
|
46
|
+
*/
|
|
47
|
+
export function loadLogs(factory, rel_dirs) {
|
|
48
|
+
if (rel_dirs.length === 0) return {};
|
|
49
|
+
return probe(factory.host, { type: 'logs', path: factory.path, dirs: rel_dirs }).logs;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Live per-line state for each factory. Host-wide (several factories), a factory
|
|
54
|
+
* whose directory is missing is reported on stderr and skipped; asked for by
|
|
55
|
+
* name, it's an error.
|
|
56
|
+
* @param {import('../config.js').FactoryContext[]} factories
|
|
57
|
+
* @returns {Array<{factory: import('../config.js').FactoryContext, root: string, lines: Object<string, import('./line-state.js').LineState>, orphans: Object<string, Object<string, string>>}>}
|
|
58
|
+
*/
|
|
59
|
+
export function loadLinesEach(factories) {
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const factory of factories) {
|
|
62
|
+
try {
|
|
63
|
+
out.push({ factory, ...loadLines(factory) });
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (factories.length === 1 || err.code !== 'FACTORY_NOT_FOUND') throw err;
|
|
66
|
+
console.error(`Skipping ${factory.name}: ${err.message}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* error.json summaries (`failed_at`, `name`, `code`, `message`) per workpiece
|
|
74
|
+
* dir; `null` where there's none.
|
|
75
|
+
* @param {import('../config.js').FactoryContext} factory
|
|
76
|
+
* @param {string[]} rel_dirs
|
|
77
|
+
*/
|
|
78
|
+
export function loadErrors(factory, rel_dirs) {
|
|
79
|
+
if (rel_dirs.length === 0) return {};
|
|
80
|
+
return probe(factory.host, { type: 'errors', path: factory.path, dirs: rel_dirs }).errors;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Last activity (ms since epoch) per workpiece dir; `null` if it's gone.
|
|
85
|
+
* @param {import('../config.js').FactoryContext} factory
|
|
86
|
+
* @param {string[]} rel_dirs
|
|
87
|
+
* @returns {Object<string, number|null>}
|
|
88
|
+
*/
|
|
89
|
+
export function loadAges(factory, rel_dirs) {
|
|
90
|
+
if (rel_dirs.length === 0) return {};
|
|
91
|
+
return probe(factory.host, { type: 'ages', path: factory.path, dirs: rel_dirs }).ages;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A workpiece folder's files (recursive, with sizes) and its full error.json.
|
|
96
|
+
* @param {import('../config.js').FactoryContext} factory
|
|
97
|
+
* @param {string} rel_dir
|
|
98
|
+
* @returns {{files: Array<{name: string, size: number}>, error: object|null}}
|
|
99
|
+
*/
|
|
100
|
+
export function loadWorkpieceFiles(factory, rel_dir) {
|
|
101
|
+
return probe(factory.host, { type: 'workpiece', path: factory.path, dir: rel_dir });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Absolute path of a workpiece folder on its host.
|
|
106
|
+
* @param {string} root - absolute factory root on the host (from the probe)
|
|
107
|
+
* @param {string} rel_dir - workpiece dir relative to temp/stations/
|
|
108
|
+
*/
|
|
109
|
+
export function workpiecePath(root, rel_dir) {
|
|
110
|
+
return path.posix.join(root, 'temp', 'stations', rel_dir);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Where a workpiece folder lives: a Cmd-clickable file:// URL locally,
|
|
115
|
+
* `host:/abs/path` (scp-style) on a remote host.
|
|
116
|
+
* @param {import('../config.js').FactoryContext} factory
|
|
117
|
+
* @param {string} root - absolute factory root on the host (from the probe)
|
|
118
|
+
* @param {string} rel_dir - workpiece dir relative to temp/stations/
|
|
119
|
+
*/
|
|
120
|
+
export function workpieceLocation(factory, root, rel_dir) {
|
|
121
|
+
const abs = workpiecePath(root, rel_dir);
|
|
122
|
+
return factory.host.is_local ? `file://${encodeURI(abs)}` : `${factory.host.ssh}:${abs}`;
|
|
123
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grouping key for a failed workpiece's error, so failures with the same
|
|
3
|
+
* cause count together even when their messages differ by an id or path.
|
|
4
|
+
*
|
|
5
|
+
* - A leading snake_case code (`gst_state_mismatch: …`) is the key.
|
|
6
|
+
* - Otherwise the message's first line with paths, id-like tokens and long
|
|
7
|
+
* numbers masked, capped at KEY_MAX.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const KEY_MAX = 100;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {{name?: string|null, message?: string|null}|null} err - error.json summary
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
export function errorKey(err) {
|
|
17
|
+
if (!err) return '(no error.json)';
|
|
18
|
+
const first = String(err.message || err.name || '').split('\n')[0].trim();
|
|
19
|
+
const code = first.match(/^([a-z][a-z0-9]*(?:_[a-z0-9]+)+)\b/);
|
|
20
|
+
if (code) return code[1];
|
|
21
|
+
const masked = first
|
|
22
|
+
.replace(/(?<![\w:/])(?:\/[^\s'"`/<>]+)+\/?/g, '<path>')
|
|
23
|
+
.replace(/\b(?=[\w-]*\d)(?=[\w-]*[A-Za-z])[\w-]{8,}\b/g, '<id>')
|
|
24
|
+
.replace(/\b\d{4,}\b/g, '<n>');
|
|
25
|
+
return masked.length > KEY_MAX ? `${masked.slice(0, KEY_MAX - 1)}…` : masked;
|
|
26
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared formatting helpers for CLI output.
|
|
3
|
+
* Structured text readable by both humans and LLMs.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Format a header line with right-aligned status.
|
|
8
|
+
* "Work Record: abc123 Completed"
|
|
9
|
+
*/
|
|
10
|
+
export function formatHeader(label, id, status) {
|
|
11
|
+
const left = `${label}: ${id}`;
|
|
12
|
+
if (!status) return left;
|
|
13
|
+
const minGap = 4;
|
|
14
|
+
const width = Math.max(left.length + minGap + status.length, 60);
|
|
15
|
+
return left + ' '.repeat(width - left.length - status.length) + status;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Format a label: value field with aligned label.
|
|
20
|
+
* "Name: Monthly Debt & Equity Monitoring"
|
|
21
|
+
*/
|
|
22
|
+
export function formatField(label, value, labelWidth = 0) {
|
|
23
|
+
const padded = (label + ':').padEnd(labelWidth || label.length + 1);
|
|
24
|
+
return `${padded} ${value ?? '—'}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Format a table with dynamic column widths.
|
|
29
|
+
* @param {string[]} headers - Column header names
|
|
30
|
+
* @param {string[][]} rows - Array of row arrays (strings)
|
|
31
|
+
* @returns {string} Formatted table string
|
|
32
|
+
*/
|
|
33
|
+
export function formatTable(headers, rows) {
|
|
34
|
+
if (rows.length === 0) {
|
|
35
|
+
return headers.join(' ') + '\n(none)';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Calculate column widths
|
|
39
|
+
const widths = headers.map((h, i) =>
|
|
40
|
+
Math.max(h.length, ...rows.map(r => (r[i] || '').length))
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const headerLine = headers.map((h, i) => h.padEnd(widths[i])).join(' ');
|
|
44
|
+
const separator = '-'.repeat(headerLine.length);
|
|
45
|
+
const dataLines = rows.map(row =>
|
|
46
|
+
row.map((cell, i) => (cell || '—').padEnd(widths[i])).join(' ')
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return [headerLine, separator, ...dataLines].join('\n');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Format a section divider.
|
|
54
|
+
* "--- Title ---"
|
|
55
|
+
*/
|
|
56
|
+
export function formatSection(title) {
|
|
57
|
+
return `\n--- ${title} ---\n`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The zone timestamps render in. Resolved once, so a long listing cannot straddle a DST change mid-table. */
|
|
61
|
+
const LOCAL_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Short zone label, e.g. "PDT" or "Asia/Calcutta". Computed once — it labels a
|
|
65
|
+
* header, not a value.
|
|
66
|
+
*
|
|
67
|
+
* `timeZoneName: 'short'` only yields a real abbreviation for zones ICU has one
|
|
68
|
+
* for; elsewhere it returns "GMT+5:30", which is correct but noisy in a column
|
|
69
|
+
* header and tells you less than the zone's own name. So: take the abbreviation
|
|
70
|
+
* when it is one, otherwise use the IANA name.
|
|
71
|
+
*/
|
|
72
|
+
const LOCAL_TZ_LABEL = (() => {
|
|
73
|
+
const parts = new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' }).formatToParts(new Date());
|
|
74
|
+
const short = parts.find((p) => p.type === 'timeZoneName')?.value;
|
|
75
|
+
if (short && !/^(GMT|UTC)[+-]/.test(short)) return short;
|
|
76
|
+
return LOCAL_TZ || short || 'local';
|
|
77
|
+
})();
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Short name of the local zone, e.g. "PDT" or "Asia/Calcutta".
|
|
81
|
+
*
|
|
82
|
+
* Use it to name the zone ONCE per table or detail block — in the column header
|
|
83
|
+
* (`STARTED (${localTzLabel()})`) or a section heading — never per row.
|
|
84
|
+
*/
|
|
85
|
+
export function localTzLabel() {
|
|
86
|
+
return LOCAL_TZ_LABEL;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Format an ISO timestamp in the operator's local timezone: "2026-03-15 15:53:01".
|
|
91
|
+
*
|
|
92
|
+
* **Local, not UTC.** This used to print `toISOString()` verbatim, so an
|
|
93
|
+
* operator in IST read every timestamp 5.5 hours early with nothing on screen
|
|
94
|
+
* saying so — and everything a factory CLI is compared against (`pm2 logs`, file
|
|
95
|
+
* mtimes, `al-orc` output) is local. Kept identical to al-orc's helper so the
|
|
96
|
+
* two CLIs never disagree about the same instant.
|
|
97
|
+
*
|
|
98
|
+
* Pair with `localTzLabel()` in the surrounding header so the zone is stated
|
|
99
|
+
* once. sv-SE is used because its locale format is already `YYYY-MM-DD HH:MM:SS`.
|
|
100
|
+
*/
|
|
101
|
+
export function formatDate(iso) {
|
|
102
|
+
if (!iso) return '—';
|
|
103
|
+
const d = new Date(iso);
|
|
104
|
+
if (Number.isNaN(d.getTime())) return '—';
|
|
105
|
+
return d.toLocaleString('sv-SE', { timeZone: LOCAL_TZ }).replace(',', '');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Wall-clock time only, for append-style feeds where the date is implied by the
|
|
110
|
+
* session: "15:53:01".
|
|
111
|
+
*
|
|
112
|
+
* Accepts an ISO string or a `Date` (`al-control workpieces watch` stamps
|
|
113
|
+
* `new Date()` as each event arrives).
|
|
114
|
+
*
|
|
115
|
+
* @param {string|Date|null|undefined} iso
|
|
116
|
+
*/
|
|
117
|
+
export function formatTime(iso) {
|
|
118
|
+
if (!iso) return '—';
|
|
119
|
+
const d = new Date(iso);
|
|
120
|
+
if (Number.isNaN(d.getTime())) return '—';
|
|
121
|
+
return d.toLocaleTimeString('sv-SE', { timeZone: LOCAL_TZ });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Format duration between two ISO timestamps.
|
|
126
|
+
* "1m" / "2h 15m" / "3d 4h"
|
|
127
|
+
*/
|
|
128
|
+
export function formatDuration(start, end) {
|
|
129
|
+
if (!start || !end) return '—';
|
|
130
|
+
const ms = new Date(end) - new Date(start);
|
|
131
|
+
if (ms < 0) return '—';
|
|
132
|
+
|
|
133
|
+
const seconds = Math.floor(ms / 1000);
|
|
134
|
+
if (seconds < 60) return `${seconds}s`;
|
|
135
|
+
|
|
136
|
+
const minutes = Math.floor(seconds / 60);
|
|
137
|
+
if (minutes < 60) return `${minutes}m`;
|
|
138
|
+
|
|
139
|
+
const hours = Math.floor(minutes / 60);
|
|
140
|
+
const remainingMinutes = minutes % 60;
|
|
141
|
+
if (hours < 24) {
|
|
142
|
+
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const days = Math.floor(hours / 24);
|
|
146
|
+
const remainingHours = hours % 24;
|
|
147
|
+
return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Format an elapsed duration in ms: "850ms" / "4.2s" / "3m 12s" / "1h 5m".
|
|
152
|
+
* @param {number} ms
|
|
153
|
+
*/
|
|
154
|
+
export function formatElapsed(ms) {
|
|
155
|
+
const secs = ms / 1000;
|
|
156
|
+
if (secs < 1) return `${ms}ms`;
|
|
157
|
+
if (secs < 60) return `${secs.toFixed(secs < 10 ? 1 : 0)}s`;
|
|
158
|
+
if (secs < 3600) return `${Math.floor(secs / 60)}m ${Math.round(secs % 60)}s`;
|
|
159
|
+
return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`;
|
|
160
|
+
}
|