@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,225 @@
1
+ /**
2
+ * Line state — client-side model over what the probe read from a factory.
3
+ *
4
+ * The probe returns raw station defs and bin listings (plain JSON). This
5
+ * module turns them into per-line state and answers:
6
+ * - which lines exist and their station order
7
+ * - how many workpieces sit in each bin of each station
8
+ * - where a given workpiece is right now (most-advanced live bin)
9
+ *
10
+ * Mental model (see fde-handbook/patterns/structural/assembly-line-processing.md):
11
+ * - Live bins (workpiece's current location): input | doing | output | failed
12
+ * - Archive bin (receipt of a successful forward move): done — NOT a position
13
+ * - Position rule: walk stations terminal → source; at each, check
14
+ * output > doing > input > failed; first match wins. done ignored.
15
+ *
16
+ * Station order, terminal and summaries come from ../probe.js (one copy, shared
17
+ * with control-room); this module adds the Map view and workpiece positions.
18
+ */
19
+
20
+ import {
21
+ ALL_BINS,
22
+ LIVE_BINS,
23
+ codeOf,
24
+ indexStationsByRef,
25
+ dependencyRef,
26
+ resolveStationRef,
27
+ topoSortStations,
28
+ computeTerminal,
29
+ groupLines,
30
+ summarizeCounts,
31
+ } from '../probe.js';
32
+
33
+ export { ALL_BINS, LIVE_BINS, codeOf, indexStationsByRef, dependencyRef, resolveStationRef, topoSortStations, computeTerminal };
34
+
35
+ const LIVE_PRIORITY = ['output', 'doing', 'input', 'failed']; // most-advanced first
36
+
37
+ /**
38
+ * @typedef {import('../probe.js').StationDef} StationDef
39
+ */
40
+
41
+ /**
42
+ * @typedef {object} LineState
43
+ * @property {string} code - line code (e.g. 'VM')
44
+ * @property {string[]} stations - Station codes in dependency order
45
+ * @property {string} terminal - Sink station of the line
46
+ * @property {Object<string, Object<string, Map<string, string>|null>>} bins -
47
+ * bins[station][bin] is a Map of workpiece_id → path relative to the bin
48
+ * (e.g. 'hi-1', or 'invoices/hi-1__NT-...' under a sub-bin), or null if the
49
+ * bin directory does not exist.
50
+ */
51
+
52
+ /**
53
+ * @typedef {object} Position
54
+ * @property {string} line
55
+ * @property {string} station
56
+ * @property {string} bin
57
+ * @property {string} subpath - path relative to the bin
58
+ * @property {boolean} terminal - true if station is the line's terminal
59
+ * @property {boolean} [anomaly] - true when only present in done (not live)
60
+ */
61
+
62
+ /**
63
+ * Build per-line state from a probe `state` result.
64
+ * @param {{stations: StationDef[], bins: Object<string, Object<string, Object<string, string>|null>>}} state
65
+ * @returns {Object<string, LineState>} keyed by line code
66
+ */
67
+ export function buildLineState(state) {
68
+ const lines = {};
69
+ for (const [line_code, line] of Object.entries(groupLines(state.stations))) {
70
+ const bins = {};
71
+ for (const station of line.stations) {
72
+ bins[station] = {};
73
+ for (const bin of ALL_BINS) {
74
+ const listing = state.bins[station]?.[bin];
75
+ bins[station][bin] = listing ? new Map(Object.entries(listing)) : null;
76
+ }
77
+ }
78
+ lines[line_code] = { code: line_code, stations: line.stations, terminal: line.terminal, bins };
79
+ }
80
+ return lines;
81
+ }
82
+
83
+ /**
84
+ * A workpiece's live position: every line terminal → source, output → doing →
85
+ * input → failed; first match wins. Falls back to a done-only anomaly.
86
+ * @param {string} workpiece_id
87
+ * @param {Object<string, LineState>} lines
88
+ * @returns {Position|null}
89
+ */
90
+ export function resolvePosition(workpiece_id, lines) {
91
+ for (const line of Object.values(lines)) {
92
+ for (let i = line.stations.length - 1; i >= 0; i--) {
93
+ const station = line.stations[i];
94
+ for (const bin of LIVE_PRIORITY) {
95
+ const map = line.bins[station][bin];
96
+ if (map?.has(workpiece_id)) {
97
+ return { line: line.code, station, bin, subpath: map.get(workpiece_id), terminal: station === line.terminal };
98
+ }
99
+ }
100
+ }
101
+ }
102
+ for (const line of Object.values(lines)) {
103
+ for (let i = line.stations.length - 1; i >= 0; i--) {
104
+ const station = line.stations[i];
105
+ const map = line.bins[station].done;
106
+ if (map?.has(workpiece_id)) {
107
+ return { line: line.code, station, bin: 'done', subpath: map.get(workpiece_id), terminal: station === line.terminal, anomaly: true };
108
+ }
109
+ }
110
+ }
111
+ return null;
112
+ }
113
+
114
+ /**
115
+ * Every workpiece id containing `query`, each resolved to its position.
116
+ * @param {string} query
117
+ * @param {Object<string, LineState>} lines
118
+ * @returns {Map<string, Position>}
119
+ */
120
+ export function findWorkpieceMatches(query, lines) {
121
+ const ids = new Set();
122
+ for (const line of Object.values(lines)) {
123
+ for (const station of line.stations) {
124
+ for (const bin of ALL_BINS) {
125
+ const map = line.bins[station][bin];
126
+ if (!map) continue;
127
+ for (const id of map.keys()) if (id.includes(query)) ids.add(id);
128
+ }
129
+ }
130
+ }
131
+ const matches = new Map();
132
+ for (const id of ids) {
133
+ const pos = resolvePosition(id, lines);
134
+ if (pos) matches.set(id, pos);
135
+ }
136
+ return matches;
137
+ }
138
+
139
+ /**
140
+ * Resolve a user-typed id: an exact id wins, else a substring matching
141
+ * exactly one workpiece.
142
+ * @param {string} query
143
+ * @param {Object<string, LineState>} lines
144
+ * @returns {{id: string, pos: Position} | {ambiguous: string[]} | null}
145
+ */
146
+ export function resolveWorkpieceQuery(query, lines) {
147
+ const matches = findWorkpieceMatches(query, lines);
148
+ if (matches.has(query)) return { id: query, pos: matches.get(query) };
149
+ if (matches.size > 1) return { ambiguous: [...matches.keys()].sort() };
150
+ const [only] = matches;
151
+ return only ? { id: only[0], pos: only[1] } : null;
152
+ }
153
+
154
+ /**
155
+ * Every workpiece id in a bin spec: `STATION/BIN` or `STATION/BIN/SUBPATH`.
156
+ * @param {string} bin_spec
157
+ * @param {Object<string, LineState>} lines
158
+ * @returns {{ok: true, ids: string[]} | {ok: false, error: string}}
159
+ */
160
+ export function collectIdsForBin(bin_spec, lines) {
161
+ const m = bin_spec.match(/^([^/]+)\/(input|doing|output|done|failed)(?:\/(.+))?$/);
162
+ if (!m) {
163
+ return {
164
+ ok: false,
165
+ error: `Invalid bin spec "${bin_spec}". Use STATION/BIN or STATION/BIN/SUBPATH (e.g. AP3b/failed, HI3/output/invoices).`,
166
+ };
167
+ }
168
+ const [, station, bin, subpath_filter] = m;
169
+ const line_found = Object.values(lines).find((line) => line.stations.includes(station));
170
+ if (!line_found) return { ok: false, error: `Station "${station}" not found.` };
171
+ const map = line_found.bins[station][bin];
172
+ if (!map) return { ok: true, ids: [] };
173
+ if (!subpath_filter) return { ok: true, ids: [...map.keys()] };
174
+ const filter_prefix = subpath_filter.replace(/\/+$/, '') + '/';
175
+ const ids = [];
176
+ for (const [id, sub] of map) {
177
+ if (sub === subpath_filter || sub.startsWith(filter_prefix)) ids.push(id);
178
+ }
179
+ return { ok: true, ids };
180
+ }
181
+
182
+ /**
183
+ * Every workpiece id on a line, across all bins.
184
+ * @param {LineState} line
185
+ * @returns {string[]}
186
+ */
187
+ export function idsOnLine(line) {
188
+ const set = new Set();
189
+ for (const station of line.stations) {
190
+ for (const bin of ALL_BINS) {
191
+ const map = line.bins[station][bin];
192
+ if (map) for (const id of map.keys()) set.add(id);
193
+ }
194
+ }
195
+ return [...set];
196
+ }
197
+
198
+ /**
199
+ * Workpiece dir relative to temp/stations/ — the form the probe's `logs`
200
+ * query takes.
201
+ * @param {Position} pos
202
+ * @param {string} workpiece_id
203
+ */
204
+ export function workpieceRelDir(pos, workpiece_id) {
205
+ return `${pos.station}/${pos.bin}/${pos.subpath || workpiece_id}`;
206
+ }
207
+
208
+ /**
209
+ * Per-line totals from live bins only (`done` ignored).
210
+ * - in_flight = input + doing + output, excluding terminal output
211
+ * - stuck = failed across the line
212
+ * - finished = terminal-station output
213
+ * @param {LineState} line
214
+ */
215
+ export function summarizeLine(line) {
216
+ const counts = {};
217
+ for (const station of line.stations) {
218
+ counts[station] = {};
219
+ for (const bin of LIVE_BINS) {
220
+ const map = line.bins[station][bin];
221
+ counts[station][bin] = map ? map.size : null;
222
+ }
223
+ }
224
+ return summarizeCounts(line, counts);
225
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Watch-mode renderer for snapshot commands (e.g. `al-control lines status --watch`).
3
+ *
4
+ * Renders in place on the main screen. Each tick sends:
5
+ * `\x1b[2J` — erase the visible display
6
+ * `\x1b[3J` — erase the scrollback / "saved lines" (xterm DECSED 3)
7
+ * `\x1b[H` — home cursor to (1,1)
8
+ *
9
+ * The scrollback erase is the key bit: when a frame is taller than the
10
+ * terminal viewport, the part that overflows scrolls into scrollback. Without
11
+ * `\x1b[3J`, tops of past frames pile up across ticks. With it, scrollback is
12
+ * purged each tick so only the current frame is ever visible.
13
+ *
14
+ * Side effect: any pre-existing scrollback above the command is also wiped
15
+ * each tick. That's the trade-off for staying on the main screen instead of
16
+ * using the alternate-screen buffer (which would hide the last frame on exit).
17
+ *
18
+ * If stdout isn't a TTY (e.g. piped to a file), falls back to plain append.
19
+ *
20
+ * `render` is invoked once per tick and may be async. Errors are not caught —
21
+ * the loop is meant to halt on programmer mistakes, not silently spin.
22
+ */
23
+
24
+ export const DEFAULT_WATCH_INTERVAL_SECS = 1;
25
+
26
+ const CLEAR_AND_HOME = '\x1b[2J\x1b[3J\x1b[H';
27
+
28
+ export async function watchRender({ render, interval_secs = DEFAULT_WATCH_INTERVAL_SECS }) {
29
+ process.on('SIGINT', () => { process.stdout.write('\n'); process.exit(0); });
30
+ let first = true;
31
+ while (true) {
32
+ if (process.stdout.isTTY && !first) {
33
+ process.stdout.write(CLEAR_AND_HOME);
34
+ }
35
+ first = false;
36
+ await render();
37
+ console.log(`\nupdated ${new Date().toLocaleTimeString()} — refresh every ${interval_secs}s (Ctrl-C to stop)`);
38
+ await new Promise((r) => setTimeout(r, interval_secs * 1000));
39
+ }
40
+ }