@giamme/forge 0.8.0 → 0.9.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +106 -1766
  3. package/SKILL.md +3 -2
  4. package/VERSION +1 -1
  5. package/docs/README.md +26 -0
  6. package/docs/add-ons.md +33 -0
  7. package/docs/artifacts.md +69 -0
  8. package/docs/commands.md +315 -0
  9. package/docs/dataset.md +20 -0
  10. package/docs/decomposed-runs.md +268 -0
  11. package/docs/development.md +95 -0
  12. package/docs/forge-ui.md +92 -0
  13. package/docs/fractal.md +181 -0
  14. package/docs/install.md +160 -0
  15. package/docs/memory.md +156 -0
  16. package/docs/models.md +118 -0
  17. package/docs/reviews.md +231 -0
  18. package/docs/safety.md +55 -0
  19. package/docs/troubleshooting.md +54 -0
  20. package/forge +5 -0
  21. package/package.json +3 -2
  22. package/references/decompose.md +1 -1
  23. package/references/fractal.md +1 -1
  24. package/references/harnesses.md +18 -6
  25. package/references/solo.md +1 -1
  26. package/scripts/forge-dispatch.sh +7 -1
  27. package/scripts/forge-runs.py +121 -0
  28. package/scripts/forge-runtime.py +59 -6
  29. package/scripts/forge-version.py +54 -12
  30. package/scripts/forge_dataset/harvest.py +1 -1
  31. package/scripts/forge_dataset/importer.py +1 -1
  32. package/scripts/forge_fractal/__init__.py +2 -1
  33. package/ui/README.md +80 -20
  34. package/ui/THIRD_PARTY_NOTICES.md +108 -0
  35. package/ui/docs/api.md +99 -17
  36. package/ui/forge_ui/export.py +16 -6
  37. package/ui/forge_ui/fractal.py +1 -2
  38. package/ui/forge_ui/reader.py +95 -7
  39. package/ui/forge_ui/server.py +10 -1
  40. package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
  41. package/ui/forge_ui/static/app.js +279 -124
  42. package/ui/forge_ui/static/cloud3d.js +3 -2
  43. package/ui/forge_ui/static/cloudui.js +8 -5
  44. package/ui/forge_ui/static/convo.js +295 -0
  45. package/ui/forge_ui/static/datatable.js +232 -0
  46. package/ui/forge_ui/static/diffview.js +29 -10
  47. package/ui/forge_ui/static/findings3d.js +134 -0
  48. package/ui/forge_ui/static/graph.js +3 -3
  49. package/ui/forge_ui/static/icons.js +94 -0
  50. package/ui/forge_ui/static/index.html +25 -13
  51. package/ui/forge_ui/static/iso.js +70 -19
  52. package/ui/forge_ui/static/leaderboard.js +44 -48
  53. package/ui/forge_ui/static/panel.js +68 -32
  54. package/ui/forge_ui/static/runmodel.js +17 -1
  55. package/ui/forge_ui/static/scene3d.js +518 -43
  56. package/ui/forge_ui/static/search.js +2 -1
  57. package/ui/forge_ui/static/shell.js +137 -0
  58. package/ui/forge_ui/static/stagectl.js +150 -0
  59. package/ui/forge_ui/static/style.css +924 -469
  60. package/ui/forge_ui/static/theme.js +25 -0
  61. package/ui/forge_ui/transcripts.py +608 -0
@@ -9,6 +9,8 @@
9
9
  All text from the API is drawn on canvases or set with textContent, never parsed as HTML. */
10
10
  import { createEngine } from './iso.js';
11
11
  import { createCloud } from './cloud3d.js';
12
+ import { createBeacons } from './findings3d.js';
13
+ import { createControls } from './stagectl.js';
12
14
 
13
15
  const RM = window.ForgeRunModel;
14
16
  const list = v => Array.isArray(v) ? v : [];
@@ -17,7 +19,6 @@ const clamp = v => Math.max(0, Math.min(1, v));
17
19
  const segT = (v, a, b) => clamp((v - a) / (b - a));
18
20
  const ez = t => t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
19
21
  const back = t => t <= 0 ? 0 : t >= 1 ? 1 : 1 + 2.2 * Math.pow(t - 1, 3) + 1.2 * Math.pow(t - 1, 2);
20
- const SEV_STATUS = { P0: 'fail', P1: 'conflict', P2: 'timeout', P3: 'unknown' };
21
22
  const MODEL_PALETTE = ['#4e9af1', '#f08c3a', '#6cc070', '#c46fd8', '#e0c341', '#4fc2c0', '#e06a8c', '#9a8cff', '#a3b84a', '#d98f6b'];
22
23
  const KIND_H = { dwarf: 24, qa: 13, shadow: 12 };
23
24
 
@@ -31,15 +32,19 @@ export async function createStage(host, cb = {}) {
31
32
 
32
33
  const st = { view: null, mode: 'plan', run: null, runs: [], filter: {}, atlasOpts: {}, sel: null, hover: null, replay: null,
33
34
  overlay: { height: 'none', color: 'status', critical: false, arcs: true, ghosts: true },
35
+ // what the stage draws, each switchable (the toolbar's Layers): see applyLayers
36
+ // route: a chosen task's lineage stays lit, what it waits on and what waits on it (I)
37
+ layers: { cables: true, beads: true, lamps: true, roof: true, labels: true, beacons: true, finish: true, districts: true, halos: true, route: true },
38
+ findings: null,
34
39
  structure: '', execSig: '', atlasSig: '', rebuilds: 0, fade: null, plan: null, exec: null, atlas: null, crit: null, now: Date.now() / 1000 };
35
40
  let world = null; // the group of the current view
36
- let EL = new Map(), PICK = [], FLOWS = [], PROG = new Map();
41
+ let EL = new Map(), PICK = [], FLOWS = [], PROG = new Map(), DISTRICTS = [], EXEC_BY = null;
37
42
  const nowSec = () => st.run ? RM.liveNow(st.run, Date.now() / 1000) : Date.now() / 1000;
38
43
 
39
44
  /* ---------- elements: a block, a part or a lane item, each with its own materials ---------- */
40
45
  function newEl(id, kind, g, n, data) {
41
46
  const el = { id, kind, g, n, data, status: 'PENDING', col: E.statusColor('pending'), root: new THREE.Group(), cover: new THREE.Group(),
42
- M: E.mats(), cur: { vis: 1, sel: 0, hov: 0, lit: 0, op: 1, opB: 1, tint: 0, halo: 0, hk: 1 }, tgt: { vis: 1, sel: 0, hov: 0, lit: 0, op: 1, opB: 1, tint: 0, halo: 0, hk: 1 },
47
+ M: E.mats(), cur: { vis: 1, sel: 0, hov: 0, lit: 0, op: 1, opB: 1, tint: 0, halo: 0, hk: 1, route: 0 }, tgt: { vis: 1, sel: 0, hov: 0, lit: 0, op: 1, opB: 1, tint: 0, halo: 0, hk: 1, route: 0 },
43
48
  texts: [], sig: '', pulse: false, offset: new THREE.Vector3(), fadeK: 1 };
44
49
  el.root.add(el.cover); g.add(el.root); EL.set(id, el);
45
50
  return el;
@@ -51,7 +56,7 @@ export async function createStage(host, cb = {}) {
51
56
  E.roofPlate(x + 1, y + 1, z + h + .08, w - 2, d - 2, r - 1, el.tintMat, el.cover);
52
57
  el.haloMat = E.softMat(0xffffff, true);
53
58
  const hm = E.noRay(new THREE.Mesh(new THREE.PlaneGeometry((w + 80) * TS, (d + 80) * TS), el.haloMat));
54
- hm.rotation.x = -Math.PI / 2; hm.position.copy(W(x + w / 2, y + d / 2, z + .5)); el.root.add(hm);
59
+ hm.rotation.x = -Math.PI / 2; hm.position.copy(W(x + w / 2, y + d / 2, z + .5)); el.root.add(hm); el.haloMesh = hm;
55
60
  return m;
56
61
  }
57
62
  // a row of status lamps on a roof: one geometry, a colour per lamp
@@ -66,15 +71,15 @@ export async function createStage(host, cb = {}) {
66
71
  geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
67
72
  geo.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
68
73
  const mat = new THREE.MeshBasicMaterial({ vertexColors: true, toneMapped: false, transparent: true, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -12 });
69
- el.cover.add(E.noRay(new THREE.Mesh(geo, mat))); el.M.labs.push(mat);
70
- el.leds = { geo, n };
74
+ const mesh = E.noRay(new THREE.Mesh(geo, mat)); el.cover.add(mesh); el.M.labs.push(mat);
75
+ el.leds = { geo, n, mesh };
71
76
  }
72
77
  function setLeds(el, statuses) {
73
78
  if (!el.leds) return;
74
79
  const a = el.leds.geo.attributes.color;
75
80
  for (let i = 0; i < el.leds.n; i++) {
76
81
  const s = statuses[i];
77
- const c = s ? E.statusColor(s) : E.DETAIL;
82
+ const c = !s ? E.DETAIL : s.isColor ? s : E.statusColor(s); // a state, or a colour of its own (a severity)
78
83
  for (let k = 0; k < 6; k++) a.setXYZ(i * 6 + k, c.r, c.g, c.b);
79
84
  }
80
85
  a.needsUpdate = true;
@@ -88,11 +93,11 @@ export async function createStage(host, cb = {}) {
88
93
  function baseTargets(el) {
89
94
  const T = el.tgt, s = el.status, light = E.theme === 'light';
90
95
  T.lit = PLAIN.has(s) ? 0 : .32;
91
- T.tint = PLAIN.has(s) ? 0 : light ? .09 : .1;
96
+ T.tint = PLAIN.has(s) ? 0 : light ? .34 : .1; // a pale panel shows a lit section by its colour: no glow to carry it
92
97
  T.vis = 1; T.op = 1; T.opB = 1;
93
98
  if (s === 'PENDING' || s === 'NONE') { T.opB = .55; T.op = .8; }
94
99
  // parts are small: their status has to read from the colour of the whole piece
95
- if (el.kind === 'part' && !PLAIN.has(s)) { T.tint = light ? .28 : .32; T.lit = .55; }
100
+ if (el.kind === 'part' && !PLAIN.has(s)) { T.tint = light ? .5 : .32; T.lit = .55; }
96
101
  }
97
102
  // a label plane on the roof or the front, redrawn only when what it shows changes
98
103
  function label(el, w, h, P, draw, where, at) {
@@ -111,7 +116,7 @@ export async function createStage(host, cb = {}) {
111
116
  return w;
112
117
  }
113
118
  function display(el, text, { w, x, y, z }) {
114
- return label(el, w, 16, 4, (cx, s, C) => {
119
+ return el.disp = label(el, w, 16, 4, (cx, s, C) => {
115
120
  cx.fillStyle = C.glassOff; cx.strokeStyle = el.cur.sel > .5 ? C.acc : C.detail; cx.lineWidth = Math.max(1, .5 * s);
116
121
  E.rr(cx, s, .5, .5, w - 1, 15, 3); cx.fill(); cx.stroke();
117
122
  cx.fillStyle = E.statusHex(el.status); cx.beginPath(); cx.arc(7 * s, 8 * s, 2.6 * s, 0, 7); cx.fill();
@@ -135,7 +140,7 @@ export async function createStage(host, cb = {}) {
135
140
  E.font(x, f, s); x.letterSpacing = `${(f * .14 * s).toFixed(1)}px`; x.fillStyle = dd.warn ? E.statusHex('fail') : C.ink;
136
141
  x.fillText(name, 0, f * 1.05 * s, tw * s); x.letterSpacing = '0px';
137
142
  }, plateG);
138
- E.roofAt(t, dd.x + 18, dd.y + 12, 0);
143
+ E.roofAt(t, dd.x + 18, dd.y + 12, 0); DISTRICTS.push(t);
139
144
  }
140
145
  return plateG;
141
146
  }
@@ -190,7 +195,8 @@ export async function createStage(host, cb = {}) {
190
195
  display(el, e => runInfo(e.data.run), { w: w - 24, x: x + 12, y: y + d, z: h - 8 });
191
196
  halo(el);
192
197
  }
193
- E.frameBox([-34, -34, -16, A.px + 34, A.py + 34, 80], st.atlasFramed ? 700 : 1400, 30);
198
+ st.atlasBox = [-34, -34, -16, A.px + 34, A.py + 34, 80];
199
+ E.frameBox(st.atlasBox, st.atlasFramed ? 700 : 1400, 30);
194
200
  st.atlasFramed = true;
195
201
  }
196
202
  function updateAtlas() {
@@ -240,11 +246,11 @@ export async function createStage(host, cb = {}) {
240
246
  let bx = 2;
241
247
  const f = tk.findings;
242
248
  if (f && (f.confirmed || f.plausible)) {
243
- for (const sev of ['P0', 'P1', 'P2', 'P3']) if (f[sev]) bx += chip(cx, s, C, bx, 64, `${sev}×${f[sev]}`, E.statusHex(SEV_STATUS[sev])) + 3;
249
+ for (const sev of ['P0', 'P1', 'P2', 'P3']) if (f[sev]) bx += chip(cx, s, C, bx, 64, `${sev}×${f[sev]}`, E.sevHex(sev)) + 3;
244
250
  if (f.unlabelled) bx += chip(cx, s, C, bx, 64, `?×${f.unlabelled}`, E.statusHex('unknown')) + 3;
245
251
  }
246
252
  if (tk.gate && tk.gate.decision === 'accept') bx += chip(cx, s, C, bx, 64, 'gate ok', E.statusHex('pass')) + 3;
247
- if (st.overlay.arcs && list(tk.shadow).some(x => x.agreement === 'disagree')) bx += chip(cx, s, C, bx, 64, '⚑ shadow', E.statusHex('conflict')) + 3;
253
+ if (st.overlay.arcs && list(tk.shadow).some(x => x.agreement === 'disagree')) bx += chip(cx, s, C, bx, 64, 'shadow ≠', E.statusHex('conflict')) + 3;
248
254
  if (tk.accepted_by) chip(cx, s, C, bx, 64, 'accepted', E.statusHex('nochanges'));
249
255
  }, 'cover', { x: x + 12, y: y + 8, z: h });
250
256
  const tries = Math.max(1, list(t.attempts).length);
@@ -285,7 +291,7 @@ export async function createStage(host, cb = {}) {
285
291
  if (fi.status === 'NONE') { cx.setLineDash([2.4 * s, 1.8 * s]); cx.strokeStyle = C.line; cx.lineWidth = Math.max(1, .3 * s); E.rr(cx, s, .5, .5, w - 25, d - 29, 5); cx.stroke(); cx.setLineDash([]); }
286
292
  E.font(cx, 15, s, 600); cx.fillStyle = sel ? C.accHi : C.inkHi; cx.fillText(fi.title || '', 4 * s, 17 * s);
287
293
  const sub = E.fit(fi.sub || '', w - 34, 22, 9.5, 2, 5); if (sub) E.lines(cx, s, sub, 4, 23, C.ink, w - 34, 400);
288
- if (fi.findings) { let bx = 4; for (const sev of ['P0', 'P1', 'P2', 'P3']) if (fi.findings[sev]) bx += chip(cx, s, C, bx, 46, `${sev}×${fi.findings[sev]}`, E.statusHex(SEV_STATUS[sev])) + 3;
294
+ if (fi.findings) { let bx = 4; for (const sev of ['P0', 'P1', 'P2', 'P3']) if (fi.findings[sev]) bx += chip(cx, s, C, bx, 46, `${sev}×${fi.findings[sev]}`, E.sevHex(sev)) + 3;
289
295
  if (fi.findings.plausible) chip(cx, s, C, bx, 46, `~${fi.findings.plausible}`, E.statusHex('pending'), { fill: false }); }
290
296
  }, 'cover', { x: x + 12, y: y + 8, z: h });
291
297
  if (m.id === 'f:final') { leds(el, 4, x + 18, y + d - 13, h + .16, { step: 11, s: 3 }); }
@@ -301,7 +307,7 @@ export async function createStage(host, cb = {}) {
301
307
  return info.status || 'PENDING';
302
308
  }
303
309
  function finishLeds(el) {
304
- if (el.id === 'f:final') { const f = finishInfo(el.id).findings || {}; setLeds(el, ['P0', 'P1', 'P2', 'P3'].map(s => f[s] ? SEV_STATUS[s].toUpperCase() : null)); }
310
+ if (el.id === 'f:final') { const f = finishInfo(el.id).findings || {}; setLeds(el, ['P0', 'P1', 'P2', 'P3'].map(s => f[s] ? E.sevColor(s) : null)); }
305
311
  else if (el.id === 'f:integration') setLeds(el, list(st.run.tasks).map(t => taskStatus(t) === 'MERGED' ? 'MERGED' : null));
306
312
  else setLeds(el, list(st.run.verification && st.run.verification.runs).map(r => r.exit === 0 ? 'PASS' : 'FAIL'));
307
313
  }
@@ -361,7 +367,7 @@ export async function createStage(host, cb = {}) {
361
367
  const sp = E.fit(pt.spec || '—', cw - 2 * pad, ch - f1 * 1.6 - pad * 2, 6, 2, 2.5);
362
368
  if (sp) E.lines(cx, s, sp, pad, pad + f1 * 1.35, Cc.ink, cw - 2 * pad, 400);
363
369
  const fs = pt.exec && pt.exec.findings || pt.shadow && pt.shadow.findings;
364
- if (fs && fs.worst) chip(cx, s, Cc, pad, ch - pad - 6 * 1.6, `${fs.worst}×${fs[fs.worst]}`, E.statusHex(SEV_STATUS[fs.worst]), { f: Math.min(5, ch * .14) });
370
+ if (fs && fs.worst) chip(cx, s, Cc, pad, ch - pad - 6 * 1.6, `${fs.worst}×${fs[fs.worst]}`, E.sevHex(fs.worst), { f: Math.min(5, ch * .14) });
365
371
  }, 'cover', { x: zx, y: n.y, z: ph });
366
372
  el.kids.push(k);
367
373
  });
@@ -386,6 +392,7 @@ export async function createStage(host, cb = {}) {
386
392
  el.innerFlows.push(f);
387
393
  }
388
394
  syncFractal(el);
395
+ applyLayers();
389
396
  }
390
397
  /* ---------- Fractal: an opened task's child tasks, a small tree floating over it ---------- */
391
398
  const FRACTAL = new Map(); // task id → TaskFractal
@@ -499,7 +506,7 @@ export async function createStage(host, cb = {}) {
499
506
  E.rr(cx, s, 1 + sx, by, Math.min(sw, lw - 2 - sx), 8, 2); cx.fill(); cx.globalAlpha = 1;
500
507
  if (sw > 26) { E.font(cx, 5.5, s, 600); cx.fillStyle = C.bg; cx.fillText(sg.role === 'qa' ? 'QA' : 'DWARF', (3 + sx) * s, (by + 6) * s); }
501
508
  }
502
- if (item.findings && item.findings.worst) chip(cx, s, C, 1, by - 12, `${item.findings.worst}×${item.findings[item.findings.worst]}`, E.statusHex(SEV_STATUS[item.findings.worst]), { f: 5 });
509
+ if (item.findings && item.findings.worst) chip(cx, s, C, 1, by - 12, `${item.findings.worst}×${item.findings[item.findings.worst]}`, E.sevHex(item.findings.worst), { f: 5 });
503
510
  if (item.ghost) { cx.setLineDash([2 * s, 1.6 * s]); cx.strokeStyle = C.line; cx.lineWidth = Math.max(1, .3 * s); E.rr(cx, s, .5, .5, lw - 1, it.d - 15, 3); cx.stroke(); cx.setLineDash([]); }
504
511
  }, 'cover', { x: it.x + 5, y: it.y + 6, z: h });
505
512
  if (it.w > 70) display(el, attemptLine, { w: Math.min(it.w - 10, 150), x: it.x + 5, y: it.y + it.d, z: h - 6 });
@@ -507,12 +514,181 @@ export async function createStage(host, cb = {}) {
507
514
  return el;
508
515
  }
509
516
 
517
+ /* ---------- finding beacons: each review finding as a mark over the block it is about ---------- */
518
+ const BEACONS = createBeacons(E, { reduce });
519
+ E.scene.add(BEACONS.group);
520
+ const SEV_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
521
+ const byRank = (a, b) => (SEV_RANK[a.f.severity] ?? 4) - (SEV_RANK[b.f.severity] ?? 4) || (a.f.status === 'CONFIRMED' ? 0 : 1) - (b.f.status === 'CONFIRMED' ? 0 : 1);
522
+ const planOff = el => [el.offset.x / TS, el.offset.z / TS];
523
+ // the i-th of n marks, in a grid of up to five a row centred on (cx, cy)
524
+ function grid(i, n, cx, cy, step) {
525
+ const cols = Math.min(n, 5), rows = Math.ceil(n / cols), c = i % cols, r = Math.floor(i / cols);
526
+ return [cx + (c - (cols - 1) / 2) * step, cy + (r - (rows - 1) / 2) * step];
527
+ }
528
+ // the execution view's block holding an execution (an attempt holds its dwarf's and its review's)
529
+ function execEl(id) {
530
+ if (!EXEC_BY) {
531
+ EXEC_BY = new Map();
532
+ for (const el of EL.values()) { const it = el.data && el.data.item; if (!it || it.ghost) continue;
533
+ EXEC_BY.set(it.id, el); for (const g of list(it.segs)) if (g.id) EXEC_BY.set(g.id, el);
534
+ if (it.kind === 'review' && it.review) EXEC_BY.set('final:' + it.review, el); }
535
+ }
536
+ return EXEC_BY.get(id) || null;
537
+ }
538
+ // a mark's foot on a block's roof: [x, y, z, scale]
539
+ function over(el, i, n, step, scale) {
540
+ // lying flat (top-down) the marks keep to the roof's right edge, off its text
541
+ const cx = BEACONS.flat ? el.n.x + el.n.w - Math.min(el.n.w / 2, step * .7) : el.n.x + el.n.w / 2;
542
+ const [ox, oy] = planOff(el), [x, y] = grid(i, n, cx + ox, el.n.y + el.n.d / 2 + oy, step);
543
+ return [x, y, el.n.h, scale];
544
+ }
545
+ // where a task's finding stands: over the roof (the latest attempt's, while the task is shut), on the review
546
+ // that found it (while it is open), on its attempt (execution); null hides it
547
+ function anchorTask(tid, r, ri, rn, j, jn, shown) {
548
+ if (!st.layers.beacons || st.fade || !shown(r)) return null;
549
+ const u = BEACONS.unit * 1.15; // marks a little more than their width apart
550
+ if (st.mode === 'exec') { const el = execEl(r.execution); return el && el.cur.vis > .5 ? over(el, j, jn, u * .7, .7) : null; }
551
+ const el = EL.get('t:' + tid); if (!el || el.cur.vis < .5) return null;
552
+ const pr = PROG.get(el.id), p = pr ? pr.p : 0;
553
+ if (p > .5) {
554
+ const k = (el.kids || []).find(k => k.data.part.exec && k.data.part.exec.id === r.execution);
555
+ if (!k || (k.q || 0) < .6) return null;
556
+ const [ox, oy] = planOff(el), [x, y] = grid(j, jn, k.n.x + k.n.w / 2 + ox, k.n.y + k.n.d / 2 + oy, u * .5);
557
+ return [x, y, k.n.h * (k.q || 1), .5];
558
+ }
559
+ if (ri < 0 || p > .15) return null;
560
+ const a = over(el, ri, rn, u, 1); a[2] = el.n.h * el.cur.hk + el.cover.position.y / TS;
561
+ return a;
562
+ }
563
+ function anchorFinal(name, i, n, shown, review) {
564
+ if (!st.layers.beacons || !st.layers.finish || st.fade || !shown(review)) return null;
565
+ const el = st.mode === 'exec' ? execEl('final:' + name) : EL.get('f:final');
566
+ const s = st.mode === 'exec' ? .7 : .9;
567
+ return el && el.cur.vis > .5 ? over(el, i, n, BEACONS.unit * 1.15 * s, s) : null;
568
+ }
569
+ function syncBeacons() {
570
+ const F = st.findings, run = st.run;
571
+ if (st.view !== 'run' || !run || !F || F.run !== run.key) { BEACONS.set([]); return; }
572
+ // in a replay a finding shows once its review has finished
573
+ const ends = new Map();
574
+ for (const t of list(run.tasks)) for (const e of list(t.executions)) ends.set(e.id, num(e.ended));
575
+ for (const fr of list(run.final_reviews)) if (fr.execution) ends.set(fr.execution, num(fr.ended));
576
+ const shown = r => st.replay === null || (r.execution && ends.get(r.execution) != null && ends.get(r.execution) <= st.replay);
577
+ const items = [], solo = run.kind === 'solo';
578
+ for (const tf of list(F.tasks)) {
579
+ const tid = solo ? (list(run.tasks)[0] || {}).id : tf.id; if (!tid) continue;
580
+ const latest = Math.max(...tf.reviews.map(r => r.attempt ?? 0));
581
+ const roof = tf.reviews.filter(r => (r.attempt ?? 0) === latest).flatMap(r => r.findings.map(f => ({ f, r }))).sort(byRank);
582
+ for (const r of tf.reviews) {
583
+ const mine = r.findings.map(f => ({ f, r })).sort(byRank);
584
+ mine.forEach(({ f }, j) => {
585
+ const ri = roof.findIndex(x => x.f.id === f.id && x.r === r);
586
+ items.push({ key: `${run.key}:${tid}:${f.id}`, sev: f.severity, plausible: f.status !== 'CONFIRMED', data: { task: tid, review: r, finding: f },
587
+ anchor: () => anchorTask(tid, r, ri, roof.length, j, mine.length, shown) });
588
+ });
589
+ }
590
+ }
591
+ const fr = list(F.final_reviews)[0]; // the newest final review's
592
+ if (fr) {
593
+ const review = { kind: 'final', execution: fr.execution, spec: fr.spec, verdict: fr.verdict };
594
+ fr.findings.map(f => ({ f })).sort(byRank).forEach(({ f }, i, arr) => items.push({ key: `${run.key}:final:${fr.name}:${f.id}`, sev: f.severity,
595
+ plausible: f.status !== 'CONFIRMED', data: { final: fr.name, review, finding: f }, anchor: () => anchorFinal(fr.name, i, arr.length, shown, review) }));
596
+ }
597
+ BEACONS.set(items);
598
+ }
599
+ // the layers the toolbar switches; a frame never turns these back on
600
+ function applyLayers() {
601
+ const L = st.layers, finishOff = id => !L.finish && String(id || '').startsWith('f:');
602
+ for (const f of FLOWS) f.line.visible = f.cone.visible = L.cables && !finishOff(f.from) && !finishOff(f.to);
603
+ for (const el of EL.values()) {
604
+ if (el.leds) el.leds.mesh.visible = L.lamps;
605
+ if (el.roof) el.roof.mesh.visible = L.roof && !el.roofTiny;
606
+ if (el.disp) el.disp.mesh.visible = L.labels;
607
+ if (el.haloMesh) el.haloMesh.visible = L.halos;
608
+ if (el.kind === 'finish') el.root.visible = L.finish;
609
+ if (el.kind === 'review' || el.kind === 'verification') el.root.visible = L.finish;
610
+ }
611
+ for (const t of DISTRICTS) t.mesh.visible = L.districts;
612
+ BEACONS.setVisible(L.beacons && st.view === 'run');
613
+ E.invalidate(100);
614
+ }
615
+
616
+ /* ---------- name tags: a roof too small to read gets its name floating over it, facing the screen ---------- */
617
+ const CM = window.ForgeCloudModel;
618
+ const TAGS = new Map(); // element id → screen sprite
619
+ const ID_FONT = 21, TAG_PX = 20, TINY = 3.5; // the roof id's size in plan units; px: a tag, a roof too small to draw
620
+ const READ = 7 / Math.sqrt(Math.min(2, window.devicePixelRatio || 1)); // px at which a roof's id still reads (sharper on a dense screen)
621
+ let tagAt = 0, tagKey = '';
622
+ const SV = new THREE.Vector3();
623
+ function screenOf(v) { SV.copy(v).project(E.camera); const [VW, VH] = E.size; return { x: (SV.x + 1) / 2 * VW, y: (1 - SV.y) / 2 * VH, on: SV.z < 1 && Math.abs(SV.x) < 1.02 && Math.abs(SV.y) < 1.02 }; }
624
+ function tagText(el) {
625
+ if (el.kind === 'task') return el.data.task.id;
626
+ if (el.kind === 'finish') return el.data.name || '';
627
+ if (el.kind === 'run') return E.ellipsis(String(el.data.run.goal || el.data.run.run_id || '').split('\n')[0], 34);
628
+ const it = el.data.item; return it ? it.task || it.review || it.kind || '' : '';
629
+ }
630
+ // the elements a tag can name in the view shown: plan blocks, the attempts on the time axis, the map's runs
631
+ function taggable(el) {
632
+ if (st.view === 'atlas') return el.kind === 'run';
633
+ if (el.kind === 'part' || el.kind === 'ghost' || !el.root.parent) return false;
634
+ return st.mode === 'plan' ? el.g === st.planG : el.g === st.execG;
635
+ }
636
+ function roofTop(el) { el.root.updateWorldMatrix(true, false); return el.root.localToWorld(W(el.n.x + el.n.w / 2, el.n.y + el.n.d / 2, el.n.h * (el.cur.hk || 1) + el.cover.position.y / TS)); }
637
+ function tagFor(el) {
638
+ let t = TAGS.get(el.id); if (t) return t;
639
+ const id = el.id, text = tagText(el), w = Math.ceil(text.length * 11 * E.CW) + 26;
640
+ t = E.sprite(w, 22, 3, (x, s, C) => {
641
+ const cur = EL.get(id) || el, col = E.statusHex(cur.status);
642
+ E.rr(x, s, 1, 1, w - 2, 20, 6); x.globalAlpha = .92; x.fillStyle = C.bg; x.fill(); x.globalAlpha = 1;
643
+ x.strokeStyle = sameSel(cur) ? C.acc : C.line; x.lineWidth = Math.max(1, s); x.stroke();
644
+ x.fillStyle = col; x.beginPath(); x.arc(10 * s, 11 * s, 3.2 * s, 0, 7); x.fill();
645
+ E.font(x, 11, s, 600); x.fillStyle = C.inkHi; x.fillText(text, 18 * s, 15 * s);
646
+ }, { screen: true, px: TAG_PX });
647
+ t.mesh.center.set(.5, -.2); t.mat.depthTest = false; t.mesh.renderOrder = 6;
648
+ world.add(t.mesh); TAGS.set(id, t); return t;
649
+ }
650
+ const sameSel = el => st.sel && selIds().has(el.id);
651
+ function updateTags() {
652
+ const on = st.layers.roof && (st.view === 'run' || st.view === 'atlas'), cands = [], sel = selIds();
653
+ E.camera.updateMatrixWorld(); // the frame step runs before the render that would update it
654
+ for (const el of EL.values()) {
655
+ if (!on || !taggable(el)) continue;
656
+ const top = roofTop(el), p = screenOf(top);
657
+ const b = screenOf(top.clone().add(W(ID_FONT, 0, 0))), px = Math.hypot(b.x - p.x, b.y - p.y);
658
+ const tiny = px < TINY && st.view === 'run';
659
+ if (el.roof && el.roofTiny !== tiny) { el.roofTiny = tiny; el.roof.mesh.visible = st.layers.roof && !tiny; }
660
+ if (!p.on || px >= READ || el.tgt.vis < .5) continue;
661
+ const w = (Math.ceil(tagText(el).length * 11 * E.CW) + 26) * TAG_PX / 22;
662
+ cands.push({ key: el.id, x: p.x, y: p.y - 4, w: w + 8, h: TAG_PX + 6, pri: (sel.has(el.id) ? 1e9 : 0) + (el.id === st.hover ? 1e8 : 0) + (el.status === 'RUNNING' ? 1e6 : 0) + px });
663
+ }
664
+ // a finding's mark is never covered: its head goes in first, as a place no tag may take
665
+ if (st.view === 'run' && st.layers.beacons) {
666
+ const sz = BEACONS.unit * 2.2 * E.pxPerUnit();
667
+ BEACONS.items.forEach((it, i) => { if (!it.head || it.hidden) return; const h = screenOf(W(...it.head)); if (h.on) cands.push({ key: 'beacon:' + i, x: h.x, y: h.y + sz * .45, w: sz * .8, h: sz, pri: 1e12 }); });
668
+ }
669
+ const keep = new Set(CM ? CM.labelPick(cands, { max: (st.view === 'atlas' ? 10 : 40) + cands.filter(c => c.pri === 1e12).length }).filter(k => !k.startsWith('beacon:')) : []);
670
+ for (const [id, t] of TAGS) if (!keep.has(id)) t.mesh.visible = false;
671
+ for (const id of keep) {
672
+ const el = EL.get(id), t = tagFor(el), look = el.status + '|' + sameSel(el);
673
+ if (t.look !== look) { t.look = look; t.redraw(); }
674
+ t.mesh.visible = true; t.mesh.position.copy(roofTop(el));
675
+ }
676
+ }
677
+ // each frame: the tags follow the camera, at most every 120 ms, and once more when it settles
678
+ function tagsTick(now) {
679
+ const c = E.camera, key = [c.position.x, c.position.y, c.position.z, c.zoom || 1, ...E.size].map(v => v.toFixed(4)).join() + st.view + st.mode + st.hover + JSON.stringify(st.sel) + st.layers.roof + st.rebuilds;
680
+ if (key === tagKey) return false;
681
+ if (now - tagAt < 120) return true;
682
+ tagAt = now; tagKey = key; updateTags(); return false;
683
+ }
684
+
510
685
  /* ---------- building a view ---------- */
511
686
  function clearWorld() {
512
687
  if (world) E.disposeGroup(world);
513
688
  world = new THREE.Group(); E.scene.add(world);
514
- EL = new Map(); PICK = []; FLOWS = []; PROG = new Map();
689
+ EL = new Map(); PICK = []; FLOWS = []; PROG = new Map(); DISTRICTS = []; EXEC_BY = null; TAGS.clear(); tagKey = '';
515
690
  st.plan = st.exec = st.atlas = null; st.fade = null;
691
+ BEACONS.set([]);
516
692
  hideTip();
517
693
  }
518
694
  function buildRun() {
@@ -522,6 +698,7 @@ export async function createStage(host, cb = {}) {
522
698
  buildPlan(pg); buildExec(xg);
523
699
  pg.visible = st.mode === 'plan'; xg.visible = st.mode === 'exec';
524
700
  computeCritical();
701
+ syncBeacons(); applyLayers();
525
702
  retarget();
526
703
  }
527
704
  function rebuildExec() {
@@ -529,6 +706,7 @@ export async function createStage(host, cb = {}) {
529
706
  PICK = PICK.filter(o => { let p = o; while (p) { if (p === st.execG) return false; p = p.parent; } return true; });
530
707
  E.disposeGroup(st.execG); st.execG = new THREE.Group(); world.add(st.execG);
531
708
  buildExec(st.execG); st.execG.visible = st.mode === 'exec' || !!st.fade; st.rebuilds++; st.execDirty = false;
709
+ EXEC_BY = null; applyLayers();
532
710
  }
533
711
  function boxOf(which) {
534
712
  const P = which === 'exec' ? st.exec : st.plan;
@@ -558,7 +736,7 @@ export async function createStage(host, cb = {}) {
558
736
  }
559
737
  function metricColor(el) {
560
738
  const o = st.overlay, t = el.data && el.data.task; if (!t || o.color === 'status') return null;
561
- if (o.color === 'model') { const a = RM.alias(t.dwarf) || '?'; const i = [...a].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7) % MODEL_PALETTE.length; return new THREE.Color(MODEL_PALETTE[i]); }
739
+ if (o.color === 'model') return new THREE.Color(modelHex(RM.alias(t.dwarf) || '?'));
562
740
  const v = RM.metricValue(t, o.color, st.run, nowSec()), mx = st.metricMax && st.metricMax[o.color];
563
741
  if (v === null || !mx) return E.DETAIL.clone();
564
742
  return new THREE.Color().copy(E.statusColor('pending')).lerp(E.accent, Math.sqrt(v / mx));
@@ -566,6 +744,10 @@ export async function createStage(host, cb = {}) {
566
744
  function retarget() {
567
745
  const sel = selIds(), tsel = st.sel && (st.sel.kind === 'task' ? st.sel.id : st.sel.kind === 'execution' || st.sel.kind === 'fractal' ? st.sel.task : null), open = tsel && st.mode === 'plan' && st.view === 'run' ? 't:' + tsel : null;
568
746
  const crit = st.overlay.critical && st.crit ? new Set(st.crit.ids.map(id => 't:' + id)) : new Set();
747
+ // the route: what the chosen task waits on and what waits on it, lit like a route set on a signal box desk
748
+ const route = new Set();
749
+ if (tsel && st.layers.route && st.view === 'run' && st.run) { const L = RM.lineage(st.run, tsel); for (const id of [...L.ancestors, ...L.descendants]) route.add('t:' + id); }
750
+ st.routeSet = route;
569
751
  if (st.view === 'run' && st.overlay.height !== 'none') {
570
752
  st.metricMax = st.metricMax || {};
571
753
  st.metricMax[st.overlay.height] = Math.max(0, ...list(st.run.tasks).map(t => RM.metricValue(t, st.overlay.height, st.run, nowSec()) || 0));
@@ -578,14 +760,16 @@ export async function createStage(host, cb = {}) {
578
760
  baseTargets(el);
579
761
  const T = el.tgt;
580
762
  T.sel = sel.has(el.id) ? 1 : 0;
763
+ T.route = route.has(el.id) && st.mode === 'plan' ? 1 : 0;
581
764
  T.hov = st.hover === el.id ? 1 : 0;
582
765
  T.halo = el.pulse ? .22 : sel.has(el.id) ? .16 : 0;
583
766
  if (el.kind === 'run' && el.data.module && !el.data.module.match) { T.vis = .14; T.op = .45; T.halo = 0; }
584
767
  if (el.kind === 'ghost') { T.opB = .08; T.op = .7; T.lit = 0; T.tint = 0; }
585
768
  if (crit.has(el.id)) { T.lit = Math.max(T.lit, .75); T.halo = Math.max(T.halo, .12); }
769
+ if (el.flash && el.flash > performance.now()) { T.lit = Math.max(T.lit, .9); T.halo = Math.max(T.halo, .3); } // an event, without motion
586
770
  if (st.overlay.critical && st.crit && el.kind === 'task' && !crit.has(el.id)) T.vis = Math.min(T.vis, .55);
587
771
  el.metricCol = metricColor(el);
588
- if (el.metricCol) { T.tint = E.theme === 'light' ? .35 : .45; T.lit = .5; }
772
+ if (el.metricCol) { T.tint = E.theme === 'light' ? .5 : .45; T.lit = .5; }
589
773
  if (el.kind === 'task' && st.overlay.height !== 'none') {
590
774
  const v = RM.metricValue(el.data.task, st.overlay.height, st.run, nowSec()), mx = st.metricMax[st.overlay.height];
591
775
  T.hk = v === null || !mx ? .35 : .4 + 1.4 * Math.sqrt(v / mx);
@@ -596,15 +780,24 @@ export async function createStage(host, cb = {}) {
596
780
  }
597
781
  if (el.kind === 'task') { const pr = PROG.get(el.id); if (pr) pr.t = el.id === open ? 1 : 0; }
598
782
  // an open task has the stage: the other blocks sink and fade, as archivision does when a block opens
599
- if (open && el.id !== open && (el.kind === 'task' || el.kind === 'finish') && !sel.has(el.id)) { T.vis = Math.min(T.vis, .3); T.hk *= .4; T.halo = 0; }
783
+ // with its route set, the rest of the plan sinks and fades (gentler on the pale panel); the route is lit full
784
+ if (open && route.size && el.id !== open && (el.kind === 'task' || el.kind === 'finish') && !sel.has(el.id)) {
785
+ if (route.has(el.id)) { T.lit = 1; T.halo = Math.max(T.halo, .14); }
786
+ else { T.vis = Math.min(T.vis, E.theme === 'light' ? .5 : .3); T.hk *= .4; T.halo = 0; }
787
+ }
788
+ // on the time axis: the route's attempts stay, the rest dim
789
+ if (tsel && st.layers.route && st.mode === 'exec' && el.data && el.data.item && el.data.item.task && el.data.item.task !== tsel && !route.has('t:' + el.data.item.task)) T.vis = Math.min(T.vis, .35);
600
790
  }
601
791
  for (const f of FLOWS) {
602
792
  f.hot = (st.hover && (f.from === st.hover || f.to === st.hover)) || sel.has(f.from) || sel.has(f.to) ? 1 : 0;
793
+ const onRoute = id => route.has(id) || sel.has(id);
794
+ f.route = route.size && onRoute(f.from) && onRoute(f.to) ? 1 : 0;
603
795
  f.crit = st.overlay.critical && st.crit && crit.has(f.from) && crit.has(f.to) && st.crit.ids.indexOf(f.from.slice(2)) === st.crit.ids.indexOf(f.to.slice(2)) - 1 ? 1 : 0;
604
796
  const tgtEl = EL.get(f.to);
605
797
  f.live = tgtEl && tgtEl.status === 'RUNNING' && f.kind === 'dep';
606
798
  }
607
799
  E.invalidate(200);
800
+ syncCtl();
608
801
  }
609
802
  function computeCritical() { st.crit = st.view === 'run' && st.run && st.run.kind !== 'solo' ? RM.criticalPath(st.run, { now: nowSec() }) : null; }
610
803
 
@@ -617,6 +810,101 @@ export async function createStage(host, cb = {}) {
617
810
  const gl = E.glowPoints([new THREE.Vector3()], .3); gl.visible = false; beadG.add(gl); BEADS.push({ m, gl });
618
811
  }
619
812
  E.themeHooks.add(() => { beadMat.color.copy(E.accent); for (const el of EL.values()) { el.col = E.statusColor(el.status); halo(el); } retarget(); });
813
+ /* ---------- events: what just happened, played once on the plate. A merge sends a pulse to integration, a failed
814
+ attempt a red ring across the plate, an escalation a hop with the two models, a final review a ring in its verdict's
815
+ colour. They play for what a live poll brings and for what a replay passes; with reduced motion the block lights
816
+ instead, and a replay says each event in the live region. ---------- */
817
+ const fxG = new THREE.Group(); fxG.name = 'events'; E.scene.add(fxG);
818
+ const FX = [], RINGS = [], PULSES = [];
819
+ const UNIT_CIRCLE = Array.from({ length: 65 }, (_, i) => [Math.cos(i / 64 * Math.PI * 2), Math.sin(i / 64 * Math.PI * 2), 0]);
820
+ for (let i = 0; i < 6; i++) {
821
+ const g = new THREE.Group(), mat = E.lmat({ color: 0xffffff, transparent: true, linewidth: E.accentLineMat.linewidth, depthTest: false });
822
+ E.polyLine(UNIT_CIRCLE, mat, g); g.visible = false; g.renderOrder = 5; fxG.add(g); RINGS.push({ g, mat, busy: false });
823
+ }
824
+ for (let i = 0; i < 4; i++) {
825
+ const mat = new THREE.MeshBasicMaterial({ color: 0xffffff, toneMapped: false, transparent: true, depthTest: false });
826
+ const m = E.noRay(new THREE.Mesh(new THREE.SphereGeometry(TS, 12, 8), mat)); m.visible = false; m.renderOrder = 5;
827
+ const gl = E.glowPoints([new THREE.Vector3()], .5); gl.visible = false; fxG.add(m, gl); PULSES.push({ m, mat, gl, busy: false });
828
+ }
829
+ const FAILED = new Set(['FAIL', 'ERROR', 'CONFLICT', 'INFRA', 'TIMEOUT', 'BLOCKED']);
830
+ const evKey = ev => [ev.kind, ev.task || ev.name, ev.attempt ?? '', ev.t].join('|');
831
+ let evSeen = new Set(), evRun = null, evAt = null, sayAt = 0;
832
+ // the block an event happened to, in the view shown: the task's block, or on the time axis its attempt then
833
+ function evEl(ev) {
834
+ if (st.mode === 'plan') return EL.get(ev.kind === 'review' ? 'f:final' : 't:' + ev.task);
835
+ let best = null;
836
+ for (const el of EL.values()) {
837
+ const it = el.data && el.data.item; if (!it || it.ghost || el.g !== st.execG) continue;
838
+ const mine = ev.kind === 'review' ? it.kind === 'review' && it.review === ev.name : it.task === ev.task && !it.kind;
839
+ if (mine && it.start <= ev.t + 1 && (!best || it.start > best.data.item.start)) best = el;
840
+ }
841
+ return best;
842
+ }
843
+ const centre = el => { const p = roofTop(el); return [p.x / TS, p.z / TS, p.y / TS]; }; // world back to plan units
844
+ function ring(el, color, from, to, dur) {
845
+ const r = RINGS.find(x => !x.busy); if (!r) return;
846
+ const [x, y, z] = centre(el); r.busy = true; r.g.position.copy(W(x, y, z + 1)); r.mat.color.copy(color); r.g.visible = true;
847
+ FX.push({ t0: performance.now(), dur, step: k => { const e = 1 - Math.pow(1 - k, 3); r.g.scale.setScalar(from + (to - from) * e); E.setOp(r.mat, (1 - k) * .95); },
848
+ end: () => { r.g.visible = false; r.busy = false; } });
849
+ }
850
+ function pulse(a, b, color, dur, then) {
851
+ const p = PULSES.find(x => !x.busy); if (!p) { if (then) then(); return; }
852
+ const A = centre(a), B = centre(b), pts = E.arc(A, B, 24, .16, 140), cum = E.cumLen(pts);
853
+ p.busy = true; p.mat.color.copy(color); p.gl.material.uniforms.color.value.copy(color); p.m.visible = p.gl.visible = true; p.m.scale.setScalar(4.5);
854
+ FX.push({ t0: performance.now(), dur, step: k => { const q = E.alongPts(pts, cum, ez(k)); p.m.position.copy(W(...q)); E.moveGlow(p.gl, q, 1); },
855
+ end: () => { p.m.visible = p.gl.visible = false; p.busy = false; if (then) then(); } });
856
+ }
857
+ function hop(el, text) {
858
+ const [x, y, z] = centre(el), w = Math.ceil(text.length * 11 * E.CW) + 16;
859
+ const t = E.sprite(w, 22, 3, (c, s, C) => {
860
+ E.rr(c, s, 1, 1, w - 2, 20, 6); c.globalAlpha = .92; c.fillStyle = C.bg; c.fill(); c.globalAlpha = 1;
861
+ c.strokeStyle = C.acc; c.lineWidth = Math.max(1, s); c.stroke();
862
+ E.font(c, 11, s, 600); c.fillStyle = C.accHi; c.fillText(text, 8 * s, 15 * s);
863
+ }, { screen: true, px: 20 });
864
+ t.mesh.center.set(.5, 0); t.mat.depthTest = false; t.mesh.renderOrder = 7; fxG.add(t.mesh);
865
+ const r = el.n.w / 2;
866
+ const pts = E.arc([x - r * .8, y, z], [x + r * .8, y, z], 8, .3, 60), mat = E.lmat({ color: E.accent.clone(), transparent: true, linewidth: E.accentLineMat.linewidth, depthTest: false });
867
+ const line = E.polyLine(pts, mat, fxG);
868
+ FX.push({ t0: performance.now(), dur: 2200, step: k => { t.mesh.position.copy(W(x, y, z + 30 + 50 * ez(k))); const o = k < .7 ? 1 : 1 - (k - .7) / .3; t.mat.opacity = o; E.setOp(mat, o); },
869
+ end: () => { E.disposeGroup(t.mesh); fxG.remove(t.mesh); E.disposeGroup(line); fxG.remove(line); } });
870
+ }
871
+ function flash(el) { el.flash = performance.now() + 2600; retarget(); setTimeout(() => retarget(), 2700); }
872
+ function say(ev) {
873
+ if (ev.kind === 'merge') return `${ev.task} merged`;
874
+ if (ev.kind === 'attempt') return `${ev.task} attempt ${ev.attempt ?? ''} ${String(ev.status || '').toLowerCase()}`.replace(/\s+/g, ' ');
875
+ if (ev.kind === 'escalation') return `${ev.task} escalated ${RM.alias(ev.from) || ev.from} → ${RM.alias(ev.to) || ev.to}`;
876
+ return `final review ${ev.name || ''}: ${String(ev.verdict || '').toLowerCase()}`;
877
+ }
878
+ function playEvent(ev) {
879
+ const el = evEl(ev); if (!el || !el.root.parent) return false;
880
+ if (ev.kind === 'attempt' && !FAILED.has(ev.status)) return false;
881
+ if (reduce) { flash(el); return true; }
882
+ const r0 = Math.max(el.n.w, el.n.d) * .55;
883
+ if (ev.kind === 'merge') {
884
+ const integ = st.mode === 'plan' && st.layers.finish ? EL.get('f:integration') : null, green = E.statusColor('merged');
885
+ if (integ) pulse(el, integ, green, 900, () => ring(integ, green, Math.max(integ.n.w, integ.n.d) * .5, Math.max(integ.n.w, integ.n.d) * .5 + 60, 900));
886
+ else ring(el, green, r0, r0 + 60, 900);
887
+ } else if (ev.kind === 'attempt') ring(el, E.statusColor('fail'), r0, r0 + 220, 1400);
888
+ else if (ev.kind === 'escalation') hop(el, `${RM.alias(ev.from) || ev.from || '?'} → ${RM.alias(ev.to) || ev.to || '?'}`);
889
+ else if (ev.kind === 'review') ring(el, E.statusColor(['PASS', 'ACCEPT', 'APPROVE'].includes(String(ev.verdict || '').toUpperCase()) ? 'pass' : 'fail'), r0, r0 + 120, 1200);
890
+ E.invalidate(100);
891
+ return true;
892
+ }
893
+ // events not seen yet (a poll) or passed since the last replay time (a replay playing forward): at most six at once
894
+ function syncEvents({ replayFrom = null } = {}) {
895
+ if (st.view !== 'run' || !st.run) return;
896
+ const all = RM.events(st.run);
897
+ if (evRun !== st.run.key) { evRun = st.run.key; evSeen = new Set(all.map(evKey)); evAt = st.replay; return; }
898
+ let fresh;
899
+ if (st.replay !== null) {
900
+ fresh = replayFrom !== null && st.replay > replayFrom ? all.filter(ev => ev.t > replayFrom && ev.t <= st.replay) : [];
901
+ evAt = st.replay;
902
+ } else fresh = all.filter(ev => !evSeen.has(evKey(ev)));
903
+ for (const ev of all) evSeen.add(evKey(ev));
904
+ const shown = fresh.slice(-6).filter(playEvent);
905
+ if (st.replay !== null && shown.length && live && performance.now() - sayAt > 1500) { sayAt = performance.now(); live.textContent = shown.slice(-3).map(say).join('; '); }
906
+ }
907
+
620
908
  let lastPulse = 0, lastText = 0;
621
909
  function frameStep(now, dt) {
622
910
  const kk = reduce ? 1 : Math.min(1, dt * 7);
@@ -638,7 +926,7 @@ export async function createStage(host, cb = {}) {
638
926
  let pulsing = false;
639
927
  for (const el of EL.values()) {
640
928
  const c = el.cur, T = el.tgt;
641
- for (const k of ['vis', 'sel', 'hov', 'lit', 'op', 'opB', 'tint', 'halo', 'hk']) {
929
+ for (const k of ['vis', 'sel', 'hov', 'lit', 'op', 'opB', 'tint', 'halo', 'hk', 'route']) {
642
930
  const d = T[k] - c[k]; if (Math.abs(d) > .002) { c[k] += d * kk; moving = true; } else c[k] = T[k];
643
931
  }
644
932
  // which view the element belongs to, during a switch
@@ -661,9 +949,10 @@ export async function createStage(host, cb = {}) {
661
949
  if (el.pulse) pulsing = true;
662
950
  const col = el.metricCol || el.col;
663
951
  TMP.copy(col).lerp(E.accent, c.sel);
664
- E.paint(el.M, c.vis, Math.min(1, c.lit + c.sel * .9), c.hov, c.op * ol * gf, c.opB * ob * gf, TMP, 0);
952
+ if (c.route > .01) TMP.lerp(E.INKHI, .55 * c.route * (1 - c.sel)); // a set route: its blocks' hairlines take the route lamp
953
+ E.paint(el.M, c.vis, Math.min(1, Math.max(c.lit + c.sel * .9, c.route * .85)), c.hov, c.op * ol * gf, c.opB * ob * gf, TMP, 0);
665
954
  if (c.tint > .001) el.M.body.color.lerp(TMP2.copy(col), c.tint * c.vis);
666
- if (el.tintMat) { el.tintMat.color.copy(col); el.tintMat.opacity = (c.tint * 1.1 + .08 * c.sel) * ob * gf * c.vis; }
955
+ if (el.tintMat) { el.tintMat.color.copy(col); el.tintMat.opacity = (c.tint * (E.theme === 'light' ? 1.45 : 1.1) + .08 * c.sel) * ob * gf * c.vis; }
667
956
  if (el.haloMat) el.haloMat.uniforms.opacity.value = (c.halo + (el.pulse ? .12 * pulseK : 0)) * gf * c.vis;
668
957
  if (el.clef) el.clef.fgM.color.copy(E.statusColor(['MERGED', 'PASS'].includes(el.status) ? 'pass' : ['FAIL', 'ERROR', 'CONFLICT'].includes(el.status) ? 'fail' : 'pending'));
669
958
  if (el.inner) {
@@ -689,9 +978,10 @@ export async function createStage(host, cb = {}) {
689
978
  let gf = 1;
690
979
  if (fk !== null) gf = st.mode === 'exec' ? 1 - fk : fk;
691
980
  const vis = Math.min(a ? a.cur.vis : 1, b ? b.cur.vis : 1);
692
- const want = Math.max(f.hot, f.crit);
981
+ const want = Math.max(f.hot, f.crit, f.route || 0);
693
982
  f.cur += (want - f.cur) * kk; if (Math.abs(want - f.cur) > .002) moving = true;
694
- E.lineCol(f.mat, E.outlineMat.uniforms.color.value, f.crit ? 1 : f.cur * .9, vis, E.accent);
983
+ // a set route is drawn in the route lamp's white; the critical path and hover keep the accent
984
+ E.lineCol(f.mat, E.outlineMat.uniforms.color.value, f.crit ? 1 : f.cur * .9, vis, f.route && !f.crit ? E.INKHI : E.accent);
695
985
  if (!f.crit && !f.cur) f.mat.color.lerp(E.INKHI, .12);
696
986
  f.mat.linewidth = E.lineMat.linewidth * (1 + .9 * Math.max(f.cur, f.crit));
697
987
  E.setOp(f.mat, (.55 + .45 * Math.max(f.cur, f.crit)) * gf);
@@ -700,18 +990,25 @@ export async function createStage(host, cb = {}) {
700
990
  // beads: into running tasks, along the selected task's cables, inside an open running task
701
991
  const want = [];
702
992
  const tB = now / 1600;
703
- for (const f of FLOWS) if ((f.live || f.hot || f.crit) && (st.mode === 'plan' || st.fade) && st.planG && st.planG.visible) want.push({ pts: f.pts, cum: f.cum, t: (tB + (f.pts[0][0] % 97) / 97) % 1, r: 3.2, size: .32, k: .8 });
993
+ for (const f of FLOWS) if ((f.live || f.hot || f.crit || f.route) && (st.mode === 'plan' || st.fade) && st.planG && st.planG.visible) want.push({ pts: f.pts, cum: f.cum, t: (tB + (f.pts[0][0] % 97) / 97) % 1, r: 3.2, size: .32, k: .8 });
704
994
  for (const el of EL.values()) if (el.inner && el.inner.visible) for (const f of el.innerFlows) {
705
995
  const b = EL.get(f.b); if (b && b.status === 'RUNNING' || f.kind === 'flow' && st.sel && sel(f.a)) want.push({ pts: f.pts, cum: f.cum, t: (tB * 1.4) % 1, r: 1.1, size: .12, k: .8 });
706
996
  }
707
997
  BEADS.forEach((bd, i) => {
708
- const w = want[i]; bd.m.visible = bd.gl.visible = !!w && !reduce; if (!w || reduce) return;
998
+ const w = want[i]; bd.m.visible = bd.gl.visible = !!w && !reduce && st.layers.beads; if (!bd.m.visible) return;
709
999
  const p = E.alongPts(w.pts, w.cum, w.t); bd.m.position.copy(W(...p)); bd.m.scale.setScalar(w.r);
710
1000
  bd.gl.material.uniforms.size.value = w.size; E.moveGlow(bd.gl, p, w.k);
711
1001
  });
712
1002
  // elapsed times on running blocks tick once a second
713
1003
  if (now - lastText > 1000) { lastText = now; for (const el of EL.values()) if (el.pulse && !el.roofOnly) for (const t of el.texts) if (t !== el.roof && !t.gone) t.redraw(); }
714
1004
  if (st.view === 'run' && st.exec && st.exec.live && st.mode === 'exec') { placeNow(); }
1005
+ if (BEACONS.tick(now)) moving = true;
1006
+ if (tagsTick(now)) moving = true;
1007
+ for (let i = FX.length - 1; i >= 0; i--) {
1008
+ const f = FX[i], k = Math.min(1, (now - f.t0) / f.dur);
1009
+ if (k >= 1) { FX.splice(i, 1); f.end(); } else f.step(k);
1010
+ moving = true;
1011
+ }
715
1012
  if (!moving && (pulsing || want.length) && !reduce) {
716
1013
  // only lamps and beads move: about 30 frames a second is plenty
717
1014
  if (now - lastPulse > 30) { lastPulse = now; setTimeout(() => E.invalidate(), 33); }
@@ -734,7 +1031,7 @@ export async function createStage(host, cb = {}) {
734
1031
  function info(el) {
735
1032
  if (!el) return null;
736
1033
  if (el.kind === 'run') { const r = el.data.run; return { meta: `${RM.basename(r.repo)} · ${r.kind}${el.data.module.match ? '' : ' · filtered out'}`, title: String(r.goal || 'Untitled run').split('\n')[0], body: runInfo(r), foot: 'click to open this run' }; }
737
- if (el.kind === 'task') { const t = el.data.task; return { meta: `task · wave ${t.wave ?? '—'} · ${t.difficulty || '—'}`, title: `${t.id} · ${t.title || ''}`, body: `${el.status} · dwarf ${t.dwarf || '—'} · qa ${t.qa || '—'}`, foot: list(t.deps).length ? 'after ' + t.deps.join(', ') : 'no dependencies' }; }
1034
+ if (el.kind === 'task') return taskCard(el);
738
1035
  if (el.kind === 'finish') { const fi = finishInfo(el.id); return { meta: 'finish', title: fi.title, body: `${fi.status} · ${fi.sub}`, foot: fi.line }; }
739
1036
  if (el.kind === 'part') { const p = el.data.part; return { meta: `${p.label.toLowerCase()} · attempt ${p.attempt ?? '—'}`, title: p.spec || '—', body: p.planned ? 'planned' : p.status, foot: p.exec && p.exec.id ? 'click for prompt, model and timings' : '' }; }
740
1037
  const it = el.data.item; if (!it) return null;
@@ -742,17 +1039,54 @@ export async function createStage(host, cb = {}) {
742
1039
  if (it.kind === 'review') return { meta: 'final review', title: it.review, body: attemptLine(el) };
743
1040
  return { meta: it.ghost ? 'projected by forge-schedule rules' : `attempt ${it.attempt ?? '—'} · lane ${it.lane + 1}`, title: it.task, body: attemptLine(el), foot: new Date(it.start * 1000).toLocaleTimeString() + ' → ' + new Date(it.end * 1000).toLocaleTimeString() };
744
1041
  }
745
- function showTip(el, cx, cy) {
746
- const inf = info(el); if (!inf) return hideTip();
1042
+ // what a finding says after its "- [P1][CONFIRMED] path:line —" head
1043
+ function said(f) {
1044
+ const t = String(f.summary || '').replace(/^\s*-\s*\[P\d\]\s*(\[[A-Z]+\]\s*)?/, ''), i = t.indexOf(' — ');
1045
+ return i >= 0 && i < 200 ? t.slice(i + 3) : t;
1046
+ }
1047
+ function findingInfo(d) {
1048
+ const f = d.finding, r = d.review || {};
1049
+ const who = r.kind === 'final' ? `final review ${d.final}` : `${r.kind || 'review'} review${r.attempt != null ? ', attempt ' + r.attempt : ''} of ${d.task}`;
1050
+ return { meta: `${f.severity || 'no severity'} · ${String(f.status || '').toLowerCase()} · ${who}`, title: f.location || 'no location named', body: said(f), foot: 'click to open it in the diff' };
1051
+ }
1052
+ // a task's card: what it is, how it went, who did it, its worst finding and its place in the plan
1053
+ function taskCard(el) {
1054
+ const t = el.data.task, now = nowSec(), tt = RM.taskTimes(t, st.run, now), tok = RM.metricValue(t, 'tokens', st.run, now);
1055
+ const tries = list(t.attempts).length;
1056
+ const how = [tries ? `${tries} attempt${tries > 1 ? 's' : ''}` : null, tt.busy ? 'busy ' + RM.fmtDur(tt.busy) : null,
1057
+ tok ? `${tok >= 1e6 ? (tok / 1e6).toFixed(1) + 'M' : tok >= 1e3 ? Math.round(tok / 1e3) + 'k' : tok} tokens` : null].filter(Boolean).join(' · ');
1058
+ const body = [how, `${t.dwarf || '—'}${t.explored ? ' *' : ''} → ${t.qa || '—'}`];
1059
+ const tf = st.findings && list(st.findings.tasks).find(x => x.id === t.id);
1060
+ let note = null;
1061
+ if (tf) {
1062
+ const latest = Math.max(...tf.reviews.map(r => r.attempt ?? 0));
1063
+ const fs = tf.reviews.filter(r => (r.attempt ?? 0) === latest).flatMap(r => r.findings.map(f => ({ f }))).sort(byRank);
1064
+ if (fs.length) note = { sev: fs[0].f.severity, text: `${fs[0].f.location ? fs[0].f.location + ' — ' : ''}${said(fs[0].f)}${fs.length > 1 ? ` (+${fs.length - 1} more)` : ''}` };
1065
+ }
1066
+ const L = st.run ? RM.lineage(st.run, t.id) : { ancestors: new Set(), descendants: new Set() };
1067
+ const foot = [list(t.deps).length ? 'after ' + t.deps.join(', ') : 'no dependencies', L.descendants.size ? `${L.descendants.size} task${L.descendants.size > 1 ? 's' : ''} wait on it` : null].filter(Boolean).join(' · ');
1068
+ return { meta: `task · wave ${t.wave ?? '—'} · ${t.difficulty || '—'}`, title: `${t.id} · ${t.title || ''}`, status: el.status, body, note, foot };
1069
+ }
1070
+ function showTip(el, cx, cy, inf = info(el)) {
1071
+ if (!inf) return hideTip();
747
1072
  tip.replaceChildren();
748
- for (const [cls, text] of [['meta', inf.meta], ['title', inf.title], ['body', inf.body], ['foot', inf.foot]]) {
749
- if (!text) continue; const d = document.createElement('div'); d.className = 'stage-tip-' + cls; d.textContent = text; tip.append(d);
1073
+ for (const [cls, text] of [['meta', inf.meta], ['title', inf.title], ...[].concat(inf.body || []).map(b => ['body', b]), ['note', inf.note], ['foot', inf.foot]]) {
1074
+ if (!text) continue; const d = document.createElement('div'); d.className = 'stage-tip-' + cls;
1075
+ // the status leads the first line as the inspector shows it: a chip with its lamp
1076
+ if (cls === 'body' && inf.status && !tip.querySelector('.chip')) { const c = document.createElement('span'); c.className = 'chip st-' + String(inf.status).toLowerCase(); c.textContent = inf.status; d.append(c, ' '); }
1077
+ if (typeof text === 'string') d.append(text);
1078
+ else { const c = document.createElement('span'); c.className = 'sev-chip sev-' + (['P0', 'P1', 'P2', 'P3'].includes(text.sev) ? text.sev : 'none'); c.textContent = text.sev || '?'; d.append(c, text.text); }
1079
+ tip.append(d);
750
1080
  }
751
1081
  tip.hidden = false;
752
- const r = host.getBoundingClientRect(), tw = tip.offsetWidth || 240, th = tip.offsetHeight || 80;
1082
+ // inside the part of the stage the modules and the stage's keys leave free
1083
+ const r = host.getBoundingClientRect(), tw = tip.offsetWidth || 240, th = tip.offsetHeight || 80, I = E.overlayInset();
1084
+ const bank = CTL && !CTL.bank.hidden ? CTL.bank.getBoundingClientRect() : null;
1085
+ const L = I.l + 4, R = r.width - I.r - (bank && bank.width && bank.left - r.left > r.width / 2 ? r.right - bank.left + 4 : 8), T = I.t + 4, B = r.height - I.b - 8;
753
1086
  let x = cx - r.left + 16, y = cy - r.top + 16;
754
- if (x + tw > r.width - 8) x = cx - r.left - tw - 16;
755
- if (y + th > r.height - 8) y = cy - r.top - th - 16;
1087
+ if (x + tw > R) x = cx - r.left - tw - 16;
1088
+ if (y + th > B) y = cy - r.top - th - 16;
1089
+ x = Math.max(L, Math.min(x, R - tw)); y = Math.max(T, Math.min(y, B - th));
756
1090
  tip.style.transform = `translate(${Math.max(4, x).toFixed(0)}px, ${Math.max(4, y).toFixed(0)}px)`;
757
1091
  }
758
1092
  function hideTip() { tip.hidden = true; }
@@ -762,6 +1096,14 @@ export async function createStage(host, cb = {}) {
762
1096
  if (hoverRaf) return;
763
1097
  hoverRaf = requestAnimationFrame(() => {
764
1098
  hoverRaf = 0;
1099
+ // a finding's mark floats in front of its block: it is picked first
1100
+ const b = hoverAt && st.view === 'run' ? BEACONS.pick(hoverAt[0], hoverAt[1]) : null;
1101
+ BEACONS.setHot(b ? b.key : null);
1102
+ if (b) {
1103
+ E.canvas.classList.add('pointing');
1104
+ if (st.hover !== null) { st.hover = null; retarget(); }
1105
+ showTip(null, hoverAt[0], hoverAt[1], findingInfo(b.data)); E.invalidate(); return;
1106
+ }
765
1107
  const o = hoverAt ? E.pick(hoverAt[0], hoverAt[1], PICK, pickable) : null;
766
1108
  const el = elOf(o), id = el && !(o.userData.floor) ? el.id : null;
767
1109
  E.canvas.classList.toggle('pointing', !!id);
@@ -782,6 +1124,8 @@ export async function createStage(host, cb = {}) {
782
1124
  }
783
1125
  E.handlers.click = (cx, cy) => {
784
1126
  hideTip();
1127
+ const b = st.view === 'run' ? BEACONS.pick(cx, cy) : null;
1128
+ if (b) { cb.onFinding && cb.onFinding(b.data); return; }
785
1129
  const o = E.pick(cx, cy, PICK, pickable), el = elOf(o);
786
1130
  if (o && o.userData.fractal) { cb.onSelect && cb.onSelect({ kind: 'fractal', task: o.userData.fractal.task, node: o.userData.fractal.node }); return; }
787
1131
  if (st.view === 'atlas') {
@@ -798,8 +1142,37 @@ export async function createStage(host, cb = {}) {
798
1142
  cb.onSelect && cb.onSelect(selFor(el));
799
1143
  };
800
1144
  function order() { return st.plan ? st.plan.modules.filter(m => m.kind === 'task').map(m => m.task.id) : []; }
1145
+ // the map by keyboard: ← → walk the runs, ↑ ↓ jump a repository, Enter opens; the run's card shows where it is
1146
+ const live = document.getElementById('live');
1147
+ function walkAtlas(e) {
1148
+ // in reading order: repository by repository as the plate lays them out, then row by row
1149
+ const A = st.atlas, li = new Map((A ? A.layers : []).map((l, j) => [l.id, j]));
1150
+ const mods = A ? A.modules.filter(m => A.place[m.id] && m.match !== false)
1151
+ .sort((a, b) => (li.get(a.layer) ?? 0) - (li.get(b.layer) ?? 0) || A.place[a.id][1] - A.place[b.id][1] || A.place[a.id][0] - A.place[b.id][0]) : [];
1152
+ if (!mods.length) return false;
1153
+ const cur = st.sel && st.sel.kind === 'run' ? mods.findIndex(m => m.run.key === st.sel.key) : -1;
1154
+ if (e.key === 'Enter') { if (cur < 0) return false; cb.onOpenRun && cb.onOpenRun(mods[cur].run.key); return true; }
1155
+ let i;
1156
+ if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { const d = e.key === 'ArrowRight' ? 1 : -1; i = cur < 0 ? (d > 0 ? 0 : mods.length - 1) : (cur + d + mods.length) % mods.length; }
1157
+ else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
1158
+ const d = e.key === 'ArrowDown' ? 1 : -1, layer = cur < 0 ? null : mods[cur].layer;
1159
+ const starts = mods.map((m, j) => j).filter(j => j === 0 || mods[j].layer !== mods[j - 1].layer);
1160
+ const at = layer === null ? -1 : starts.findIndex(j => mods[j].layer === layer);
1161
+ i = starts[at < 0 ? (d > 0 ? 0 : starts.length - 1) : (at + d + starts.length) % starts.length];
1162
+ } else return false;
1163
+ const m = mods[i]; st.sel = { kind: 'run', key: m.run.key }; retarget();
1164
+ const el = EL.get('r:' + m.id);
1165
+ if (el) {
1166
+ const r = E.canvas.getBoundingClientRect(), p = E.project(el.n.x + el.n.w / 2, el.n.y + el.n.d / 2, el.n.h);
1167
+ if (!p.on) E.frameBox([el.n.x - 60, el.n.y - 60, 0, el.n.x + el.n.w + 60, el.n.y + el.n.d + 60, el.n.h * 2], 500, 30);
1168
+ else showTip(el, r.left + p.x, r.top + p.y);
1169
+ }
1170
+ if (live) live.textContent = `${String(m.run.goal || m.run.run_id || m.run.key).split('\n')[0]}: ${m.state || ''}, ${RM.basename(m.run.repo || '')}, ${i + 1} of ${mods.length}`;
1171
+ return true;
1172
+ }
801
1173
  E.handlers.key = e => {
802
- if (e.key === 'Escape') { if (st.view === 'run' && st.sel) { cb.onSelect && cb.onSelect(null); return true; } if (st.view === 'run' && cb.onBack) { cb.onBack(); return true; } return false; }
1174
+ if (e.key === 'Escape') { if (st.view === 'run' && st.sel) { cb.onSelect && cb.onSelect(null); return true; } if (st.view === 'run' && cb.onBack) { cb.onBack(); return true; } if (st.view === 'atlas' && st.sel) { st.sel = null; hideTip(); retarget(); return true; } return false; }
1175
+ if (st.view === 'atlas') return walkAtlas(e);
803
1176
  if (st.view !== 'run') return false;
804
1177
  if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
805
1178
  const ids = order(); if (!ids.length) return true;
@@ -823,10 +1196,12 @@ export async function createStage(host, cb = {}) {
823
1196
  const leaveCloud = () => { if (cloud) cloud.detach(); };
824
1197
 
825
1198
  /* ---------- public API ---------- */
826
- function frameSelection(dur = 850) {
1199
+ function frameSelection(dur = 850, dir = null) {
827
1200
  if (st.view !== 'run') return;
828
1201
  const ids = [...selIds()].map(id => EL.get(id)).filter(el => el && el.root.parent && (st.mode === 'plan' ? el.g === st.planG || el.kind === 'part' : el.g === st.execG));
829
- if (!ids.length) { E.frameBox(boxOf(st.mode), dur, 26); return; }
1202
+ if (!ids.length) { E.frameBox(boxOf(st.mode), dur, 26, dir); return; }
1203
+ // a chosen task's route is seen end to end: what it waits on and what waits on it come into the frame
1204
+ if (st.mode === 'plan' && st.routeSet) for (const id of st.routeSet) { const el = EL.get(id); if (el && el.root.parent) ids.push(el); }
830
1205
  hideTip();
831
1206
  const xs = ids.flatMap(el => [el.n.x, el.n.x + el.n.w]), ys = ids.flatMap(el => [el.n.y, el.n.y + el.n.d]);
832
1207
  const hs = Math.max(...ids.map(el => el.n.h));
@@ -835,8 +1210,73 @@ export async function createStage(host, cb = {}) {
835
1210
  const pad = Math.max(st.mode === 'exec' ? 80 : 40, span * .3);
836
1211
  const opening = st.mode === 'plan' && ids.some(el => el.kind === 'task');
837
1212
  const tree = opening && ids.some(el => FRACTAL.has(el.data && el.data.task && el.data.task.id)) ? hs + 130 : 0; // a Fractal tree floats above
838
- E.frameBox([Math.min(...xs) - pad, Math.min(...ys) - pad, 0, Math.max(...xs) + pad, Math.max(...ys) + pad, Math.max(tree, hs * (opening ? 2 : 1.4))], dur, opening ? 52 : 40);
1213
+ E.frameBox([Math.min(...xs) - pad, Math.min(...ys) - pad, 0, Math.max(...xs) + pad, Math.max(...ys) + pad, Math.max(tree, hs * (opening ? 2 : 1.4))], dur, opening ? 52 : 40, dir);
1214
+ }
1215
+
1216
+ /* ---------- the view controls (stagectl.js): fit, zoom, projection, reset, the legend ---------- */
1217
+ const ISO_DIR = new THREE.Vector3(1, 1, 1), TOP_DIR = new THREE.Vector3(0, 1, .002);
1218
+ let proj = 'persp';
1219
+ try { proj = localStorage.getItem('forge-ui-projection') || 'persp'; } catch { /* storage refused: perspective */ }
1220
+ if (!['persp', 'iso', 'top'].includes(proj)) proj = 'persp';
1221
+ if (proj !== 'persp') { E.setOrtho(true); if (proj === 'top') E.camera.position.copy(TOP_DIR).normalize().multiplyScalar(20); }
1222
+ BEACONS.setFlat(proj === 'top');
1223
+ function frameShown(dur = 650, dir = null) {
1224
+ if (st.view === 'run' && st.run) { if (st.sel) frameSelection(dur, dir); else E.frameBox(boxOf(st.mode), dur, 26, dir); }
1225
+ else if (st.view === 'atlas' && st.atlasBox) E.frameBox(st.atlasBox, dur, 30, dir);
1226
+ }
1227
+ function setProjection(p, dur = 650, dir) {
1228
+ const was = proj; proj = ['persp', 'iso', 'top'].includes(p) ? p : 'persp';
1229
+ try { localStorage.setItem('forge-ui-projection', proj); } catch { /* not remembered */ }
1230
+ E.setOrtho(proj !== 'persp'); BEACONS.setFlat(proj === 'top'); syncBeacons();
1231
+ // isometric is the true 35° view; leaving top-down goes back to it
1232
+ frameShown(dur, dir !== undefined ? dir : proj === 'top' ? TOP_DIR : proj === 'iso' || was === 'top' ? ISO_DIR : null);
839
1233
  }
1234
+ const fmtMetric = (m, v) => v === null || v === undefined ? '—' : m === 'duration' ? RM.fmtDur(v) : m === 'tokens' ? (v >= 1e6 ? (v / 1e6).toFixed(1) + 'M' : v >= 1e3 ? Math.round(v / 1e3) + 'k' : String(v)) : String(Math.round(v));
1235
+ const modelHex = a => MODEL_PALETTE[[...a].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7) % MODEL_PALETTE.length];
1236
+ // what the legend says: only what the stage draws now
1237
+ function legendModel() {
1238
+ const L = st.layers, o = st.overlay, out = [];
1239
+ const lamps = { title: 'Lamps', rows: [
1240
+ { sw: { lamp: 'st-merged' }, label: 'merged · passed · accepted' }, { sw: { lamp: 'st-running' }, label: 'running · interrupted' },
1241
+ { sw: { lamp: 'st-fail' }, label: 'failed · conflict · infra · timeout' }, { sw: { lamp: 'st-pending' }, label: 'pending, not started' },
1242
+ { sw: { route: true }, label: st.view === 'atlas' ? 'chosen run' : 'chosen task' + (L.route ? ' and its route' : '') }] };
1243
+ if (st.view === 'atlas') {
1244
+ out.push({ title: 'Map', rows: [], note: 'Each block is a run, each district a repository. The lamps on a block are its tasks; the block stands taller the more tasks it has.' }, lamps);
1245
+ return out;
1246
+ }
1247
+ if (st.view !== 'run') return out;
1248
+ out.push(lamps);
1249
+ if (st.mode === 'exec') out.push({ title: 'Execution', rows: [], note: 'One block per attempt on wall-clock time, in the lane it ran in: the dwarf, then its review.' });
1250
+ const cables = [];
1251
+ if (L.cables) cables.push({ sw: { cable: 'dep' }, label: 'a task waits on the one this comes from' });
1252
+ if (L.cables && L.beads) cables.push({ sw: { bead: true }, label: 'work flowing into a running task' });
1253
+ if (L.cables && L.route) cables.push({ sw: { cable: 'route' }, label: 'route of the chosen task' });
1254
+ if (L.cables && o.critical) cables.push({ sw: { cable: 'critical' }, label: 'critical path: it bounded the wall time' });
1255
+ cables.push({ sw: { cable: 'retry' }, label: 'inside a task: retry, shadow review' }, { sw: { cable: 'escalation' }, label: 'inside a task: escalation to another model' });
1256
+ out.push({ title: 'Cables', rows: cables });
1257
+ const fs = L.beacons && st.findings ? BEACONS.items : [];
1258
+ if (fs.length) {
1259
+ const has = new Set(fs.map(it => it.shape));
1260
+ const rows = [['P0', 'P0 · spike'], ['P1', 'P1 · diamond'], ['P2', 'P2 · cone'], ['P3', 'P3 · pin'], ['none', 'no severity · ring']].filter(([k]) => has.has(k)).map(([k, label]) => ({ sw: { sev: k }, label }));
1261
+ if (fs.some(it => it.plausible)) rows.push({ sw: { sev: 'P2', pale: true }, label: 'plausible: smaller, paler' });
1262
+ out.push({ title: 'Findings', rows, note: 'A click opens the diff at the finding.' });
1263
+ }
1264
+ if (o.arcs && list(st.run.tasks).some(t => t.clef && t.clef.first_pass)) out.push({ title: 'Review marks', rows: [{ sw: { ring: true }, label: 'Clef ring: the chance the first try passes' }] });
1265
+ if (o.height !== 'none') {
1266
+ const mx = st.metricMax && st.metricMax[o.height];
1267
+ out.push({ title: 'Height', rows: [{ sw: { height: true }, label: `${o.height}: up to ${fmtMetric(o.height, mx)}` }] });
1268
+ } else out.push({ title: 'Height', rows: [{ sw: { height: true }, label: 'difficulty' }] });
1269
+ if (o.color === 'model') {
1270
+ const models = [...new Set(list(st.run.tasks).map(t => RM.alias(t.dwarf) || '?'))].sort();
1271
+ out.push({ title: 'Colour: model', rows: models.map(a => ({ sw: { color: modelHex(a) }, label: a })) });
1272
+ } else if (o.color !== 'status') {
1273
+ const mx = st.metricMax && st.metricMax[o.color];
1274
+ out.push({ title: `Colour: ${o.color}`, rows: [{ sw: { scale: [fmtMetric(o.color, 0), fmtMetric(o.color, mx)] }, label: '' }] });
1275
+ }
1276
+ return out;
1277
+ }
1278
+ let CTL = null, ctlQ = 0;
1279
+ const syncCtl = () => { if (CTL && !ctlQ) ctlQ = requestAnimationFrame(() => { ctlQ = 0; CTL.sync(); }); };
840
1280
  const api = {
841
1281
  showAtlas(runs, filter, opts = {}) {
842
1282
  st.runs = list(runs); st.filter = filter || {}; st.atlasOpts = { showAll: !!opts.showAll };
@@ -866,6 +1306,7 @@ export async function createStage(host, cb = {}) {
866
1306
  } else api.update(run);
867
1307
  E.canvas.setAttribute('aria-label', `Run ${run.run_id || ''}: ${list(run.tasks).length} tasks, ${st.mode === 'plan' ? 'planned waves' : 'execution timeline'}`);
868
1308
  retarget();
1309
+ syncEvents();
869
1310
  },
870
1311
  update(run) {
871
1312
  if (st.view !== 'run' || !run || !st.run || run.key !== st.run.key) return;
@@ -906,6 +1347,7 @@ export async function createStage(host, cb = {}) {
906
1347
  }
907
1348
  computeCritical();
908
1349
  retarget();
1350
+ syncEvents();
909
1351
  },
910
1352
  setMode(mode) {
911
1353
  if (mode === st.mode || st.view !== 'run') { st.mode = mode; return; }
@@ -930,10 +1372,17 @@ export async function createStage(host, cb = {}) {
930
1372
  if (st.sel) frameSelection(900); else E.frameBox(boxOf(mode), 900, 26);
931
1373
  E.invalidate(950);
932
1374
  },
1375
+ // RunFindings of the run shown (GET /api/runs/<key>/findings): a mark per finding
1376
+ setFindings(data) { if ((data || null) === st.findings) return; st.findings = data || null; syncBeacons(); E.invalidate(200); },
1377
+ // {cables, beads, lamps, roof, labels, beacons, finish, districts, halos}: what the stage draws
1378
+ setLayers(L) { Object.assign(st.layers, L || {}); applyLayers(); if ('beacons' in (L || {}) || 'finish' in (L || {})) syncBeacons(); retarget(); },
1379
+ get layers() { return { ...st.layers }; },
933
1380
  setOverlay(o) { Object.assign(st.overlay, o || {}); if (st.view === 'run') { if ('ghosts' in (o || {})) rebuildExec(); computeCritical(); for (const el of EL.values()) redraw(el); } retarget(); },
934
1381
  setReplay(t) {
1382
+ const from = st.replay;
935
1383
  st.replay = t === null || t === undefined ? null : Number(t);
936
1384
  if (st.view !== 'run') return;
1385
+ E.invalidate(100); // marks a replay reaches drop in
937
1386
  st.replayState = st.replay !== null ? RM.stateAt(st.run, st.replay) : null;
938
1387
  for (const el of EL.values()) {
939
1388
  if (el.kind === 'task') setStatus(el, taskStatus(el.data.task));
@@ -944,6 +1393,10 @@ export async function createStage(host, cb = {}) {
944
1393
  const sig = 'replay:' + el.status; if (sig !== el.sig) { el.sig = sig; redraw(el); }
945
1394
  }
946
1395
  placeNow(); retarget();
1396
+ // playing forward: what the replay just passed happens again; a jump across the run plays nothing
1397
+ const R = st.run && RM.timeRange(st.run, nowSec());
1398
+ if (st.replay === null) evAt = null;
1399
+ else syncEvents({ replayFrom: from !== null && R && st.replay - from <= .1 * Math.max(1, R.end - R.start) ? from : null });
947
1400
  },
948
1401
  select(s, { frame = true } = {}) {
949
1402
  const same = JSON.stringify(s || null) === JSON.stringify(st.sel || null);
@@ -954,7 +1407,7 @@ export async function createStage(host, cb = {}) {
954
1407
  showCloud(runs, opts = {}) {
955
1408
  if (st.view !== 'cloud') { clearWorld(); st.view = 'cloud'; st.run = null; st.sel = null; st.atlasSig = ''; st.structure = ''; }
956
1409
  if (!cloud) cloud = createCloud(E, host, cb.cloud || {});
957
- const c = cloud.show(list(runs), opts);
1410
+ const c = cloud.show(list(runs), opts); syncCtl();
958
1411
  E.canvas.setAttribute('aria-label', `Cloud of runs: ${c.visible} of ${c.total} runs in ${c.projects} projects, ${c.layout} layout`);
959
1412
  return c;
960
1413
  },
@@ -965,15 +1418,30 @@ export async function createStage(host, cb = {}) {
965
1418
  const el = EL.get('t:' + taskId); if (el && el.inner) { syncFractal(el); E.invalidate(300); }
966
1419
  },
967
1420
  focus() { E.canvas.focus({ preventScroll: true }); },
1421
+ // frame again what is shown, in the part of the stage the modules leave free (after a module opens, folds or resizes)
1422
+ reframe(dur = 500) {
1423
+ if (st.view === 'run' && st.run) { if (st.sel) frameSelection(dur); else E.frameBox(boxOf(st.mode), dur, 26); }
1424
+ else if (st.view === 'atlas' && st.atlasBox) E.frameBox(st.atlasBox, dur, 30);
1425
+ else if (st.view === 'cloud' && cloud && cloud.frameAll) cloud.frameAll();
1426
+ },
968
1427
  get mode() { return st.mode; },
969
1428
  get view() { return st.view; },
970
- dispose() { E.dispose(); tip.remove(); },
1429
+ dispose() { if (CTL) CTL.dispose(); E.dispose(); tip.remove(); },
971
1430
  debug() {
972
1431
  return {
973
1432
  view: st.view, mode: st.mode, rebuilds: st.rebuilds, sel: st.sel, prog: [...PROG].map(([id, p]) => [id, p.p, p.t]), fade: !!st.fade,
974
1433
  nodes: [...EL.values()].map(el => ({ id: el.id, kind: el.kind, status: el.status, sel: el.tgt.sel, vis: el.tgt.vis })),
975
1434
  screenPos(id) { const el = EL.get(id); if (!el) return null; const r = E.canvas.getBoundingClientRect(); const p = E.project(el.n.x + el.n.w / 2, el.n.y + el.n.d / 2, el.n.h); return { x: r.left + p.x, y: r.top + p.y, on: p.on }; },
976
1435
  settle() { for (const el of EL.values()) Object.assign(el.cur, el.tgt); for (const pr of PROG.values()) pr.p = pr.t; E.invalidate(); },
1436
+ beaconScale: () => ({ unit: BEACONS.unit, ppu: E.pxPerUnit() }),
1437
+ projection: () => ({ proj, ortho: E.isOrtho() }),
1438
+ pickBeacon: (x, y) => { const b = BEACONS.pick(x, y); return b ? b.key : null; },
1439
+ // the finding marks drawn now, each with where it is on the screen
1440
+ beacons() {
1441
+ const r = E.canvas.getBoundingClientRect();
1442
+ return BEACONS.items.map(it => { if (!it.anchor() || it.hidden || !it.head) return { key: it.key, id: it.data.finding.id, shown: false };
1443
+ const p = E.project(...it.head); return { key: it.key, id: it.data.finding.id, sev: it.sev, shown: true, x: r.left + p.x, y: r.top + p.y, on: p.on }; });
1444
+ },
977
1445
  };
978
1446
  },
979
1447
  };
@@ -992,5 +1460,12 @@ export async function createStage(host, cb = {}) {
992
1460
  const nel = buildAttempt(it, g);
993
1461
  Object.assign(nel.cur, el.cur);
994
1462
  }
1463
+ CTL = createControls(host, {
1464
+ view: () => st.view, fit: () => frameShown(650), zoom: k => E.zoomBy(k),
1465
+ get projection() { return proj; }, setProjection,
1466
+ reset: () => setProjection('persp', 700, ISO_DIR),
1467
+ legend: legendModel,
1468
+ toggleRoute: () => { api.setLayers({ route: !st.layers.route }); if (cb.onLayers) cb.onLayers({ ...st.layers }); },
1469
+ });
995
1470
  return api;
996
1471
  }