@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
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { probe } from '../../transport.js';
|
|
4
|
+
import { formatTable } from '../../utils/format.js';
|
|
5
|
+
import { loadLines } from '../../utils/factory-data.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `al-control workpieces requeue [id] --bin STATION/failed -f <factory> [--yes]` — move
|
|
9
|
+
* workpieces back to their station's `input` to run that station again. With `--bin`, every
|
|
10
|
+
* workpiece in that station's `failed` bin; with an id, that one workpiece from `failed` or
|
|
11
|
+
* `done`. Dry run unless `--yes`. The probe re-checks each folder, drops its stale
|
|
12
|
+
* `error.json`/`error.txt` and logs a `requeued` event; the station's next run picks it up.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} RequeueEntry
|
|
17
|
+
* @property {string} id
|
|
18
|
+
* @property {string} from - `STATION/BIN`
|
|
19
|
+
* @property {string} rel - folder relative to temp/stations/
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Which workpieces a requeue would move. Throws a user-facing Error when the request can't be
|
|
24
|
+
* met (unknown station, ambiguous id, nothing found).
|
|
25
|
+
*
|
|
26
|
+
* @param {Object<string, import('../../utils/line-state.js').LineState>} lines
|
|
27
|
+
* @param {{id?: string, bin?: string}} argv
|
|
28
|
+
* @returns {RequeueEntry[]}
|
|
29
|
+
*/
|
|
30
|
+
export function planRequeue(lines, argv) {
|
|
31
|
+
if (argv.bin) {
|
|
32
|
+
const m = argv.bin.match(/^([^/]+)\/failed\/?$/);
|
|
33
|
+
if (!m) throw new Error(`--bin takes STATION/failed (e.g. EX1/failed); to retry a done workpiece, give its id.`);
|
|
34
|
+
const station = m[1];
|
|
35
|
+
const line = Object.values(lines).find((l) => l.stations.includes(station));
|
|
36
|
+
if (!line) throw new Error(`Station "${station}" not found.`);
|
|
37
|
+
const map = line.bins[station].failed;
|
|
38
|
+
return [...(map ?? new Map())].map(([id, sub]) => ({ id, from: `${station}/failed`, rel: `${station}/failed/${sub}` }));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @type {RequeueEntry[]} */
|
|
42
|
+
const all = [];
|
|
43
|
+
for (const line of Object.values(lines)) {
|
|
44
|
+
for (const station of line.stations) {
|
|
45
|
+
for (const bin of ['failed', 'done']) {
|
|
46
|
+
for (const [id, sub] of line.bins[station][bin] ?? new Map()) {
|
|
47
|
+
all.push({ id, from: `${station}/${bin}`, rel: `${station}/${bin}/${sub}` });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const query = /** @type {string} */ (argv.id);
|
|
53
|
+
const exact = all.filter((e) => e.id === query);
|
|
54
|
+
const ids = exact.length > 0 ? [query] : [...new Set(all.filter((e) => e.id.includes(query)).map((e) => e.id))].sort();
|
|
55
|
+
if (ids.length === 0) throw new Error(`No failed or done workpiece matches "${query}".`);
|
|
56
|
+
if (ids.length > 1) {
|
|
57
|
+
const shown = ids.slice(0, 10);
|
|
58
|
+
throw new Error(`"${query}" matches ${ids.length} workpieces; use the full id:\n ${shown.join('\n ')}${ids.length > 10 ? `\n … +${ids.length - 10} more` : ''}`);
|
|
59
|
+
}
|
|
60
|
+
const copies = all.filter((e) => e.id === ids[0]);
|
|
61
|
+
if (copies.length > 1) {
|
|
62
|
+
throw new Error(`${ids[0]} is in ${copies.map((c) => c.from).join(' and ')}; requeue one bin with --bin STATION/failed.`);
|
|
63
|
+
}
|
|
64
|
+
return copies;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {{id?: string, bin?: string, yes?: boolean}} argv
|
|
69
|
+
* @param {import('../../config.js').FactoryContext} factory
|
|
70
|
+
*/
|
|
71
|
+
export async function requeueHandler(argv, factory) {
|
|
72
|
+
const { lines } = loadLines(factory);
|
|
73
|
+
let plan;
|
|
74
|
+
try {
|
|
75
|
+
plan = planRequeue(lines, argv);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
const where = `${factory.host.name} / ${factory.name}`;
|
|
81
|
+
if (plan.length === 0) {
|
|
82
|
+
console.log(`Nothing to requeue: ${argv.bin} is empty on ${where}.`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const counts = new Map();
|
|
87
|
+
for (const e of plan) counts.set(e.from, (counts.get(e.from) ?? 0) + 1);
|
|
88
|
+
console.log(formatTable(['FROM', 'TO', 'WORKPIECES'], [...counts].map(([from, n]) => [from, `${from.split('/')[0]}/input`, String(n)])));
|
|
89
|
+
if (plan.length === 1) console.log(`\n${plan[0].id}`);
|
|
90
|
+
console.log('');
|
|
91
|
+
|
|
92
|
+
if (!argv.yes) {
|
|
93
|
+
console.log(`Dry run: would requeue ${plan.length} workpiece${plan.length === 1 ? '' : 's'} on ${where}. Re-run with --yes to move.`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const { requeued } = probe(factory.host, { type: 'requeue', path: factory.path, dirs: plan.map((e) => e.rel) });
|
|
97
|
+
console.log(`Requeued ${requeued.length} workpiece${requeued.length === 1 ? '' : 's'} on ${where}. The station's next run picks them up.`);
|
|
98
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { resolvePosition, findWorkpieceMatches, workpieceRelDir } from '../../utils/line-state.js';
|
|
2
|
+
import { loadLines, loadLogs, loadWorkpieceFiles, workpieceLocation } from '../../utils/factory-data.js';
|
|
3
|
+
import { formatDate, formatElapsed, formatSection, localTzLabel } from '../../utils/format.js';
|
|
4
|
+
import { listWorkpiecesHandler } from './list.js';
|
|
5
|
+
|
|
6
|
+
const STACK_FRAMES = 6;
|
|
7
|
+
const MESSAGE_LINES = 3;
|
|
8
|
+
const LINE_MAX = 300;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `al-control workpieces show <id-or-substring> -f <factory>` — one
|
|
12
|
+
* workpiece: position, journey from log.jsonl with durations, the error when
|
|
13
|
+
* it has one, its files, folder.
|
|
14
|
+
*
|
|
15
|
+
* A substring matching several ids promotes to the `workpieces list --match`
|
|
16
|
+
* dashboard; pass the full id to force the single view.
|
|
17
|
+
*
|
|
18
|
+
* @param {{id: string, json?: boolean}} argv
|
|
19
|
+
* @param {import('../../config.js').FactoryContext} factory
|
|
20
|
+
*/
|
|
21
|
+
export async function showWorkpieceHandler(argv, factory) {
|
|
22
|
+
const { id: query, json } = argv;
|
|
23
|
+
const { root, lines } = loadLines(factory);
|
|
24
|
+
const matches = findWorkpieceMatches(query, lines);
|
|
25
|
+
|
|
26
|
+
if (!matches.has(query) && matches.size > 1) {
|
|
27
|
+
return listWorkpiecesHandler({ ...argv, match: query }, factory);
|
|
28
|
+
}
|
|
29
|
+
const id = matches.has(query) ? query : [...matches.keys()][0];
|
|
30
|
+
if (!id) {
|
|
31
|
+
console.error(`No workpiece matches "${query}".`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const pos = resolvePosition(id, lines);
|
|
36
|
+
const rel = workpieceRelDir(pos, id);
|
|
37
|
+
const journey = loadLogs(factory, [rel])[rel];
|
|
38
|
+
const folder = workpieceLocation(factory, root, rel);
|
|
39
|
+
const { files, error } = loadWorkpieceFiles(factory, rel);
|
|
40
|
+
|
|
41
|
+
if (json) {
|
|
42
|
+
console.log(JSON.stringify({ id, position: pos, journey, error, files, folder }, null, 2));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
console.log(`Workpiece: ${id}`);
|
|
47
|
+
console.log(`Position: ${positionLabel(pos)}`);
|
|
48
|
+
console.log('');
|
|
49
|
+
renderJourney(journey);
|
|
50
|
+
if (error) renderError(error, `al-control workpieces cat ${id} error.txt ${factory.flags}`);
|
|
51
|
+
console.log(formatSection('Files'));
|
|
52
|
+
const name_w = Math.max(...files.map((f) => f.name.length), 0);
|
|
53
|
+
for (const f of files) console.log(` ${f.name.padEnd(name_w)} ${formatSize(f.size).padStart(8)}`);
|
|
54
|
+
console.log('');
|
|
55
|
+
console.log(`Folder: ${folder}`);
|
|
56
|
+
console.log('');
|
|
57
|
+
console.log(`Read a file: al-control workpieces cat ${id} <file> ${factory.flags}`);
|
|
58
|
+
console.log(`Go there: al-control workpieces open ${id} ${factory.flags}`);
|
|
59
|
+
console.log(`Tail live: al-control workpieces watch ${id} ${factory.flags}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Message (first lines, capped — some carry a whole HTML page), any extra
|
|
64
|
+
* fields the step attached, then the top of the stack.
|
|
65
|
+
*/
|
|
66
|
+
function renderError(error, full_cmd) {
|
|
67
|
+
const heading = `Error${error.failed_at ? ` — failed ${formatDate(error.failed_at)}` : ''}`;
|
|
68
|
+
console.log(formatSection(heading));
|
|
69
|
+
const cap = (l) => (l.length > LINE_MAX ? `${l.slice(0, LINE_MAX)}… [+${l.length - LINE_MAX} chars]` : l);
|
|
70
|
+
const message_lines = `${error.name ?? 'Error'}: ${error.message ?? ''}`.split('\n');
|
|
71
|
+
for (const l of message_lines.slice(0, MESSAGE_LINES)) console.log(` ${cap(l)}`);
|
|
72
|
+
if (message_lines.length > MESSAGE_LINES) console.log(` … +${message_lines.length - MESSAGE_LINES} lines`);
|
|
73
|
+
|
|
74
|
+
for (const [key, value] of Object.entries(error)) {
|
|
75
|
+
if (['failed_at', 'name', 'message', 'stack'].includes(key)) continue;
|
|
76
|
+
console.log(` ${key}: ${cap(typeof value === 'string' ? value : JSON.stringify(value))}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const frames = String(error.stack ?? '').split('\n').filter((l) => /^\s+at /.test(l));
|
|
80
|
+
if (frames.length > 0) {
|
|
81
|
+
console.log('');
|
|
82
|
+
for (const f of frames.slice(0, STACK_FRAMES)) console.log(` ${f.trim()}`);
|
|
83
|
+
if (frames.length > STACK_FRAMES) console.log(` … +${frames.length - STACK_FRAMES} frames`);
|
|
84
|
+
}
|
|
85
|
+
console.log(`\n Full error: ${full_cmd}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function formatSize(bytes) {
|
|
89
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
90
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
91
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function positionLabel(pos) {
|
|
95
|
+
let label = `${pos.station}/${pos.bin}`;
|
|
96
|
+
if (pos.anomaly) label += ' (anomaly — only in done, no live presence)';
|
|
97
|
+
else if (pos.bin === 'failed') label += ' (stuck)';
|
|
98
|
+
else if (pos.bin === 'output' && pos.terminal) label += ' (finished — terminal output)';
|
|
99
|
+
else if (pos.bin === 'doing') label += ' (in progress)';
|
|
100
|
+
return label;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function renderJourney(events) {
|
|
104
|
+
const heading = `Journey (${localTzLabel()})`;
|
|
105
|
+
console.log(heading);
|
|
106
|
+
console.log('-'.repeat(heading.length));
|
|
107
|
+
if (events.length === 0) {
|
|
108
|
+
console.log(' (no log.jsonl found at current position)');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const started_at = {}; // station → ts of last station_started
|
|
112
|
+
for (const e of events) {
|
|
113
|
+
let extra = '';
|
|
114
|
+
if (e.event === 'station_started') {
|
|
115
|
+
started_at[e.station] = e.ts;
|
|
116
|
+
} else if (e.event === 'station_complete' || e.event === 'station_failed') {
|
|
117
|
+
const start = started_at[e.station];
|
|
118
|
+
if (start) {
|
|
119
|
+
const ms = new Date(e.ts).getTime() - new Date(start).getTime();
|
|
120
|
+
if (!Number.isNaN(ms) && ms >= 0) extra = ` (${formatElapsed(ms)})`;
|
|
121
|
+
delete started_at[e.station];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
console.log(`${formatDate(e.ts)} ${e.station.padEnd(3)} ${e.event}${extra}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolvePosition,
|
|
3
|
+
findWorkpieceMatches,
|
|
4
|
+
collectIdsForBin,
|
|
5
|
+
idsOnLine,
|
|
6
|
+
workpieceRelDir,
|
|
7
|
+
} from '../../utils/line-state.js';
|
|
8
|
+
import { loadLines, loadLogs, workpieceLocation } from '../../utils/factory-data.js';
|
|
9
|
+
import { formatTime } from '../../utils/format.js';
|
|
10
|
+
import { listWorkpiecesHandler } from './list.js';
|
|
11
|
+
import { showWorkpieceHandler } from './show.js';
|
|
12
|
+
|
|
13
|
+
const DEFAULT_INTERVAL_SECS = 2;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `al-control workpieces watch [id | --line | --bin | --match] -f <factory>` —
|
|
17
|
+
* append-style live tail: prints only bin moves and new log events.
|
|
18
|
+
*
|
|
19
|
+
* Renders the matching snapshot first (show for one workpiece, list for a
|
|
20
|
+
* set), then polls. Each tick is two probe calls — state, then the tracked
|
|
21
|
+
* workpieces' logs in one batch.
|
|
22
|
+
*
|
|
23
|
+
* @param {{id?: string, line?: string, bin?: string, match?: string, interval?: number}} argv
|
|
24
|
+
* @param {import('../../config.js').FactoryContext} factory
|
|
25
|
+
*/
|
|
26
|
+
export async function watchHandler(argv, factory) {
|
|
27
|
+
const { id, line: line_arg, bin: bin_arg, match: match_arg, interval } = argv;
|
|
28
|
+
const interval_secs = typeof interval === 'number' ? interval : DEFAULT_INTERVAL_SECS;
|
|
29
|
+
const { lines } = loadLines(factory);
|
|
30
|
+
|
|
31
|
+
if (id) {
|
|
32
|
+
const matches = findWorkpieceMatches(id, lines);
|
|
33
|
+
if (matches.size === 0) {
|
|
34
|
+
console.error(`No workpiece matches "${id}".`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
if (matches.size === 1 || matches.has(id)) {
|
|
38
|
+
const resolved_id = matches.has(id) ? id : [...matches.keys()][0];
|
|
39
|
+
await showWorkpieceHandler({ id: resolved_id }, factory);
|
|
40
|
+
await watchLoop(factory, [resolved_id], { interval_secs, single: true });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
await listWorkpiecesHandler({ match: id }, factory);
|
|
44
|
+
await watchLoop(factory, [...matches.keys()], { interval_secs, single: false });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let ids;
|
|
49
|
+
if (bin_arg) {
|
|
50
|
+
const result = collectIdsForBin(bin_arg, lines);
|
|
51
|
+
if (!result.ok) {
|
|
52
|
+
console.error(result.error);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
ids = result.ids;
|
|
56
|
+
} else if (match_arg) {
|
|
57
|
+
ids = [...findWorkpieceMatches(match_arg, lines).keys()];
|
|
58
|
+
} else if (line_arg) {
|
|
59
|
+
const ls = lines[line_arg];
|
|
60
|
+
if (!ls) {
|
|
61
|
+
console.error(`Line "${line_arg}" not found. Available: ${Object.keys(lines).sort().join(', ') || '(none)'}`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
ids = idsOnLine(ls);
|
|
65
|
+
} else {
|
|
66
|
+
console.error(`Usage: al-control workpieces watch <id> | --line <code> | --bin STATION/BIN | --match <substring> ${factory.flags}`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (ids.length === 0) {
|
|
71
|
+
console.log('No workpieces in scope.');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await listWorkpiecesHandler({ line: line_arg, bin: bin_arg, match: match_arg }, factory);
|
|
76
|
+
await watchLoop(factory, ids, { interval_secs, single: false });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Poll a fixed set of workpieces. Multi-workpiece output tags each line with
|
|
81
|
+
* the id; a workpiece reaching terminal output emits `✓ finished` once and
|
|
82
|
+
* stops being followed.
|
|
83
|
+
*
|
|
84
|
+
* @param {import('../../config.js').FactoryContext} factory
|
|
85
|
+
* @param {string[]} ids
|
|
86
|
+
* @param {{interval_secs: number, single: boolean}} opts
|
|
87
|
+
*/
|
|
88
|
+
async function watchLoop(factory, ids, { interval_secs, single }) {
|
|
89
|
+
process.on('SIGINT', () => { console.log('\nStopped.'); process.exit(0); });
|
|
90
|
+
const label = single ? ids[0] : `${ids.length} workpiece${ids.length === 1 ? '' : 's'}`;
|
|
91
|
+
console.log(`\n--- watching ${label} (interval ${interval_secs}s, Ctrl-C to stop) ---`);
|
|
92
|
+
|
|
93
|
+
/** @type {Map<string, {pos_key: string, log_count: number, finished: boolean}>} */
|
|
94
|
+
const state = new Map();
|
|
95
|
+
|
|
96
|
+
const snapshot = () => {
|
|
97
|
+
const { root, lines } = loadLines(factory);
|
|
98
|
+
const tracked = ids
|
|
99
|
+
.filter((id) => !state.get(id)?.finished)
|
|
100
|
+
.map((id) => {
|
|
101
|
+
const pos = resolvePosition(id, lines);
|
|
102
|
+
return { id, pos, rel: pos ? workpieceRelDir(pos, id) : null };
|
|
103
|
+
});
|
|
104
|
+
const logs = loadLogs(factory, tracked.filter((t) => t.pos).map((t) => t.rel));
|
|
105
|
+
return { root, tracked, logs };
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const seed = snapshot();
|
|
109
|
+
for (const { id, pos, rel } of seed.tracked) {
|
|
110
|
+
if (!pos) {
|
|
111
|
+
if (single) console.log(`${formatTime(new Date())} ${id} not on disk`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
state.set(id, {
|
|
115
|
+
pos_key: `${pos.station}/${pos.bin}`,
|
|
116
|
+
log_count: seed.logs[rel].length,
|
|
117
|
+
finished: !single && pos.bin === 'output' && pos.terminal,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if (state.size === 0 && single) return;
|
|
121
|
+
|
|
122
|
+
const tag = (id) => (single ? '' : ` ${id}`);
|
|
123
|
+
|
|
124
|
+
while (true) {
|
|
125
|
+
await new Promise((r) => setTimeout(r, interval_secs * 1000));
|
|
126
|
+
const { root, tracked, logs } = snapshot();
|
|
127
|
+
|
|
128
|
+
for (const { id, pos, rel } of tracked) {
|
|
129
|
+
const prev = state.get(id);
|
|
130
|
+
if (!prev) continue;
|
|
131
|
+
|
|
132
|
+
if (!pos) {
|
|
133
|
+
console.log(`${formatTime(new Date())}${tag(id)} no longer on disk`);
|
|
134
|
+
state.delete(id);
|
|
135
|
+
if (single) return;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const pos_key = `${pos.station}/${pos.bin}`;
|
|
140
|
+
const events = logs[rel];
|
|
141
|
+
if (pos_key !== prev.pos_key) {
|
|
142
|
+
console.log(`${formatTime(new Date())}${tag(id)} → moved from ${prev.pos_key} to ${pos_key}`);
|
|
143
|
+
console.log(`${single ? 'Folder: ' : ' '}${workpieceLocation(factory, root, rel)}`);
|
|
144
|
+
prev.pos_key = pos_key;
|
|
145
|
+
prev.log_count = events.length;
|
|
146
|
+
} else if (events.length > prev.log_count) {
|
|
147
|
+
for (const e of events.slice(prev.log_count)) {
|
|
148
|
+
console.log(`${formatTime(e.ts)}${tag(id)} ${single ? e.station.padEnd(3) : e.station} ${e.event}`);
|
|
149
|
+
}
|
|
150
|
+
prev.log_count = events.length;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!single && pos.bin === 'output' && pos.terminal && !prev.finished) {
|
|
154
|
+
console.log(`${formatTime(new Date())}${tag(id)} ✓ finished (terminal output)`);
|
|
155
|
+
prev.finished = true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config store — ~/.assemblyline/al-control/config.yml — and resolution of which host
|
|
3
|
+
* and factory the current command targets.
|
|
4
|
+
*
|
|
5
|
+
* Shape:
|
|
6
|
+
* current_host: local
|
|
7
|
+
* hosts:
|
|
8
|
+
* local:
|
|
9
|
+
* factories:
|
|
10
|
+
* factory-acme: ~/factories/factory-acme
|
|
11
|
+
* build-box:
|
|
12
|
+
* ssh: build-box # alias from ~/.ssh/config
|
|
13
|
+
* node: /home/me/.nvm/versions/node/v20.11.0/bin/node # optional
|
|
14
|
+
* factories:
|
|
15
|
+
* factory-acme: /home/me/factories/factory-acme
|
|
16
|
+
* factory-demo: # long form, to protect bins from `workpieces clear`
|
|
17
|
+
* path: /home/me/factories/factory-demo
|
|
18
|
+
* keep: [DI1/done]
|
|
19
|
+
*
|
|
20
|
+
* `local` is reserved for this machine and has no `ssh`. Every other host
|
|
21
|
+
* must have one. Factory paths are paths on that host (`~` expands there).
|
|
22
|
+
*
|
|
23
|
+
* Override the directory with AL_CONTROL_CONFIG_DIR (used by tests).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
27
|
+
import { homedir } from 'node:os';
|
|
28
|
+
import { join, resolve, basename } from 'node:path';
|
|
29
|
+
import yaml from 'js-yaml';
|
|
30
|
+
|
|
31
|
+
export const LOCAL_HOST = 'local';
|
|
32
|
+
|
|
33
|
+
/** @returns {string} */
|
|
34
|
+
export function configDir() {
|
|
35
|
+
return process.env.AL_CONTROL_CONFIG_DIR || join(homedir(), '.assemblyline', 'al-control');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @returns {string} */
|
|
39
|
+
export function configPath() {
|
|
40
|
+
return join(configDir(), 'config.yml');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const EXAMPLE = `current_host: local
|
|
44
|
+
hosts:
|
|
45
|
+
local:
|
|
46
|
+
factories:
|
|
47
|
+
factory-acme: ~/path/to/factory-acme
|
|
48
|
+
my-server:
|
|
49
|
+
ssh: my-server # alias from ~/.ssh/config
|
|
50
|
+
factories:
|
|
51
|
+
factory-acme: /home/me/factories/factory-acme`;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @typedef {object} HostConfig
|
|
55
|
+
* @property {string} [ssh] - ssh alias / destination; absent for `local`
|
|
56
|
+
* @property {string} [node] - absolute path to `node` on the host; its dir is
|
|
57
|
+
* prepended to PATH so `pm2` resolves too
|
|
58
|
+
* @property {Object<string, string|FactoryEntry>} factories - factory name → path
|
|
59
|
+
* on the host, or the long form
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @typedef {object} FactoryEntry
|
|
64
|
+
* @property {string} path
|
|
65
|
+
* @property {string[]} [keep] - `STATION/BIN` bins `workpieces clear` refuses
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Either form of a factory entry, as `{path, keep}`.
|
|
70
|
+
* @param {string|FactoryEntry} entry
|
|
71
|
+
* @returns {{path: string, keep: string[]}}
|
|
72
|
+
*/
|
|
73
|
+
export function factoryEntry(entry) {
|
|
74
|
+
return typeof entry === 'string' ? { path: entry, keep: [] } : { path: entry.path, keep: entry.keep ?? [] };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @typedef {object} Config
|
|
79
|
+
* @property {string|null} current_host
|
|
80
|
+
* @property {Object<string, HostConfig>} hosts
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Load and validate the config. Throws with an example when missing.
|
|
85
|
+
* @returns {Config}
|
|
86
|
+
*/
|
|
87
|
+
export function loadConfig() {
|
|
88
|
+
const file = configPath();
|
|
89
|
+
if (!existsSync(file)) {
|
|
90
|
+
throw new Error(`No config at ${file}. Create it, for example:\n\n${EXAMPLE}`);
|
|
91
|
+
}
|
|
92
|
+
const raw = yaml.load(readFileSync(file, 'utf8')) || {};
|
|
93
|
+
const cfg = { current_host: raw.current_host ?? null, hosts: raw.hosts ?? {} };
|
|
94
|
+
|
|
95
|
+
for (const [name, host] of Object.entries(cfg.hosts)) {
|
|
96
|
+
if (!host || typeof host !== 'object') throw new Error(`Host '${name}' in ${file} must be a mapping.`);
|
|
97
|
+
host.factories = host.factories ?? {};
|
|
98
|
+
if (name === LOCAL_HOST && host.ssh) {
|
|
99
|
+
throw new Error(`Host 'local' in ${file} is this machine and must not have an 'ssh' key.`);
|
|
100
|
+
}
|
|
101
|
+
if (name !== LOCAL_HOST) {
|
|
102
|
+
if (typeof host.ssh !== 'string' || !host.ssh) throw new Error(`Host '${name}' in ${file} needs an 'ssh' alias.`);
|
|
103
|
+
// A leading dash would be parsed by ssh as an option.
|
|
104
|
+
if (host.ssh.startsWith('-')) throw new Error(`Host '${name}' has an invalid ssh alias '${host.ssh}'.`);
|
|
105
|
+
}
|
|
106
|
+
for (const [w, entry] of Object.entries(host.factories)) {
|
|
107
|
+
const ok = typeof entry === 'string'
|
|
108
|
+
|| (entry && typeof entry.path === 'string' && (entry.keep === undefined || (Array.isArray(entry.keep) && entry.keep.every((k) => /^[^/]+\/(done|output)$/.test(k)))));
|
|
109
|
+
if (!ok) throw new Error(`Factory '${w}' on host '${name}' in ${file} must be a path, or {path, keep: [STATION/done|output, ...]}.`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return cfg;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** @param {Config} cfg */
|
|
116
|
+
export function saveConfig(cfg) {
|
|
117
|
+
mkdirSync(configDir(), { recursive: true });
|
|
118
|
+
writeFileSync(configPath(), yaml.dump(cfg), { mode: 0o600 });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {string} name
|
|
123
|
+
* @returns {Config}
|
|
124
|
+
*/
|
|
125
|
+
export function useHost(name) {
|
|
126
|
+
const cfg = loadConfig();
|
|
127
|
+
if (!cfg.hosts[name]) {
|
|
128
|
+
throw new Error(`No host named '${name}'. Configured: ${Object.keys(cfg.hosts).join(', ') || '(none)'}`);
|
|
129
|
+
}
|
|
130
|
+
cfg.current_host = name;
|
|
131
|
+
saveConfig(cfg);
|
|
132
|
+
return cfg;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @typedef {object} HostContext
|
|
137
|
+
* @property {string} name
|
|
138
|
+
* @property {boolean} is_local
|
|
139
|
+
* @property {string} [ssh]
|
|
140
|
+
* @property {string} [node]
|
|
141
|
+
* @property {Object<string, string>} factories - factory name → path on the host
|
|
142
|
+
* @property {Object<string, string[]>} keep - factory name → bins to never clear
|
|
143
|
+
*/
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Resolve the target host: `--host` beats `current_host`.
|
|
147
|
+
* @param {{host?: string}} argv
|
|
148
|
+
* @returns {HostContext}
|
|
149
|
+
*/
|
|
150
|
+
export function resolveHost(argv) {
|
|
151
|
+
const cfg = loadConfig();
|
|
152
|
+
const name = argv.host || cfg.current_host;
|
|
153
|
+
if (!name) {
|
|
154
|
+
throw new Error(`No host selected. Run \`al-control hosts use <name>\` or pass --host. Configured: ${Object.keys(cfg.hosts).join(', ') || '(none)'}`);
|
|
155
|
+
}
|
|
156
|
+
const host = cfg.hosts[name];
|
|
157
|
+
if (!host) {
|
|
158
|
+
throw new Error(`No host named '${name}' in ${configPath()}. Configured: ${Object.keys(cfg.hosts).join(', ') || '(none)'}`);
|
|
159
|
+
}
|
|
160
|
+
const factories = {};
|
|
161
|
+
const keep = {};
|
|
162
|
+
for (const [w, entry] of Object.entries(host.factories)) ({ path: factories[w], keep: keep[w] } = factoryEntry(entry));
|
|
163
|
+
return { name, is_local: name === LOCAL_HOST, ssh: host.ssh, node: host.node, factories, keep };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* @typedef {object} FactoryContext
|
|
168
|
+
* @property {HostContext} host
|
|
169
|
+
* @property {string} name
|
|
170
|
+
* @property {string} path - path on the host as configured (may start with `~`)
|
|
171
|
+
* @property {string[]} keep - bins `workpieces clear` refuses
|
|
172
|
+
* @property {string} flags - `-f <name>` (plus `--host` when overridden), for
|
|
173
|
+
* "run this next" hints
|
|
174
|
+
*/
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Whether a `-f` value is a path rather than a configured name. Names never hold a `/`.
|
|
178
|
+
* @param {string} value
|
|
179
|
+
* @returns {boolean}
|
|
180
|
+
*/
|
|
181
|
+
export function isPathTarget(value) {
|
|
182
|
+
return value === '.' || value === '..' || value.startsWith('~') || value.includes('/');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @param {string} p */
|
|
186
|
+
function expandHome(p) {
|
|
187
|
+
return p === '~' || p.startsWith('~/') ? join(homedir(), p.slice(1)) : p;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* `-f <path>`: a factory named by its folder. Without `--host` the path is on this machine
|
|
192
|
+
* (relative to the current directory) and needs no config; a configured local factory at the
|
|
193
|
+
* same path lends its name and `keep` bins. With `--host`, the path is on that host as written.
|
|
194
|
+
* Typing the path is the explicit target decision 0003 asks for; nothing falls back to the cwd.
|
|
195
|
+
* @param {{host?: string, factory: string}} argv
|
|
196
|
+
* @returns {FactoryContext}
|
|
197
|
+
*/
|
|
198
|
+
function resolvePathTarget(argv) {
|
|
199
|
+
if (argv.host) {
|
|
200
|
+
const host = resolveHost(argv);
|
|
201
|
+
const name = basename(argv.factory.replace(/\/+$/, '')) || argv.factory;
|
|
202
|
+
host.factories = { ...host.factories, [name]: argv.factory };
|
|
203
|
+
host.keep = { ...host.keep, [name]: host.keep[name] ?? [] };
|
|
204
|
+
return { ...factoryContext(host, name, argv.host), flags: `-f ${argv.factory} --host ${argv.host}` };
|
|
205
|
+
}
|
|
206
|
+
const abs = resolve(expandHome(argv.factory));
|
|
207
|
+
let name = basename(abs);
|
|
208
|
+
let keep = [];
|
|
209
|
+
if (existsSync(configPath())) {
|
|
210
|
+
const local = loadConfig().hosts[LOCAL_HOST];
|
|
211
|
+
for (const [n, entry] of Object.entries(local?.factories ?? {})) {
|
|
212
|
+
const e = factoryEntry(entry);
|
|
213
|
+
if (resolve(expandHome(e.path)) === abs) ({ name, keep } = { name: n, keep: e.keep });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** @type {HostContext} */
|
|
217
|
+
const host = { name: LOCAL_HOST, is_local: true, factories: { [name]: abs }, keep: { [name]: keep } };
|
|
218
|
+
return { host, name, path: abs, keep, flags: `-f ${argv.factory}` };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Resolve host + the `-f` factory on it: a configured name, or a path (`-f .`). `-f` is always
|
|
223
|
+
* required — the current directory is never consulted on its own.
|
|
224
|
+
* @param {{host?: string, factory?: string}} argv
|
|
225
|
+
* @returns {FactoryContext}
|
|
226
|
+
*/
|
|
227
|
+
export function resolveFactory(argv) {
|
|
228
|
+
if (argv.factory && isPathTarget(argv.factory)) return resolvePathTarget({ ...argv, factory: argv.factory });
|
|
229
|
+
const host = resolveHost(argv);
|
|
230
|
+
const names = Object.keys(host.factories);
|
|
231
|
+
if (!argv.factory) {
|
|
232
|
+
throw new Error(`Specify a factory with -f <name>, or -f . for the one you're in. Factories on ${host.name}: ${names.join(', ') || '(none)'}`);
|
|
233
|
+
}
|
|
234
|
+
const p = host.factories[argv.factory];
|
|
235
|
+
if (!p) {
|
|
236
|
+
throw new Error(`No factory '${argv.factory}' on host ${host.name}. Factories on ${host.name}: ${names.join(', ') || '(none)'}`);
|
|
237
|
+
}
|
|
238
|
+
return factoryContext(host, argv.factory, argv.host);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Every factory on the host when no `-f` is given, else just that one — for
|
|
243
|
+
* commands whose scope is "the host" but that can narrow to a factory.
|
|
244
|
+
* @param {{host?: string, factory?: string}} argv
|
|
245
|
+
* @returns {{host: HostContext, factories: FactoryContext[]}}
|
|
246
|
+
*/
|
|
247
|
+
export function resolveFactories(argv) {
|
|
248
|
+
if (argv.factory) {
|
|
249
|
+
const factory = resolveFactory(argv);
|
|
250
|
+
return { host: factory.host, factories: [factory] };
|
|
251
|
+
}
|
|
252
|
+
const host = resolveHost(argv);
|
|
253
|
+
return { host, factories: Object.keys(host.factories).map((name) => factoryContext(host, name, argv.host)) };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function factoryContext(host, name, host_flag) {
|
|
257
|
+
const flags = `-f ${name}${host_flag ? ` --host ${host_flag}` : ''}`;
|
|
258
|
+
return { host, name, path: host.factories[name], keep: host.keep[name], flags };
|
|
259
|
+
}
|