@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.
@@ -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
+ }