@assemblyline/al-control 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,213 @@
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 } 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
+ * Resolve host + the `-f` factory on it. `-f` is always required — the current
178
+ * directory is never consulted.
179
+ * @param {{host?: string, factory?: string}} argv
180
+ * @returns {FactoryContext}
181
+ */
182
+ export function resolveFactory(argv) {
183
+ const host = resolveHost(argv);
184
+ const names = Object.keys(host.factories);
185
+ if (!argv.factory) {
186
+ throw new Error(`Specify a factory with -f <name>. Factories on ${host.name}: ${names.join(', ') || '(none)'}`);
187
+ }
188
+ const p = host.factories[argv.factory];
189
+ if (!p) {
190
+ throw new Error(`No factory '${argv.factory}' on host ${host.name}. Factories on ${host.name}: ${names.join(', ') || '(none)'}`);
191
+ }
192
+ return factoryContext(host, argv.factory, argv.host);
193
+ }
194
+
195
+ /**
196
+ * Every factory on the host when no `-f` is given, else just that one — for
197
+ * commands whose scope is "the host" but that can narrow to a factory.
198
+ * @param {{host?: string, factory?: string}} argv
199
+ * @returns {{host: HostContext, factories: FactoryContext[]}}
200
+ */
201
+ export function resolveFactories(argv) {
202
+ if (argv.factory) {
203
+ const factory = resolveFactory(argv);
204
+ return { host: factory.host, factories: [factory] };
205
+ }
206
+ const host = resolveHost(argv);
207
+ return { host, factories: Object.keys(host.factories).map((name) => factoryContext(host, name, argv.host)) };
208
+ }
209
+
210
+ function factoryContext(host, name, host_flag) {
211
+ const flags = `-f ${name}${host_flag ? ` --host ${host_flag}` : ''}`;
212
+ return { host, name, path: host.factories[name], keep: host.keep[name], flags };
213
+ }