@giamme/forge 0.8.0 → 0.10.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 (83) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +106 -1766
  3. package/SKILL.md +22 -3
  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 +84 -0
  8. package/docs/commands.md +365 -0
  9. package/docs/dataset.md +29 -0
  10. package/docs/decomposed-runs.md +310 -0
  11. package/docs/development.md +95 -0
  12. package/docs/forge-ui.md +128 -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 +245 -0
  18. package/docs/safety.md +55 -0
  19. package/docs/troubleshooting.md +54 -0
  20. package/forge +20 -0
  21. package/package.json +3 -2
  22. package/references/dataset.md +18 -1
  23. package/references/decompose.md +20 -2
  24. package/references/fractal.md +1 -1
  25. package/references/harnesses.md +18 -6
  26. package/references/solo.md +1 -1
  27. package/references/ui.md +27 -5
  28. package/scripts/forge-config.py +347 -0
  29. package/scripts/forge-control.py +645 -0
  30. package/scripts/forge-dispatch.sh +7 -1
  31. package/scripts/forge-issues.py +806 -0
  32. package/scripts/forge-lib-task.sh +234 -35
  33. package/scripts/forge-parallel-options.sh +3 -1
  34. package/scripts/forge-parallel.sh +3 -2
  35. package/scripts/forge-route.py +184 -27
  36. package/scripts/forge-runs-rm.py +840 -0
  37. package/scripts/forge-runs.py +139 -0
  38. package/scripts/forge-runtime.py +59 -6
  39. package/scripts/forge-schedule.py +227 -11
  40. package/scripts/forge-ui.py +85 -19
  41. package/scripts/forge-version.py +54 -12
  42. package/scripts/forge_dataset/__init__.py +77 -6
  43. package/scripts/forge_dataset/capture.py +5 -2
  44. package/scripts/forge_dataset/harvest.py +23 -1
  45. package/scripts/forge_dataset/importer.py +420 -18
  46. package/scripts/forge_fractal/__init__.py +2 -1
  47. package/scripts/forge_registry.py +168 -16
  48. package/ui/README.md +116 -23
  49. package/ui/THIRD_PARTY_NOTICES.md +108 -0
  50. package/ui/docs/api.md +283 -23
  51. package/ui/forge_ui/__main__.py +10 -2
  52. package/ui/forge_ui/control.py +575 -0
  53. package/ui/forge_ui/export.py +33 -9
  54. package/ui/forge_ui/fractal.py +1 -2
  55. package/ui/forge_ui/reader.py +121 -8
  56. package/ui/forge_ui/server.py +135 -5
  57. package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
  58. package/ui/forge_ui/static/app.js +358 -131
  59. package/ui/forge_ui/static/cloud3d.js +3 -2
  60. package/ui/forge_ui/static/cloudui.js +8 -5
  61. package/ui/forge_ui/static/controls.js +619 -0
  62. package/ui/forge_ui/static/convo.js +295 -0
  63. package/ui/forge_ui/static/datatable.js +232 -0
  64. package/ui/forge_ui/static/diffview.js +29 -10
  65. package/ui/forge_ui/static/findings3d.js +134 -0
  66. package/ui/forge_ui/static/findingsview.js +412 -0
  67. package/ui/forge_ui/static/graph.js +3 -3
  68. package/ui/forge_ui/static/icons.js +94 -0
  69. package/ui/forge_ui/static/index.html +31 -13
  70. package/ui/forge_ui/static/iso.js +70 -19
  71. package/ui/forge_ui/static/leaderboard.js +44 -48
  72. package/ui/forge_ui/static/panel.js +92 -36
  73. package/ui/forge_ui/static/rundelete.js +388 -0
  74. package/ui/forge_ui/static/runmodel.js +17 -1
  75. package/ui/forge_ui/static/scene3d.js +518 -43
  76. package/ui/forge_ui/static/search.js +2 -1
  77. package/ui/forge_ui/static/settings.js +399 -0
  78. package/ui/forge_ui/static/shell.js +137 -0
  79. package/ui/forge_ui/static/stagectl.js +150 -0
  80. package/ui/forge_ui/static/style.css +1099 -469
  81. package/ui/forge_ui/static/theme.js +25 -0
  82. package/ui/forge_ui/static/uikit.js +161 -0
  83. package/ui/forge_ui/transcripts.py +608 -0
@@ -0,0 +1,388 @@
1
+ /* forge-ui delete-run dialog (window.ForgeRunDelete, and a CommonJS module under node:test). A "Delete run…" key in the
2
+ run header opens a modal: it dry-runs `forge runs rm` on open and whenever an option changes, shows what would go
3
+ (size, archive, steps, worktrees, branches), and deletes only after the run id has been typed exactly.
4
+ The pure helpers (request body, option state, confirm gate, warnings, plates, response decision) are tested in
5
+ ui/tests/js/rundelete.test.js; open(run, ctx) is thin DOM over them. Every value from the CLI enters the page as text.
6
+ ctx = { onDeleted?() } is called after a full success (the page then shows the run list again).
7
+ The write call is ForgeUI's: POST /api/runs/<key>/delete, see ui/docs/api.md, "Controls (write endpoints)". */
8
+ (function (root, factory) {
9
+ 'use strict';
10
+ const api = factory(root);
11
+ if (typeof module === 'object' && module.exports) module.exports = api;
12
+ else root.ForgeRunDelete = api;
13
+ })(typeof self !== 'undefined' ? self : this, function (root) {
14
+ 'use strict';
15
+
16
+ const list = v => Array.isArray(v) ? v : [];
17
+ const object = v => v && typeof v === 'object' && !Array.isArray(v) ? v : null;
18
+ const text = v => typeof v === 'string' ? v : '';
19
+
20
+ /* ---------- pure ---------- */
21
+ // The POST body. Archive is on unless it is explicitly off; the rest are on only when asked for.
22
+ function deleteBody(o) {
23
+ const x = o || {};
24
+ return { dry_run: !!x.dryRun, archive: x.archive !== false, worktrees: !!x.worktrees, branches: !!x.branches };
25
+ }
26
+
27
+ // The dialog's state: the three options and what has been typed. Never starts with anything typed.
28
+ const OPTIONS = ['archive', 'worktrees', 'branches'];
29
+ const initial = runId => ({ runId: text(runId), archive: true, worktrees: false, branches: false, typed: '' });
30
+ // A changed option clears the typed confirmation (the dry run it answered is out of date); an unchanged one does not.
31
+ function withOption(s, name, value) {
32
+ if (!OPTIONS.includes(name) || s[name] === !!value) return s;
33
+ return { ...s, [name]: !!value, typed: '' };
34
+ }
35
+ const withTyped = (s, typed) => ({ ...s, typed: text(typed) });
36
+ // The options as the dry run / delete body is built from them.
37
+ const bodyOf = (s, dryRun) => deleteBody({ dryRun, archive: s.archive, worktrees: s.worktrees, branches: s.branches });
38
+
39
+ // The confirm gate: the run id, character for character (no trimming, no case folding).
40
+ const confirmOk = (typed, runId) => typeof typed === 'string' && typeof runId === 'string' && runId !== '' && typed === runId;
41
+ // Delete is armed by the exact id AND a dry run that answers for the options as they stand now.
42
+ const canDelete = (s, preview, busy) => !!preview && !busy && confirmOk(s.typed, s.runId);
43
+
44
+ const isUnmerged = b => !!b && String(b.status).toUpperCase() === 'UNMERGED';
45
+ // The worktree that still has a branch checked out once the worktrees step has run, which keeps the branch ('' when
46
+ // none does). The dry run answers this for the options it was asked with: with "Remove worktrees" the run's clean
47
+ // worktrees go first and no longer hold their branches.
48
+ const heldBy = b => text(b && b.stays_checked_out);
49
+ // The UNMERGED branches the options delete (all of them are kept without "Delete branches").
50
+ const unmerged = (result, branches) => list(object(result) && result.branches).filter(b => isUnmerged(b) && !(branches && heldBy(b)));
51
+ const dirtyRun = result => list(object(result) && result.worktrees).filter(w => w && w.kind !== 'foreign' && w.dirty);
52
+
53
+ // Warnings for a dry-run result under the options as they stand: [{id, tone: 'warn' | 'note', text, names}].
54
+ // 'warn' is what the delete would do; 'note' is a risk the options do not take.
55
+ function warningsOf(result, opts) {
56
+ const o = opts || {}, out = [];
57
+ if (o.archive === false) {
58
+ out.push({ id: 'no-archive', tone: 'warn', names: [],
59
+ text: 'Without the archive the transcripts and run files are lost for good. The dataset events are still preserved first.' });
60
+ }
61
+ const bad = unmerged(result, o.branches).map(b => text(b.name));
62
+ if (bad.length) {
63
+ out.push({ id: 'unmerged', tone: o.branches ? 'warn' : 'note', names: bad,
64
+ text: o.branches ? 'Deleting these branches drops commits that are merged nowhere: ' + bad.join(', ')
65
+ : 'These branches are not merged and are kept: ' + bad.join(', ') });
66
+ }
67
+ const dirty = dirtyRun(result).map(w => text(w.path));
68
+ if (dirty.length) {
69
+ out.push({ id: 'dirty', tone: o.worktrees ? 'warn' : 'note', names: dirty,
70
+ text: o.worktrees ? 'These worktrees have uncommitted changes and will not be removed, so the run directory is kept too: ' + dirty.join(', ')
71
+ : 'These worktrees have uncommitted changes and are never removed: ' + dirty.join(', ') });
72
+ }
73
+ return out;
74
+ }
75
+
76
+ // The plate of a worktree: run | dirty | foreign, and what happens to it.
77
+ function worktreePlate(w) {
78
+ if (w && w.kind === 'foreign') return { id: 'foreign', label: 'foreign', note: 'never touched' };
79
+ if (w && w.dirty) return { id: 'dirty', label: 'dirty', note: 'will not be removed' };
80
+ return { id: 'run', label: 'run', note: '' };
81
+ }
82
+ // The plate of a branch: merged | UNMERGED, and, with "Delete branches", whether it goes.
83
+ function branchPlate(b, opts) {
84
+ const un = isUnmerged(b), held = heldBy(b);
85
+ const note = !(opts && opts.branches) ? '' : held ? 'kept: checked out in ' + held : 'will be deleted';
86
+ return { id: un ? 'unmerged' : 'merged', label: un ? 'UNMERGED' : 'merged', note };
87
+ }
88
+
89
+ function stepsOf(result) {
90
+ return list(object(result) && result.steps).filter(object).map(s => ({ name: text(s.step || s.name), ok: s.ok !== false, detail: text(s.detail) }));
91
+ }
92
+
93
+ // `forge runs rm --json` prints nothing on stderr, so the server's `detail` is only "the forge CLI exited N".
94
+ // The CLI's real words are the details of the failed steps in `result`.
95
+ const GENERIC = /^the forge CLI exited \d+$/;
96
+ const failedDetails = result => stepsOf(result).filter(s => !s.ok && s.detail).map(s => s.detail);
97
+ // Why a refused or failed answer was refused, in the CLI's words ('' when there are none).
98
+ function reasonOf(res) {
99
+ const r = res || {};
100
+ const fromSteps = failedDetails(r.result);
101
+ if (fromSteps.length) return fromSteps.join('; ');
102
+ const d = text(r.detail);
103
+ return GENERIC.test(d) ? '' : d;
104
+ }
105
+ // The actionable part of a partial failure: failed steps that say what to do ("registry needs repair: rerun …").
106
+ const guidanceOf = result => failedDetails(result).filter(d => /rerun|repair/i.test(d)).join('; ');
107
+
108
+ // What a POST answer means. kind: 'success' (a full success), 'partial' (the CLI failed part-way: steps and guidance),
109
+ // 'busy' (409: active or busy), 'error' (anything else: the page prints `text` with ForgeUI.outcome).
110
+ function outcomeOf(res) {
111
+ const r = res || {};
112
+ if (r.ok && object(r.data) && r.data.ok !== false) {
113
+ const result = r.data;
114
+ return { kind: 'success', result, steps: stepsOf(result), failed: [], guidance: '' };
115
+ }
116
+ if (r.ok) {
117
+ const steps = stepsOf(r.data);
118
+ return { kind: 'partial', result: r.data, steps, failed: steps.filter(s => !s.ok), guidance: guidanceOf(r.data) };
119
+ }
120
+ if (r.status === 409) {
121
+ return { kind: 'busy', text: r.error === 'busy' ? 'busy: another change to this run is in progress, try again' : reasonOf(r) || 'the run is active' };
122
+ }
123
+ const result = object(r.result);
124
+ if (r.status === 500 && result) {
125
+ const steps = stepsOf(result);
126
+ return { kind: 'partial', result, steps, failed: steps.filter(s => !s.ok), guidance: guidanceOf(result) };
127
+ }
128
+ return { kind: 'error', text: reasonOf(r) || text(r.detail) || text(r.error) || 'failed', refused: r.status === 400 && !!result };
129
+ }
130
+
131
+ // What a dry-run answer means: 'preview' with the result (and whether the real run would complete, with the CLI's
132
+ // words when it would not), 'busy', or 'error'. A dry run that reports failure still describes what it would do.
133
+ function previewOf(res) {
134
+ const r = res || {};
135
+ if (r.ok && object(r.data)) return { kind: 'preview', result: r.data, completes: r.data.ok !== false, note: '' };
136
+ if (r.status === 409) return { kind: 'busy', text: outcomeOf(r).text };
137
+ if (r.status === 500 && object(r.result)) return { kind: 'preview', result: r.result, completes: false, note: reasonOf(r) };
138
+ return { kind: 'error', text: outcomeOf(r).text };
139
+ }
140
+
141
+ // A click on the <dialog> element itself is on its backdrop only when it falls outside the dialog's box;
142
+ // the padding inside the border is also the element.
143
+ const outsideBox = (box, x, y) => !!box && (x < box.left || x > box.right || y < box.top || y > box.bottom);
144
+
145
+ function fmtBytes(n) {
146
+ if (typeof n !== 'number' || !isFinite(n) || n < 0) return 'unknown';
147
+ let v = n;
148
+ for (const unit of ['B', 'KB', 'MB', 'GB']) {
149
+ if (v < 1024 || unit === 'GB') return unit === 'B' ? v + ' B' : v.toFixed(1) + ' ' + unit;
150
+ v /= 1024;
151
+ }
152
+ return 'unknown';
153
+ }
154
+
155
+ // The write lock answers 409 {error: 'busy'} at once while another write to the run (a dry run included) is running.
156
+ const isLockBusy = res => !!res && res.status === 409 && res.error === 'busy';
157
+ const RETRIES = 8, RETRY_MS = 700;
158
+
159
+ // Runs async jobs one after another; a job that throws does not stop the queue.
160
+ function serializer() {
161
+ let tail = Promise.resolve();
162
+ return job => {
163
+ const p = tail.then(job);
164
+ tail = p.then(() => {}, () => {});
165
+ return p;
166
+ };
167
+ }
168
+ // One queue per page: a dialog that was closed may still have a request on the server, and the next must wait for it.
169
+ const gate = serializer();
170
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
171
+
172
+ const deletePath = key => '/api/runs/' + encodeURIComponent(key) + '/delete';
173
+
174
+ /* ---------- DOM ---------- */
175
+ let current = null; // the one dialog that may be open
176
+
177
+ function open(run, ctx) {
178
+ const UI = root.ForgeUI;
179
+ if (!UI || !run || !run.key) return null;
180
+ if (current) current.close();
181
+ const c = ctx || {};
182
+ const el = (tag, t, cls) => UI.el(tag, t, cls);
183
+ const s = { v: initial(run.run_id), preview: null, seq: 0, loading: false, posting: false, done: false };
184
+ const lead = text(run.goal).split('\n')[0];
185
+
186
+ const dlg = el('dialog', undefined, 'del-dialog'); dlg.setAttribute('aria-labelledby', 'del-title');
187
+ const title = el('h2', 'Delete this run', 'del-title'); title.id = 'del-title';
188
+ const ident = el('p', undefined, 'del-ident');
189
+ ident.append(el('code', run.run_id || run.key, 'del-id'), el('span', lead, 'muted'));
190
+
191
+ const lamp = el('p', undefined, 'del-status'); lamp.setAttribute('role', 'status'); lamp.setAttribute('aria-live', 'polite');
192
+ const previewHost = el('div', undefined, 'del-preview');
193
+
194
+ const optHost = el('fieldset', undefined, 'del-options'); optHost.append(el('legend', 'Options', 'plate'));
195
+ const boxes = {};
196
+ const option = (name, label, help) => {
197
+ const l = el('label', undefined, 'del-opt'), box = el('input'); box.type = 'checkbox'; box.checked = s.v[name];
198
+ const t = el('span'); t.append(el('strong', label), el('span', help, 'muted'));
199
+ l.append(box, t); optHost.append(l); boxes[name] = box;
200
+ box.addEventListener('change', () => { s.v = withOption(s.v, name, box.checked); confirmInput.value = ''; load(); syncAll(); });
201
+ };
202
+ option('archive', 'Archive transcripts and run files for the dataset', 'A tar.gz under the dataset home. The dataset events themselves are always preserved first.');
203
+ option('worktrees', 'Remove worktrees', 'Only the run\'s own clean worktrees. Foreign and dirty ones are never touched.');
204
+ option('branches', 'Delete branches', 'The run\'s branches, except any still checked out in a worktree that stays.');
205
+ const warnHost = el('ul', undefined, 'del-warnings');
206
+
207
+ const confirmBox = el('div', undefined, 'del-confirm');
208
+ const confirmLabel = el('label', undefined, 'del-confirm-label'); confirmLabel.htmlFor = 'del-confirm-input';
209
+ const idCode = el('code', run.run_id || '…', 'del-id');
210
+ confirmLabel.append(document.createTextNode('To delete, type the run id: '), idCode);
211
+ const confirmInput = el('input', undefined, 'del-confirm-input'); confirmInput.id = 'del-confirm-input'; confirmInput.type = 'text';
212
+ confirmInput.autocomplete = 'off'; confirmInput.spellcheck = false; confirmInput.autocapitalize = 'off'; confirmInput.value = '';
213
+ confirmBox.append(confirmLabel, confirmInput);
214
+
215
+ const resultHost = el('div', undefined, 'del-result');
216
+ const cancel = el('button', 'Cancel'); cancel.type = 'button';
217
+ const go = el('button', 'Delete', 'ui-danger del-go'); go.type = 'button'; go.disabled = true;
218
+ const retry = el('button', 'Check again'); retry.type = 'button'; retry.hidden = true;
219
+ const actions = el('div', undefined, 'del-actions'); actions.append(retry, cancel, go);
220
+ dlg.append(title, ident, lamp, previewHost, optHost, warnHost, confirmBox, resultHost, actions);
221
+
222
+ function setLamp(cls, t) { lamp.replaceChildren(el('i', undefined, 'lamp ' + cls), el('span', t)); }
223
+ function row(host, plate, label, note) {
224
+ const li = el('li', undefined, 'del-row');
225
+ li.append(el('span', plate.label, 'plate del-plate del-plate-' + plate.id), el('span', label, 'del-row-text mono'));
226
+ if (note) li.append(el('span', note, 'muted del-row-note'));
227
+ host.append(li);
228
+ }
229
+ function section(name, body) {
230
+ const sec = el('section', undefined, 'del-section');
231
+ sec.append(el('h3', name, 'plate'), body);
232
+ return sec;
233
+ }
234
+
235
+ function drawPreview() {
236
+ const p = s.preview, r = p && p.result;
237
+ if (!r) { previewHost.replaceChildren(); return; }
238
+ const facts = el('dl', undefined, 'del-facts');
239
+ const fact = (k, v) => { facts.append(el('dt', k), el('dd', v, 'mono')); };
240
+ fact('Size', fmtBytes(r.size_bytes));
241
+ if (s.v.archive) fact('Archive', (r.archive ? text(r.archive) : 'under the dataset home') + ' · about ' + fmtBytes(r.archive_estimate_bytes));
242
+ else fact('Archive', 'none');
243
+ const parts = [section('Would delete', facts)];
244
+ const steps = stepsOf(r);
245
+ if (steps.length) {
246
+ const ul = el('ul', undefined, 'del-steps');
247
+ for (const st of steps) {
248
+ const li = el('li', undefined, 'del-step');
249
+ li.append(el('i', undefined, 'lamp ' + (st.ok ? 'st-pass' : 'st-fail')), el('span', st.name, 'mono del-step-name'), el('span', st.detail, 'muted'));
250
+ ul.append(li);
251
+ }
252
+ parts.push(section('Steps', ul));
253
+ }
254
+ const wts = list(r.worktrees).filter(object);
255
+ if (wts.length) {
256
+ const ul = el('ul', undefined, 'del-list');
257
+ for (const w of wts) { const pl = worktreePlate(w); row(ul, pl, text(w.path) + (w.branch ? ' ' + text(w.branch) : ''), pl.note || text(w.note)); }
258
+ parts.push(section('Worktrees', ul));
259
+ }
260
+ const brs = list(r.branches).filter(object);
261
+ if (brs.length) {
262
+ const ul = el('ul', undefined, 'del-list');
263
+ for (const b of brs) { const pl = branchPlate(b, s.v); row(ul, pl, text(b.name), pl.note || text(b.note)); }
264
+ parts.push(section('Branches', ul));
265
+ }
266
+ previewHost.replaceChildren(...parts);
267
+ }
268
+
269
+ function drawWarnings() {
270
+ const ws = warningsOf(s.preview && s.preview.result, s.v);
271
+ warnHost.replaceChildren(...ws.map(w => {
272
+ const li = el('li', undefined, 'del-warning del-warning-' + w.tone);
273
+ li.append(el('i', undefined, 'lamp ' + (w.tone === 'warn' ? 'st-fail' : 'st-running')), el('span', w.text));
274
+ return li;
275
+ }));
276
+ if (s.preview && !s.preview.completes) {
277
+ const li = el('li', undefined, 'del-warning del-warning-warn');
278
+ li.append(el('i', undefined, 'lamp st-fail'), el('span', 'The dry run says a real delete would not complete' + (s.preview.note ? ': ' + s.preview.note : '') + '. See the steps above.'));
279
+ warnHost.append(li);
280
+ }
281
+ }
282
+
283
+ function syncAll() {
284
+ for (const name of OPTIONS) boxes[name].checked = s.v[name];
285
+ for (const b of Object.values(boxes)) b.disabled = s.posting || s.done;
286
+ confirmInput.disabled = s.posting || s.done || !s.preview;
287
+ go.disabled = s.done || !canDelete(s.v, s.preview, s.loading || s.posting);
288
+ go.textContent = s.posting ? 'Deleting…' : 'Delete';
289
+ go.hidden = s.done;
290
+ retry.hidden = s.done || s.loading || s.posting || !!s.preview;
291
+ cancel.textContent = s.done ? 'Close' : 'Cancel';
292
+ cancel.disabled = s.posting;
293
+ drawWarnings();
294
+ }
295
+
296
+ // The dry run for the options as they are now. A later call makes an earlier answer stale.
297
+ async function load(keepResult) {
298
+ const seq = ++s.seq;
299
+ s.loading = true; s.preview = null;
300
+ if (!keepResult) resultHost.replaceChildren();
301
+ previewHost.replaceChildren(); setLamp('st-running', 'Checking what would be deleted…'); syncAll();
302
+ // Only one request per run at a time (the server answers a second one 'busy'), and only the latest options are sent.
303
+ let res = null;
304
+ for (let i = 0; ; i++) {
305
+ res = await gate(() => seq === s.seq && !s.done ? UI.post(deletePath(run.key), bodyOf(s.v, true)) : null);
306
+ if (seq !== s.seq || s.done) return;
307
+ if (!isLockBusy(res) || i >= RETRIES) break;
308
+ await sleep(RETRY_MS);
309
+ if (seq !== s.seq || s.done) return;
310
+ }
311
+ s.loading = false;
312
+ const p = previewOf(res);
313
+ if (p.kind === 'preview') {
314
+ s.preview = p;
315
+ // A plan dir without a run_id file is named by its directory; the CLI says which id it confirms against.
316
+ if (!s.v.runId && text(p.result.run_id)) { s.v = { ...s.v, runId: text(p.result.run_id) }; idCode.textContent = s.v.runId; }
317
+ setLamp(p.completes ? 'st-pass' : 'st-fail', p.completes ? 'Dry run: nothing has been changed yet.' : 'Dry run: nothing has been changed yet, but it would not complete.');
318
+ drawPreview();
319
+ } else {
320
+ setLamp('st-fail', p.kind === 'busy' ? p.text : 'Could not check: ' + (res.status === 404 ? 'this run no longer exists' : p.text));
321
+ }
322
+ syncAll();
323
+ }
324
+
325
+ function showResult(o, res) {
326
+ resultHost.replaceChildren();
327
+ if (o.kind === 'error') { UI.outcome(resultHost, o.refused ? { ...res, detail: o.text } : res); return; }
328
+ if (o.kind === 'busy') { UI.outcome(resultHost, { ok: false, status: 0, error: '', detail: 'Not deleted: ' + o.text }); return; }
329
+ const box = el('div', undefined, 'del-outcome');
330
+ box.setAttribute('role', 'status'); box.setAttribute('aria-live', 'polite');
331
+ const head = el('p', undefined, 'del-outcome-head');
332
+ head.append(el('i', undefined, 'lamp ' + (o.kind === 'success' ? 'st-pass' : 'st-fail')),
333
+ el('strong', o.kind === 'success' ? 'Run deleted' : 'Delete did not complete: ' + o.failed.length + ' step' + (o.failed.length === 1 ? '' : 's') + ' failed'));
334
+ box.append(head);
335
+ const ul = el('ul', undefined, 'del-steps');
336
+ for (const st of o.steps) {
337
+ const li = el('li', undefined, 'del-step');
338
+ li.append(el('i', undefined, 'lamp ' + (st.ok ? 'st-pass' : 'st-fail')), el('span', st.name, 'mono del-step-name'), el('span', st.detail, st.ok ? 'muted' : 'del-step-fail'));
339
+ ul.append(li);
340
+ }
341
+ box.append(ul);
342
+ if (o.kind === 'success' && o.result.archive) box.append(el('p', 'Archive: ' + text(o.result.archive), 'mono del-archive'));
343
+ if (o.kind === 'partial') {
344
+ if (o.guidance) box.append(el('p', o.guidance, 'del-guidance'));
345
+ box.append(el('p', 'Fix what failed, then delete again: the same command finishes the job.', 'muted'));
346
+ }
347
+ resultHost.append(box);
348
+ }
349
+
350
+ async function submit() {
351
+ if (!canDelete(s.v, s.preview, s.loading || s.posting)) return;
352
+ s.posting = true; syncAll();
353
+ const res = await gate(() => UI.post(deletePath(run.key), bodyOf(s.v, false)));
354
+ s.posting = false;
355
+ const o = outcomeOf(res);
356
+ showResult(o, res);
357
+ if (o.kind === 'success') {
358
+ s.done = true; s.seq++;
359
+ setLamp('st-pass', 'Deleted.');
360
+ previewHost.replaceChildren(); warnHost.replaceChildren();
361
+ syncAll(); cancel.focus();
362
+ if (c.onDeleted) c.onDeleted(run);
363
+ return;
364
+ }
365
+ s.v = withTyped(s.v, ''); confirmInput.value = '';
366
+ if (o.kind === 'partial') load(true); // what is left now; the result stays on screen
367
+ else syncAll();
368
+ }
369
+
370
+ confirmInput.addEventListener('input', () => { s.v = withTyped(s.v, confirmInput.value); syncAll(); });
371
+ confirmInput.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); if (!go.disabled) submit(); } });
372
+ go.addEventListener('click', submit);
373
+ retry.addEventListener('click', () => { load(); });
374
+ cancel.addEventListener('click', () => { if (!s.posting) dlg.close(); });
375
+ dlg.addEventListener('cancel', e => { if (s.posting) e.preventDefault(); });
376
+ dlg.addEventListener('click', e => { if (e.target === dlg && !s.posting && outsideBox(dlg.getBoundingClientRect(), e.clientX, e.clientY)) dlg.close(); });
377
+ dlg.addEventListener('close', () => { s.seq++; dlg.remove(); if (current && current.dlg === dlg) current = null; });
378
+
379
+ document.body.append(dlg);
380
+ current = { dlg, close: () => { if (!s.posting) dlg.close(); } };
381
+ dlg.showModal();
382
+ syncAll(); load();
383
+ return dlg;
384
+ }
385
+
386
+ return { open, deleteBody, initial, withOption, withTyped, bodyOf, confirmOk, canDelete, warningsOf, worktreePlate, branchPlate,
387
+ stepsOf, outcomeOf, previewOf, reasonOf, outsideBox, fmtBytes, deletePath, isLockBusy, serializer, OPTIONS };
388
+ });
@@ -508,6 +508,22 @@
508
508
  if (!s.length) return null;
509
509
  return { start: Math.min(...s), end: Math.max(...e, ...s) };
510
510
  }
511
+ // A task's route through the plan: the tasks it waits on, however indirectly (ancestors), and the tasks that wait
512
+ // on it (descendants). Unknown ids and dependency cycles end the walk instead of looping.
513
+ function lineage(run, id) {
514
+ const up = new Map(), down = new Map();
515
+ for (const t of list(run && run.tasks)) {
516
+ if (!t || t.id == null) continue;
517
+ up.set(t.id, list(t.deps));
518
+ for (const d of list(t.deps)) { if (!down.has(d)) down.set(d, []); down.get(d).push(t.id); }
519
+ }
520
+ const walk = next => {
521
+ const out = new Set(), stack = [...(next.get(id) || [])];
522
+ while (stack.length) { const x = stack.pop(); if (x === id || out.has(x) || !up.has(x)) continue; out.add(x); stack.push(...(next.get(x) || [])); }
523
+ return out;
524
+ };
525
+ return { ancestors: walk(up), descendants: walk(down) };
526
+ }
511
527
  // Events worth flashing during a replay: attempts ending, escalations, merges, reviews.
512
528
  function events(run) {
513
529
  const out = [];
@@ -646,6 +662,6 @@
646
662
  }
647
663
 
648
664
  return { liveNow, runState, execStatus, matchRun, filterOptions, atlasModel, columns, planModel, finishState, taskParts,
649
- taskTimes, metricValue, overlap, projectSchedule, execModel, criticalPath, concurrency, stateAt, timeRange, events, structureKey, innerKey,
665
+ taskTimes, metricValue, overlap, projectSchedule, execModel, criticalPath, concurrency, stateAt, timeRange, events, lineage, structureKey, innerKey,
650
666
  fmtDur, alias, basename, weight, searchIndex, searchRank, statusEvents, fractalTree };
651
667
  });