@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,25 @@
1
+ import { probeProcesses } from '../../transport.js';
2
+ import { formatTable } from '../../utils/format.js';
3
+
4
+ /**
5
+ * `al-control procs list` — every factory process on the host, configured
6
+ * or not, pm2-managed or run directly.
7
+ * @param {{json?: boolean}} argv
8
+ * @param {import('../../config.js').HostContext} host
9
+ */
10
+ export async function listProcsHandler(argv, host) {
11
+ const { procs } = probeProcesses(host, { type: 'procs' });
12
+
13
+ if (argv.json) {
14
+ console.log(JSON.stringify(procs, null, 2));
15
+ return;
16
+ }
17
+ if (procs.length === 0) {
18
+ console.log(`No running factories on ${host.name}.`);
19
+ return;
20
+ }
21
+ console.log(formatTable(
22
+ ['PID', 'FACTORY', 'MODE', 'PM2', 'PORT', 'UPTIME', 'STARTED'],
23
+ procs.map((r) => [r.pid, r.factory, r.mode, r.pm2, r.port, r.uptime, r.started]),
24
+ ));
25
+ }
@@ -0,0 +1,79 @@
1
+ import { formatHeader } from '../../utils/format.js';
2
+ import { LIVE_BINS } from '../../utils/line-state.js';
3
+ import { loadLines } from '../../utils/factory-data.js';
4
+ import { watchRender, DEFAULT_WATCH_INTERVAL_SECS } from '../../utils/watch-render.js';
5
+
6
+ /**
7
+ * `al-control stations status <code> -f <factory>` — one station's bins and
8
+ * the workpieces in each.
9
+ *
10
+ * `--watch [--interval=N]` re-renders in place; exclusive with `--json`.
11
+ *
12
+ * @param {{id: string, json?: boolean, watch?: boolean, interval?: number}} argv
13
+ * @param {import('../../config.js').FactoryContext} factory
14
+ */
15
+ export async function statusStationHandler(argv, factory) {
16
+ const { id, json, watch, interval } = argv;
17
+ if (watch && json) {
18
+ console.error('--watch and --json are mutually exclusive (watch is a TTY redraw; json is a one-shot snapshot).');
19
+ process.exit(1);
20
+ }
21
+ const interval_secs = typeof interval === 'number' ? interval : DEFAULT_WATCH_INTERVAL_SECS;
22
+
23
+ const renderOnce = () => {
24
+ const { lines } = loadLines(factory);
25
+ const station_line = Object.values(lines).find((ls) => ls.stations.includes(id));
26
+ if (!station_line) {
27
+ // In watch mode the station may appear later, so surface and retry.
28
+ if (watch) {
29
+ console.log(`No station "${id}" on ${factory.name}.`);
30
+ return;
31
+ }
32
+ console.error(`No station "${id}" on ${factory.name}.`);
33
+ console.error(`Pass the station short_code (e.g. VM3). Run \`al-control lines status ${factory.flags}\` to see active lines.`);
34
+ process.exit(1);
35
+ }
36
+
37
+ const bins = station_line.bins[id];
38
+ const is_terminal = id === station_line.terminal;
39
+
40
+ if (json) {
41
+ const out = { station: id, line: station_line.code, terminal: is_terminal, bins: {} };
42
+ for (const bin of [...LIVE_BINS, 'done']) {
43
+ const map = bins[bin];
44
+ out.bins[bin] = map ? [...map.keys()].sort() : null;
45
+ }
46
+ console.log(JSON.stringify(out, null, 2));
47
+ return;
48
+ }
49
+
50
+ console.log(formatHeader('Station', id));
51
+ console.log(`Line: ${station_line.code}${is_terminal ? ' (terminal)' : ''}`);
52
+ console.log('');
53
+
54
+ for (const bin of [...LIVE_BINS, 'done']) {
55
+ const map = bins[bin];
56
+ const label = bin === 'done' ? '(done)' : bin;
57
+ if (map === null) {
58
+ console.log(`${label.padEnd(8)} —`);
59
+ continue;
60
+ }
61
+ const sorted = [...map.keys()].sort();
62
+ console.log(`${label.padEnd(8)} (${sorted.length})`);
63
+ // Show the sub-bin when a workpiece lives under one (invoices/hi-1__...).
64
+ for (const wp_id of sorted) {
65
+ const sub = map.get(wp_id);
66
+ const display = sub && sub !== wp_id ? `${wp_id} (in ${sub.slice(0, sub.lastIndexOf('/'))}/)` : wp_id;
67
+ console.log(` ${display}`);
68
+ }
69
+ if (sorted.length === 0) console.log(' —');
70
+ console.log('');
71
+ }
72
+ };
73
+
74
+ if (watch) {
75
+ await watchRender({ render: renderOnce, interval_secs });
76
+ return;
77
+ }
78
+ renderOnce();
79
+ }
@@ -0,0 +1,25 @@
1
+ import { resolveWorkpieceQuery, workpieceRelDir } from '../../utils/line-state.js';
2
+ import { loadLines } from '../../utils/factory-data.js';
3
+
4
+ /**
5
+ * The one workpiece an id (or unambiguous substring) names, for commands that
6
+ * act on a single folder. Exits with the candidates when it's ambiguous.
7
+ * @param {import('../../config.js').FactoryContext} factory
8
+ * @param {string} query
9
+ * @returns {{id: string, root: string, rel: string}}
10
+ */
11
+ export function resolveOneWorkpiece(factory, query) {
12
+ const { root, lines } = loadLines(factory);
13
+ const found = resolveWorkpieceQuery(query, lines);
14
+ if (!found) {
15
+ console.error(`No workpiece matches "${query}".`);
16
+ process.exit(1);
17
+ }
18
+ if ('ambiguous' in found) {
19
+ const shown = found.ambiguous.slice(0, 10);
20
+ const more = found.ambiguous.length - shown.length;
21
+ console.error(`"${query}" matches ${found.ambiguous.length} workpieces; use the full id:\n ${shown.join('\n ')}${more ? `\n … +${more} more` : ''}`);
22
+ process.exit(1);
23
+ }
24
+ return { id: found.id, root, rel: workpieceRelDir(found.pos, found.id) };
25
+ }
@@ -0,0 +1,20 @@
1
+ import { probe } from '../../transport.js';
2
+ import { resolveOneWorkpiece } from './_resolve.js';
3
+
4
+ /**
5
+ * `al-control workpieces cat <id> <file> -f <factory>` — one file from the
6
+ * workpiece folder to stdout, byte-for-byte (so `> invoice.pdf` works).
7
+ * Binary output to a terminal is refused.
8
+ *
9
+ * @param {{id: string, file: string}} argv
10
+ * @param {import('../../config.js').FactoryContext} factory
11
+ */
12
+ export async function catHandler(argv, factory) {
13
+ const { rel } = resolveOneWorkpiece(factory, argv.id);
14
+ const { encoding, content } = probe(factory.host, { type: 'read', path: factory.path, dir: rel, file: argv.file });
15
+ if (encoding === 'base64' && process.stdout.isTTY) {
16
+ console.error(`${argv.file} is binary. Redirect it to a file: … > ${argv.file.split('/').pop()}`);
17
+ process.exit(1);
18
+ }
19
+ process.stdout.write(Buffer.from(content, encoding));
20
+ }
@@ -0,0 +1,121 @@
1
+ import { probe } from '../../transport.js';
2
+ import { formatTable } from '../../utils/format.js';
3
+ import { loadAges, loadLines } from '../../utils/factory-data.js';
4
+ import { drainedBins } from '../../utils/conveyors.js';
5
+
6
+ const DAY_MS = 24 * 60 * 60 * 1000;
7
+
8
+ /**
9
+ * `al-control workpieces clear [id] --bin done|finished -f <factory> [--line]
10
+ * [--station] [--older-than <days>] [--yes]` — delete workpieces from `done`
11
+ * bins, or from the output of each line's terminal station (`finished`).
12
+ * With `id` instead of `--bin`: every done or finished copy of that one
13
+ * workpiece. `--older-than` keeps anything active (folder or log.jsonl
14
+ * modified) within that many days. Prints what it would delete and stops
15
+ * unless `--yes`. Skipped: bins in the factory's config `keep` list, and bins
16
+ * a conveyor (`move_files`) still drains — a terminal output can feed another
17
+ * line. The probe re-checks every folder before deleting (see
18
+ * docs/decisions/0005).
19
+ *
20
+ * @param {{id?: string, bin?: 'done'|'finished', line?: string, station?: string, olderThan?: number, yes?: boolean}} argv
21
+ * @param {import('../../config.js').FactoryContext} factory
22
+ */
23
+ export async function clearHandler(argv, factory) {
24
+ const { lines, stations } = loadLines(factory);
25
+
26
+ if (argv.line && !lines[argv.line]) fail(`Line "${argv.line}" not found. Available: ${Object.keys(lines).sort().join(', ') || '(none)'}`);
27
+ if (argv.station) {
28
+ const line = Object.values(lines).find((l) => l.stations.includes(argv.station));
29
+ if (!line) fail(`Station "${argv.station}" not found.`);
30
+ if (argv.bin === 'finished' && line.terminal !== argv.station) {
31
+ fail(`${argv.station} isn't the terminal station of line ${line.code} (${line.terminal} is); only terminal output counts as finished.`);
32
+ }
33
+ }
34
+
35
+ // STATION/BIN → where a conveyor moves it; source_bin may name a sub-bin.
36
+ const drained_by = drainedBins(stations.map((s) => s.data));
37
+
38
+ // Every clearable bin in scope: done anywhere, output of a terminal station.
39
+ const groups = [];
40
+ for (const line of Object.values(lines)) {
41
+ if (argv.line && line.code !== argv.line) continue;
42
+ for (const station of line.stations) {
43
+ if (argv.station && station !== argv.station) continue;
44
+ const bins = [];
45
+ if (argv.bin !== 'finished') bins.push('done');
46
+ if (argv.bin !== 'done' && station === line.terminal) bins.push('output');
47
+ for (const bin of bins) {
48
+ const map = line.bins[station][bin];
49
+ if (!map || map.size === 0) continue;
50
+ const spec = `${station}/${bin}`;
51
+ let skip = null;
52
+ if (factory.keep.includes(spec)) skip = 'kept (config keep list)';
53
+ else if (drained_by.has(spec)) skip = `skipped: conveyor moves it to ${drained_by.get(spec)}`;
54
+ groups.push({ line: line.code, spec, skip, entries: [...map].map(([id, sub]) => ({ id, rel: `${spec}/${sub}` })) });
55
+ }
56
+ }
57
+ }
58
+
59
+ let what = `${argv.bin} bins`;
60
+ if (argv.id) {
61
+ const ids = new Set(groups.flatMap((g) => g.entries.map((e) => e.id)));
62
+ const matches = ids.has(argv.id) ? [argv.id] : [...ids].filter((id) => id.includes(argv.id)).sort();
63
+ if (matches.length === 0) fail(`No done or finished workpiece matches "${argv.id}" on ${factory.name}. Only done bins and terminal outputs can be cleared.`);
64
+ if (matches.length > 1) {
65
+ const shown = matches.slice(0, 10);
66
+ fail(`"${argv.id}" matches ${matches.length} workpieces; use the full id:\n ${shown.join('\n ')}${matches.length > 10 ? `\n … +${matches.length - 10} more` : ''}`);
67
+ }
68
+ for (const g of groups) g.entries = g.entries.filter((e) => e.id === matches[0]);
69
+ what = `done or finished copies of ${matches[0]}`;
70
+ }
71
+
72
+ if (argv.olderThan !== undefined) {
73
+ const ages = loadAges(factory, groups.filter((g) => !g.skip).flatMap((g) => g.entries.map((e) => e.rel)));
74
+ const cutoff = Date.now() - argv.olderThan * DAY_MS;
75
+ for (const g of groups) {
76
+ if (g.skip) continue;
77
+ const before = g.entries.length;
78
+ g.entries = g.entries.filter((e) => ages[e.rel] !== null && ages[e.rel] < cutoff);
79
+ g.newer = before - g.entries.length;
80
+ }
81
+ what += ` older than ${argv.olderThan} day${argv.olderThan === 1 ? '' : 's'}`;
82
+ }
83
+
84
+ const shown = groups.filter((g) => g.entries.length > 0 || g.newer);
85
+ if (shown.length === 0) {
86
+ console.log(`Nothing to clear: no ${what} on ${factory.name}.`);
87
+ return;
88
+ }
89
+
90
+ const has_age = argv.olderThan !== undefined;
91
+ console.log(formatTable(
92
+ ['LINE', 'BIN', 'WORKPIECES', ...(has_age ? ['NEWER (KEPT)'] : []), 'NOTE'],
93
+ shown.map((g) => [
94
+ g.line,
95
+ g.spec,
96
+ String(g.entries.length),
97
+ ...(has_age ? [g.skip ? '' : String(g.newer ?? 0)] : []),
98
+ g.skip ?? '',
99
+ ]),
100
+ ));
101
+ console.log('');
102
+
103
+ const to_delete = shown.filter((g) => !g.skip).flatMap((g) => g.entries.map((e) => e.rel));
104
+ if (to_delete.length === 0) {
105
+ console.log(`Nothing to delete: every matching bin is skipped${has_age ? ' or too recent' : ''}.`);
106
+ return;
107
+ }
108
+ const where = `${factory.host.name} / ${factory.name}`;
109
+ if (!argv.yes) {
110
+ console.log(`Dry run: would delete ${to_delete.length} workpiece${to_delete.length === 1 ? '' : 's'} on ${where}. Re-run with --yes to delete.`);
111
+ return;
112
+ }
113
+
114
+ const { removed } = probe(factory.host, { type: 'remove', path: factory.path, dirs: to_delete, keep: factory.keep });
115
+ console.log(`Deleted ${removed.length} workpiece${removed.length === 1 ? '' : 's'} on ${where}.`);
116
+ }
117
+
118
+ function fail(msg) {
119
+ console.error(msg);
120
+ process.exit(1);
121
+ }
@@ -0,0 +1,73 @@
1
+ import { formatTable } from '../../utils/format.js';
2
+ import { loadLinesEach } from '../../utils/factory-data.js';
3
+
4
+ const LEVELS = ['factory', 'line', 'station'];
5
+
6
+ const BINS = ['failed', 'done', 'finished'];
7
+
8
+ /**
9
+ * `al-control workpieces count [failed|done|finished] [-f <factory>]
10
+ * [--by factory|line|station]` — FAILED / DONE / FINISHED per station, rolled
11
+ * up to the `--by` level; naming a bin shows only that count. Every factory on
12
+ * the host unless `-f` narrows it. FINISHED is the output bin of a line's
13
+ * terminal station. Rows that are zero in every shown count are hidden.
14
+ * FAILED includes orphaned failed folders (no pointer.json), noted below the
15
+ * table since nothing else can see them.
16
+ *
17
+ * @param {{bin?: string, by?: string, json?: boolean}} argv
18
+ * @param {{host: import('../../config.js').HostContext, factories: import('../../config.js').FactoryContext[]}} scope
19
+ */
20
+ export async function countWorkpiecesHandler(argv, { factories }) {
21
+ const by = argv.by ?? 'station';
22
+ const keys = LEVELS.slice(0, LEVELS.indexOf(by) + 1);
23
+
24
+ const rows = new Map();
25
+ let orphan_total = 0;
26
+ for (const { factory, lines, orphans } of loadLinesEach(factories)) {
27
+ for (const line of Object.values(lines)) {
28
+ for (const station of line.stations) {
29
+ const bins = line.bins[station];
30
+ const at = { factory: factory.name, line: line.code, station };
31
+ const id = keys.map((k) => at[k]).join('\0');
32
+ if (!rows.has(id)) rows.set(id, { ...Object.fromEntries(keys.map((k) => [k, at[k]])), failed: 0, done: 0, finished: 0 });
33
+ const row = rows.get(id);
34
+ const orphan_count = Object.keys(orphans[station] ?? {}).length;
35
+ orphan_total += orphan_count;
36
+ row.failed += (bins.failed?.size ?? 0) + orphan_count;
37
+ row.done += bins.done?.size ?? 0;
38
+ if (station === line.terminal) row.finished += bins.output?.size ?? 0;
39
+ }
40
+ }
41
+ }
42
+
43
+ const bins = argv.bin ? [argv.bin] : BINS;
44
+ const nonzero = [...rows.values()]
45
+ .filter((r) => bins.some((b) => r[b]))
46
+ .map((r) => ({ ...Object.fromEntries(keys.map((k) => [k, r[k]])), ...Object.fromEntries(bins.map((b) => [b, r[b]])) }));
47
+
48
+ if (argv.json) {
49
+ console.log(JSON.stringify({ by, rows: nonzero, orphaned: orphan_total }, null, 2));
50
+ return;
51
+ }
52
+
53
+ if (nonzero.length === 0) {
54
+ console.log(`No ${bins.join(', ')} workpieces.`);
55
+ return;
56
+ }
57
+
58
+ const n = (v) => (v ? String(v) : '—');
59
+ const blank = keys.map(() => '');
60
+ blank[0] = 'TOTAL';
61
+ console.log(formatTable(
62
+ [...keys.map((k) => k.toUpperCase()), ...bins.map((b) => b.toUpperCase())],
63
+ [
64
+ ...nonzero.map((r) => [...keys.map((k) => r[k]), ...bins.map((b) => n(r[b]))]),
65
+ [...blank, ...bins.map((b) => n(nonzero.reduce((t, r) => t + r[b], 0)))],
66
+ ],
67
+ ));
68
+ if (bins.includes('finished')) console.log('\nFINISHED = output of a line\'s terminal station.');
69
+ if (bins.includes('failed') && orphan_total > 0) {
70
+ console.log(`${bins.includes('finished') ? '' : '\n'}FAILED includes ${orphan_total} orphaned folder${orphan_total === 1 ? '' : 's'} (no pointer.json); \`workpieces errors\` lists them.`);
71
+ }
72
+ if (bins.includes('failed')) console.log(`${bins.includes('finished') || orphan_total > 0 ? '' : '\n'}Why things failed: \`al-control workpieces errors\`.`);
73
+ }
@@ -0,0 +1,79 @@
1
+ import { errorKey } from '../../utils/failures.js';
2
+ import { formatTable, formatDate, localTzLabel } from '../../utils/format.js';
3
+ import { loadLinesEach, loadErrors } from '../../utils/factory-data.js';
4
+
5
+ const MAX_EXAMPLES = 2;
6
+ const ERROR_COL = 80;
7
+
8
+ /**
9
+ * `al-control workpieces errors [-f <factory>] [--line] [--station]` — failed
10
+ * workpieces grouped by error (see utils/failures.js), most common first.
11
+ * Every factory on the host unless `-f` narrows it. One probe call per factory.
12
+ * Orphaned failed folders (no pointer.json) are included and flagged.
13
+ *
14
+ * @param {{line?: string, station?: string, json?: boolean}} argv
15
+ * @param {{host: import('../../config.js').HostContext, factories: import('../../config.js').FactoryContext[]}} scope
16
+ */
17
+ export async function errorsHandler(argv, { factories }) {
18
+ const host_wide = factories.length > 1;
19
+ const groups = new Map();
20
+
21
+ let orphan_total = 0;
22
+ for (const { factory, lines, orphans } of loadLinesEach(factories)) {
23
+ const failed = [];
24
+ for (const line of Object.values(lines)) {
25
+ if (argv.line && line.code !== argv.line) continue;
26
+ for (const station of line.stations) {
27
+ if (argv.station && station !== argv.station) continue;
28
+ for (const [id, sub] of line.bins[station].failed ?? []) failed.push({ id, station, rel: `${station}/failed/${sub}`, orphan: false });
29
+ for (const [id, sub] of Object.entries(orphans[station] ?? {})) failed.push({ id, station, rel: `${station}/failed/${sub}`, orphan: true });
30
+ }
31
+ }
32
+ const errors = loadErrors(factory, failed.map((f) => f.rel));
33
+ for (const f of failed) {
34
+ const err = errors[f.rel];
35
+ const key = errorKey(err);
36
+ if (!groups.has(key)) groups.set(key, { key, count: 0, where: {}, workpieces: [], last_failed_at: null });
37
+ const g = groups.get(key);
38
+ const where = host_wide ? `${factory.name}/${f.station}` : f.station;
39
+ g.count += 1;
40
+ g.where[where] = (g.where[where] ?? 0) + 1;
41
+ g.workpieces.push({ factory: factory.name, id: f.id, station: f.station, failed_at: err?.failed_at ?? null, message: err?.message ?? null, orphan: f.orphan });
42
+ if (f.orphan) { g.orphans = (g.orphans ?? 0) + 1; orphan_total += 1; }
43
+ if (err?.failed_at && (!g.last_failed_at || err.failed_at > g.last_failed_at)) g.last_failed_at = err.failed_at;
44
+ }
45
+ }
46
+
47
+ const sorted = [...groups.values()].sort((a, b) => b.count - a.count || a.key.localeCompare(b.key));
48
+
49
+ if (argv.json) {
50
+ console.log(JSON.stringify({ groups: sorted }, null, 2));
51
+ return;
52
+ }
53
+
54
+ if (sorted.length === 0) {
55
+ console.log('No failed workpieces.');
56
+ return;
57
+ }
58
+
59
+ const total = sorted.reduce((n, g) => n + g.count, 0);
60
+ console.log(`${total} failed workpiece${total === 1 ? '' : 's'}, ${sorted.length} distinct error${sorted.length === 1 ? '' : 's'}\n`);
61
+ console.log(formatTable(
62
+ ['COUNT', 'ERROR', 'WHERE', `LAST FAILED (${localTzLabel()})`, 'EXAMPLES'],
63
+ sorted.map((g) => [
64
+ g.orphans ? `${g.count} (${g.orphans} orphaned)` : String(g.count),
65
+ g.key.length > ERROR_COL ? `${g.key.slice(0, ERROR_COL - 1)}…` : g.key,
66
+ Object.keys(g.where).length === 1
67
+ ? Object.keys(g.where)[0]
68
+ : Object.entries(g.where).map(([w, n]) => `${w} ×${n}`).join(', '),
69
+ g.last_failed_at ? formatDate(g.last_failed_at) : '—',
70
+ g.workpieces.slice(0, MAX_EXAMPLES).map((w) => w.id).join(', ') + (g.count > MAX_EXAMPLES ? ` +${g.count - MAX_EXAMPLES}` : ''),
71
+ ]),
72
+ ));
73
+ if (orphan_total > 0) {
74
+ console.log(`\n${orphan_total} orphaned: failed folder${orphan_total === 1 ? '' : 's'} with no pointer.json. \`show\` and \`clear\` can't see them; inspect or remove them on the host (--json has their stations).`);
75
+ }
76
+ const ex = sorted.flatMap((g) => g.workpieces).find((w) => !w.orphan) ?? sorted[0].workpieces[0];
77
+ const flags = factories.find((w) => w.name === ex.factory).flags;
78
+ console.log(`\nDetails: \`al-control workpieces show ${ex.id} ${flags}\`. Every id per group: --json.`);
79
+ }
@@ -0,0 +1,140 @@
1
+ import { formatTable, formatSection, formatTime, formatElapsed, localTzLabel } from '../../utils/format.js';
2
+ import {
3
+ resolvePosition,
4
+ findWorkpieceMatches,
5
+ collectIdsForBin,
6
+ idsOnLine,
7
+ workpieceRelDir,
8
+ } from '../../utils/line-state.js';
9
+ import { loadLines, loadLogs, workpieceLocation } from '../../utils/factory-data.js';
10
+
11
+ /**
12
+ * `al-control workpieces list -f <factory> [--line] [--bin] [--match]` —
13
+ * dashboard of workpieces on disk: position, last event, folder.
14
+ *
15
+ * Scope flags are optional and layer when combined:
16
+ * --line VM all workpieces on the VM line
17
+ * --bin STATION/BIN one bin, e.g. VM3/failed
18
+ * --match <substring> workpiece-id substring
19
+ *
20
+ * @param {{line?: string, bin?: string, match?: string, json?: boolean}} argv
21
+ * @param {import('../../config.js').FactoryContext} factory
22
+ */
23
+ export async function listWorkpiecesHandler(argv, factory) {
24
+ const { line: line_arg, bin: bin_arg, match: match_arg, json } = argv;
25
+ const { root, lines } = loadLines(factory);
26
+
27
+ if (Object.keys(lines).length === 0) {
28
+ if (json) {
29
+ console.log(JSON.stringify({ header: 'Workpieces', workpieces: [] }, null, 2));
30
+ return;
31
+ }
32
+ console.log(`No lines in ${factory.name}/.orchestrator/stations/. Run "al-orc stations pull --all" in the factory repo.`);
33
+ return;
34
+ }
35
+
36
+ let ids;
37
+ let header = 'Workpieces';
38
+ if (bin_arg) {
39
+ const result = collectIdsForBin(bin_arg, lines);
40
+ if (!result.ok) {
41
+ console.error(result.error);
42
+ process.exit(1);
43
+ }
44
+ ids = result.ids;
45
+ header = `Workpieces in ${bin_arg}`;
46
+ } else if (match_arg) {
47
+ ids = [...findWorkpieceMatches(match_arg, lines).keys()];
48
+ header = `Workpieces matching "${match_arg}"`;
49
+ } else if (line_arg) {
50
+ ids = idsOnLine(requireLine(lines, line_arg));
51
+ header = `Workpieces on line ${line_arg}`;
52
+ } else {
53
+ ids = [...new Set(Object.values(lines).flatMap(idsOnLine))];
54
+ }
55
+
56
+ // --line narrows --bin / --match further.
57
+ if (line_arg && (bin_arg || match_arg)) {
58
+ const line_ids = new Set(idsOnLine(requireLine(lines, line_arg)));
59
+ ids = ids.filter((id) => line_ids.has(id));
60
+ }
61
+
62
+ ids.sort((a, b) => a.localeCompare(b));
63
+
64
+ const positioned = ids.map((id) => {
65
+ const pos = resolvePosition(id, lines);
66
+ return { id, pos, rel: pos ? workpieceRelDir(pos, id) : null };
67
+ });
68
+ const logs = loadLogs(factory, positioned.filter((p) => p.pos).map((p) => p.rel));
69
+
70
+ const rows = positioned.map(({ id, pos, rel }) => {
71
+ if (!pos) return { id, position: '(not on disk)', last_event: '—', folder: '—' };
72
+ return {
73
+ id,
74
+ position: positionShort(pos),
75
+ last_event: formatLastEventCompact(logs[rel] || []),
76
+ folder: workpieceLocation(factory, root, rel),
77
+ };
78
+ });
79
+
80
+ if (json) {
81
+ console.log(JSON.stringify({ header, workpieces: rows }, null, 2));
82
+ return;
83
+ }
84
+
85
+ if (rows.length === 0) {
86
+ console.log(`${header}: 0 workpieces.`);
87
+ return;
88
+ }
89
+
90
+ console.log(`${header} — ${rows.length} workpiece${rows.length === 1 ? '' : 's'}`);
91
+ console.log('');
92
+ console.log(formatTable(
93
+ ['WORKPIECE', 'POSITION', `LAST EVENT (${localTzLabel()})`],
94
+ rows.map((r) => [r.id, r.position, r.last_event]),
95
+ ));
96
+
97
+ console.log(formatSection('Folders'));
98
+ const id_w = Math.max(...rows.map((r) => r.id.length));
99
+ for (const r of rows) console.log(` ${r.id.padEnd(id_w)} ${r.folder}`);
100
+ }
101
+
102
+ function requireLine(lines, code) {
103
+ const ls = lines[code];
104
+ if (!ls) {
105
+ console.error(`Line "${code}" not found. Available: ${Object.keys(lines).sort().join(', ') || '(none)'}`);
106
+ process.exit(1);
107
+ }
108
+ return ls;
109
+ }
110
+
111
+ function positionShort(pos) {
112
+ // Show the sub-bin segment when the workpiece lives under one
113
+ // (HI3/output/invoices); the id itself is already the WORKPIECE column.
114
+ const sub = pos.subpath && pos.subpath.includes('/')
115
+ ? '/' + pos.subpath.slice(0, pos.subpath.lastIndexOf('/'))
116
+ : '';
117
+ if (pos.anomaly) return `${pos.station}/done${sub} (anomaly)`;
118
+ let suffix = '';
119
+ if (pos.bin === 'failed') suffix = ' (stuck)';
120
+ else if (pos.bin === 'output' && pos.terminal) suffix = ' (finished)';
121
+ else if (pos.bin === 'doing') suffix = ' (active)';
122
+ return `${pos.station}/${pos.bin}${sub}${suffix}`;
123
+ }
124
+
125
+ function formatLastEventCompact(events) {
126
+ if (events.length === 0) return '—';
127
+ const e = events[events.length - 1];
128
+ const ts = formatTime(e.ts).slice(0, 5); // HH:MM, local
129
+ let extra = '';
130
+ if (e.event === 'station_complete' || e.event === 'station_failed') {
131
+ for (let i = events.length - 2; i >= 0; i--) {
132
+ if (events[i].station === e.station && events[i].event === 'station_started') {
133
+ const ms = new Date(e.ts).getTime() - new Date(events[i].ts).getTime();
134
+ if (!Number.isNaN(ms) && ms >= 0) extra = ` (${formatElapsed(ms)})`;
135
+ break;
136
+ }
137
+ }
138
+ }
139
+ return `${ts} ${e.station} ${e.event}${extra}`;
140
+ }
@@ -0,0 +1,25 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ import { shell } from '../../transport.js';
4
+ import { workpiecePath } from '../../utils/factory-data.js';
5
+ import { resolveOneWorkpiece } from './_resolve.js';
6
+
7
+ /**
8
+ * `al-control workpieces open <id> -f <factory>` — local: the folder in
9
+ * Finder (xdg-open on Linux). Remote: an interactive shell on the host,
10
+ * started in the folder; exit it to come back.
11
+ *
12
+ * @param {{id: string}} argv
13
+ * @param {import('../../config.js').FactoryContext} factory
14
+ */
15
+ export async function openHandler(argv, factory) {
16
+ const { root, rel } = resolveOneWorkpiece(factory, argv.id);
17
+ const dir = workpiecePath(root, rel);
18
+ if (factory.host.is_local) {
19
+ const res = spawnSync(process.platform === 'darwin' ? 'open' : 'xdg-open', [dir], { stdio: 'inherit' });
20
+ if (res.error || res.status !== 0) console.log(dir);
21
+ return;
22
+ }
23
+ console.error(`Shell in ${dir} on ${factory.host.name} — exit to return.`);
24
+ shell(factory.host, dir);
25
+ }