@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,324 @@
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
+ * Pure logic copied from al-factory's line-state.js; the filesystem half lives
17
+ * in ../probe.js.
18
+ */
19
+
20
+ export const ALL_BINS = ['input', 'doing', 'output', 'failed', 'done'];
21
+ export const LIVE_BINS = ['input', 'doing', 'output', 'failed'];
22
+ const LIVE_PRIORITY = ['output', 'doing', 'input', 'failed']; // most-advanced first
23
+
24
+ /**
25
+ * @typedef {{line: string|null, data: object}} StationDef
26
+ */
27
+
28
+ /**
29
+ * @typedef {object} LineState
30
+ * @property {string} code - line code (e.g. 'VM')
31
+ * @property {string[]} stations - Station codes in dependency order
32
+ * @property {string} terminal - Sink station of the line
33
+ * @property {Object<string, Object<string, Map<string, string>|null>>} bins -
34
+ * bins[station][bin] is a Map of workpiece_id → path relative to the bin
35
+ * (e.g. 'hi-1', or 'invoices/hi-1__NT-...' under a sub-bin), or null if the
36
+ * bin directory does not exist.
37
+ */
38
+
39
+ /**
40
+ * @typedef {object} Position
41
+ * @property {string} line
42
+ * @property {string} station
43
+ * @property {string} bin
44
+ * @property {string} subpath - path relative to the bin
45
+ * @property {boolean} terminal - true if station is the line's terminal
46
+ * @property {boolean} [anomaly] - true when only present in done (not live)
47
+ */
48
+
49
+ /** Stable code for a station def — short_code, falling back to id. */
50
+ export const codeOf = (s) => s.data.short_code || s.data.id;
51
+
52
+ /**
53
+ * Index station defs by every reference form they answer to, so a dependency
54
+ * edge written as short_code or id finds its peer.
55
+ * @param {StationDef[]} members
56
+ * @returns {Map<string, StationDef>}
57
+ */
58
+ export function indexStationsByRef(members) {
59
+ const by_ref = new Map();
60
+ for (const s of members) {
61
+ by_ref.set(codeOf(s), s);
62
+ if (s.data.id) by_ref.set(s.data.id, s);
63
+ if (s.data.short_code) by_ref.set(s.data.short_code, s);
64
+ }
65
+ return by_ref;
66
+ }
67
+
68
+ /**
69
+ * Normalise one `dependencies` entry (bare id string, or object with
70
+ * short_code/id) to a reference string.
71
+ */
72
+ export function dependencyRef(dep) {
73
+ return typeof dep === 'string' ? dep : dep?.short_code || dep?.id;
74
+ }
75
+
76
+ /**
77
+ * Resolve a station reference (short_code or orchestrator id) to its display
78
+ * code; unchanged when the peer isn't among `stations`.
79
+ * @param {string} ref
80
+ * @param {Map<string, StationDef>|StationDef[]} stations
81
+ */
82
+ export function resolveStationRef(ref, stations) {
83
+ const by_ref = stations instanceof Map ? stations : indexStationsByRef(stations);
84
+ const match = by_ref.get(ref);
85
+ return match ? codeOf(match) : ref;
86
+ }
87
+
88
+ /**
89
+ * Topologically sort one line's station defs by `dependencies`, with an
90
+ * alphabetical pre-sort for stable sibling order.
91
+ * @param {StationDef[]} members
92
+ * @returns {StationDef[]}
93
+ */
94
+ export function topoSortStations(members) {
95
+ const by_ref = indexStationsByRef(members);
96
+ const depsOf = (s) => (s.data.dependencies || [])
97
+ .map(dependencyRef)
98
+ .map((ref) => by_ref.get(ref))
99
+ .filter(Boolean);
100
+
101
+ const sorted = [...members].sort((a, b) => codeOf(a).localeCompare(codeOf(b)));
102
+ const visited = new Set();
103
+ const result = [];
104
+ const visit = (s) => {
105
+ const code = codeOf(s);
106
+ if (visited.has(code)) return;
107
+ visited.add(code);
108
+ for (const dep of depsOf(s)) visit(dep);
109
+ result.push(s);
110
+ };
111
+ for (const s of sorted) visit(s);
112
+ return result;
113
+ }
114
+
115
+ /**
116
+ * Terminal = station no in-line peer depends on. With several sinks, the one
117
+ * latest in topo order.
118
+ */
119
+ function computeTerminal(ordered) {
120
+ if (ordered.length === 0) return '';
121
+ const by_ref = indexStationsByRef(ordered);
122
+ const depended_on = new Set();
123
+ for (const s of ordered) {
124
+ for (const d of s.data.dependencies || []) {
125
+ const ref = dependencyRef(d);
126
+ if (by_ref.has(ref)) depended_on.add(resolveStationRef(ref, by_ref));
127
+ }
128
+ }
129
+ for (let i = ordered.length - 1; i >= 0; i--) {
130
+ const c = codeOf(ordered[i]);
131
+ if (!depended_on.has(c)) return c;
132
+ }
133
+ return codeOf(ordered[ordered.length - 1]);
134
+ }
135
+
136
+ /**
137
+ * Build per-line state from a probe `state` result.
138
+ * @param {{stations: StationDef[], bins: Object<string, Object<string, Object<string, string>|null>>}} state
139
+ * @returns {Object<string, LineState>} keyed by line code
140
+ */
141
+ export function buildLineState(state) {
142
+ const grouped = new Map();
143
+ for (const def of state.stations) {
144
+ if (!def.line) continue;
145
+ if (!grouped.has(def.line)) grouped.set(def.line, []);
146
+ grouped.get(def.line).push(def);
147
+ }
148
+
149
+ const lines = {};
150
+ for (const [line_code, members] of grouped) {
151
+ const ordered = topoSortStations(members);
152
+ const station_codes = ordered.map(codeOf);
153
+ const bins = {};
154
+ for (const station of station_codes) {
155
+ bins[station] = {};
156
+ for (const bin of ALL_BINS) {
157
+ const listing = state.bins[station]?.[bin];
158
+ bins[station][bin] = listing ? new Map(Object.entries(listing)) : null;
159
+ }
160
+ }
161
+ lines[line_code] = { code: line_code, stations: station_codes, terminal: computeTerminal(ordered), bins };
162
+ }
163
+ return lines;
164
+ }
165
+
166
+ /**
167
+ * A workpiece's live position: every line terminal → source, output → doing →
168
+ * input → failed; first match wins. Falls back to a done-only anomaly.
169
+ * @param {string} workpiece_id
170
+ * @param {Object<string, LineState>} lines
171
+ * @returns {Position|null}
172
+ */
173
+ export function resolvePosition(workpiece_id, lines) {
174
+ for (const line of Object.values(lines)) {
175
+ for (let i = line.stations.length - 1; i >= 0; i--) {
176
+ const station = line.stations[i];
177
+ for (const bin of LIVE_PRIORITY) {
178
+ const map = line.bins[station][bin];
179
+ if (map?.has(workpiece_id)) {
180
+ return { line: line.code, station, bin, subpath: map.get(workpiece_id), terminal: station === line.terminal };
181
+ }
182
+ }
183
+ }
184
+ }
185
+ for (const line of Object.values(lines)) {
186
+ for (let i = line.stations.length - 1; i >= 0; i--) {
187
+ const station = line.stations[i];
188
+ const map = line.bins[station].done;
189
+ if (map?.has(workpiece_id)) {
190
+ return { line: line.code, station, bin: 'done', subpath: map.get(workpiece_id), terminal: station === line.terminal, anomaly: true };
191
+ }
192
+ }
193
+ }
194
+ return null;
195
+ }
196
+
197
+ /**
198
+ * Every workpiece id containing `query`, each resolved to its position.
199
+ * @param {string} query
200
+ * @param {Object<string, LineState>} lines
201
+ * @returns {Map<string, Position>}
202
+ */
203
+ export function findWorkpieceMatches(query, lines) {
204
+ const ids = new Set();
205
+ for (const line of Object.values(lines)) {
206
+ for (const station of line.stations) {
207
+ for (const bin of ALL_BINS) {
208
+ const map = line.bins[station][bin];
209
+ if (!map) continue;
210
+ for (const id of map.keys()) if (id.includes(query)) ids.add(id);
211
+ }
212
+ }
213
+ }
214
+ const matches = new Map();
215
+ for (const id of ids) {
216
+ const pos = resolvePosition(id, lines);
217
+ if (pos) matches.set(id, pos);
218
+ }
219
+ return matches;
220
+ }
221
+
222
+ /**
223
+ * Resolve a user-typed id: an exact id wins, else a substring matching
224
+ * exactly one workpiece.
225
+ * @param {string} query
226
+ * @param {Object<string, LineState>} lines
227
+ * @returns {{id: string, pos: Position} | {ambiguous: string[]} | null}
228
+ */
229
+ export function resolveWorkpieceQuery(query, lines) {
230
+ const matches = findWorkpieceMatches(query, lines);
231
+ if (matches.has(query)) return { id: query, pos: matches.get(query) };
232
+ if (matches.size > 1) return { ambiguous: [...matches.keys()].sort() };
233
+ const [only] = matches;
234
+ return only ? { id: only[0], pos: only[1] } : null;
235
+ }
236
+
237
+ /**
238
+ * Every workpiece id in a bin spec: `STATION/BIN` or `STATION/BIN/SUBPATH`.
239
+ * @param {string} bin_spec
240
+ * @param {Object<string, LineState>} lines
241
+ * @returns {{ok: true, ids: string[]} | {ok: false, error: string}}
242
+ */
243
+ export function collectIdsForBin(bin_spec, lines) {
244
+ const m = bin_spec.match(/^([^/]+)\/(input|doing|output|done|failed)(?:\/(.+))?$/);
245
+ if (!m) {
246
+ return {
247
+ ok: false,
248
+ error: `Invalid bin spec "${bin_spec}". Use STATION/BIN or STATION/BIN/SUBPATH (e.g. AP3b/failed, HI3/output/invoices).`,
249
+ };
250
+ }
251
+ const [, station, bin, subpath_filter] = m;
252
+ const line_found = Object.values(lines).find((line) => line.stations.includes(station));
253
+ if (!line_found) return { ok: false, error: `Station "${station}" not found.` };
254
+ const map = line_found.bins[station][bin];
255
+ if (!map) return { ok: true, ids: [] };
256
+ if (!subpath_filter) return { ok: true, ids: [...map.keys()] };
257
+ const filter_prefix = subpath_filter.replace(/\/+$/, '') + '/';
258
+ const ids = [];
259
+ for (const [id, sub] of map) {
260
+ if (sub === subpath_filter || sub.startsWith(filter_prefix)) ids.push(id);
261
+ }
262
+ return { ok: true, ids };
263
+ }
264
+
265
+ /**
266
+ * Every workpiece id on a line, across all bins.
267
+ * @param {LineState} line
268
+ * @returns {string[]}
269
+ */
270
+ export function idsOnLine(line) {
271
+ const set = new Set();
272
+ for (const station of line.stations) {
273
+ for (const bin of ALL_BINS) {
274
+ const map = line.bins[station][bin];
275
+ if (map) for (const id of map.keys()) set.add(id);
276
+ }
277
+ }
278
+ return [...set];
279
+ }
280
+
281
+ /**
282
+ * Workpiece dir relative to temp/stations/ — the form the probe's `logs`
283
+ * query takes.
284
+ * @param {Position} pos
285
+ * @param {string} workpiece_id
286
+ */
287
+ export function workpieceRelDir(pos, workpiece_id) {
288
+ return `${pos.station}/${pos.bin}/${pos.subpath || workpiece_id}`;
289
+ }
290
+
291
+ /**
292
+ * Per-line totals from live bins only (`done` ignored).
293
+ * - in_flight = input + doing + output, excluding terminal output
294
+ * - stuck = failed across the line
295
+ * - finished = terminal-station output
296
+ * @param {LineState} line
297
+ */
298
+ export function summarizeLine(line) {
299
+ let in_flight = 0;
300
+ let stuck = 0;
301
+ let finished = 0;
302
+ const stuck_locations = [];
303
+ let biggest_flow = { count: 0, at: null };
304
+
305
+ for (const station of line.stations) {
306
+ const bins = line.bins[station];
307
+ const is_terminal = station === line.terminal;
308
+ for (const bin of LIVE_BINS) {
309
+ const map = bins[bin];
310
+ if (!map) continue;
311
+ const n = map.size;
312
+ if (bin === 'failed') {
313
+ stuck += n;
314
+ if (n > 0) stuck_locations.push(`${n} at ${station}/failed`);
315
+ } else if (is_terminal && bin === 'output') {
316
+ finished += n;
317
+ } else {
318
+ in_flight += n;
319
+ if (n > biggest_flow.count) biggest_flow = { count: n, at: `${station}/${bin}` };
320
+ }
321
+ }
322
+ }
323
+ return { in_flight, stuck, finished, stuck_locations, biggest_flow };
324
+ }
@@ -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
+ }