@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,619 @@
1
+ /* forge-ui controls: the run and task panels that change a live forge run (window.ForgeControls, and a CommonJS
2
+ module under node:test). panel.js mounts them in the inspector; every change goes through ForgeUI (uikit.js) to
3
+ POST /api/runs/<key>/controls, which only ever calls `forge control` (ui/docs/api.md, "Controls (write endpoints)").
4
+ The first half is pure: what a control shows (effective), whether it can change (canEdit), the request a draft
5
+ becomes (prepare, bodyFor), and whether a rebuilt inspector takes a half-edited section back (keepEditing).
6
+ The second half draws the keys and fields; all API values enter the DOM as text. */
7
+ (function (root, factory) {
8
+ 'use strict';
9
+ const api = factory(root);
10
+ if (typeof module === 'object' && module.exports) module.exports = api;
11
+ else root.ForgeControls = api;
12
+ })(typeof self !== 'undefined' ? self : this, function (root) {
13
+ 'use strict';
14
+
15
+ const LEVELS = ['P0', 'P1', 'P2', 'P3'];
16
+ const EFFORTS = ['default', 'low', 'medium', 'high', 'xhigh', 'max'];
17
+ const MAX_FIELD = 256; // control.py: a body string is at most this long
18
+ const LIVE_ONLY = 'only while a run is in progress';
19
+ const PIN_APPLIES = 'at the next full retry, not a QA-only resume';
20
+ // The CLI's keys (scripts/forge-control.py KEYS): the label, when a change applies, and where it can be set.
21
+ const KEYS = {
22
+ 'qa-threshold': { label: 'QA threshold', run: "at each task's next attempt", task: 'at its next attempt' },
23
+ 'timeout': { label: 'Timeout', run: "at each task's next attempt", planOnly: true },
24
+ 'escalate-after': { label: 'Escalate after', run: 'at the next retry decision', planOnly: true },
25
+ 'verify-retries': { label: 'Verify retries', run: 'at the next verification', planOnly: true },
26
+ 'retry-budget': { label: 'Retry budget', run: 'at the next retry decision', task: 'at the next retry decision', live: true },
27
+ 'infra-retries': { label: 'Infra retries', run: 'at the next retry decision', planOnly: true, live: true },
28
+ };
29
+
30
+ const obj = v => v && typeof v === 'object' && !Array.isArray(v) ? v : {};
31
+ const num = v => typeof v === 'number' && Number.isFinite(v) ? v : null;
32
+ const bad = error => ({ ok: false, error });
33
+
34
+ /* ---------- pure: values, specs, requests ---------- */
35
+
36
+ // The model spec alias[:effort[:harness]] from its three parts. The alias is required, the effort one of EFFORTS
37
+ // ('default' leaves it out), the harness optional; whitespace or ':' inside a part is refused, and a leading '-'
38
+ // (control.py takes none: the CLI would read it as an option).
39
+ function specOf(parts) {
40
+ const p = parts || {};
41
+ const alias = String(p.alias === undefined || p.alias === null ? '' : p.alias).trim();
42
+ const effort = String(p.effort === undefined || p.effort === null ? '' : p.effort).trim();
43
+ const harness = String(p.harness === undefined || p.harness === null ? '' : p.harness).trim();
44
+ const unsafe = s => /[\s:\u0000-\u001f\u007f]/.test(s);
45
+ if (!alias) return bad('a model alias is required');
46
+ if (unsafe(alias)) return bad('the alias cannot hold spaces or ":"');
47
+ if (alias.startsWith('-')) return bad('the alias cannot start with "-"');
48
+ if (effort && !EFFORTS.includes(effort)) return bad('the effort is one of ' + EFFORTS.join(', '));
49
+ if (unsafe(harness)) return bad('the harness cannot hold spaces or ":"');
50
+ const e = effort === 'default' ? '' : effort;
51
+ const spec = harness ? `${alias}:${e}:${harness}` : e ? `${alias}:${e}` : alias;
52
+ if (spec.length > MAX_FIELD) return bad(`the spec is longer than ${MAX_FIELD} characters`);
53
+ return { ok: true, spec };
54
+ }
55
+
56
+ // A count the CLI takes: 1 to 9 digits (control.py `_int`).
57
+ function countOf(text) {
58
+ const t = String(text === undefined || text === null ? '' : text).trim();
59
+ return /^[0-9]{1,9}$/.test(t) ? { ok: true, value: String(Number(t)) } : bad('needs a whole number, 0 to 999999999');
60
+ }
61
+ // Minutes in the UI, whole seconds to the CLI. 0 turns the dispatch watchdog off (forge-dispatch.sh --timeout 0).
62
+ function secondsOf(text) {
63
+ const t = String(text === undefined || text === null ? '' : text).trim();
64
+ if (!/^[0-9]+(\.[0-9]+)?$/.test(t)) return bad('needs minutes, such as 45 or 7.5');
65
+ const s = Math.round(Number(t) * 60);
66
+ return s <= 999999999 ? { ok: true, value: String(s) } : bad('that is too many minutes');
67
+ }
68
+ const minutesOf = seconds => {
69
+ const s = num(seconds);
70
+ return s === null ? null : String(Math.round(s / 60 * 100) / 100);
71
+ };
72
+
73
+ // What the user typed (or pressed) for a key, as the CLI takes it. `task` is set for a task-level change.
74
+ function valueFor(key, input, task) {
75
+ const info = KEYS[key];
76
+ if (!info) return bad('unknown control');
77
+ if (task && info.planOnly) return bad(`${info.label} applies to the whole run, not a task`);
78
+ const text = String(input === undefined || input === null ? '' : input).trim();
79
+ if (key === 'qa-threshold') {
80
+ return LEVELS.includes(text) || text === 'none' || (task && text === 'inherit') ? { ok: true, value: text } : bad('P0, P1, P2, P3 or none');
81
+ }
82
+ if (task && key === 'retry-budget' && text === 'inherit') return { ok: true, value: 'inherit' };
83
+ return key === 'timeout' ? secondsOf(text) : countOf(text);
84
+ }
85
+
86
+ // The JSON body of POST /api/runs/<key>/controls (ui/docs/api.md). `value` is always a string.
87
+ function bodyFor(action, a) {
88
+ if (action === 'set') {
89
+ const body = { action: 'set', key: a.key, value: String(a.value) };
90
+ if (a.task) body.task = a.task;
91
+ return body;
92
+ }
93
+ if (action === 'pin') return { action: 'pin', task: a.task, spec: a.spec };
94
+ if (action === 'unpin') return { action: 'unpin', task: a.task };
95
+ throw new Error('unknown action ' + action);
96
+ }
97
+
98
+ const shown = (key, value, task) => key === 'timeout' ? `${minutesOf(Number(value))} min`
99
+ : key === 'qa-threshold' && value === 'none' && task ? 'none (no threshold for this task)' : value;
100
+ const inTask = task => task ? ` for task \`${task}\`` : '';
101
+
102
+ // A draft change → {ok, body, confirm} or {ok: false, error}. change: {kind: 'set', key, input, task?} |
103
+ // {kind: 'pin', task, alias, effort, harness} | {kind: 'unpin', task}.
104
+ function prepare(change) {
105
+ const c = change || {};
106
+ if (c.kind === 'set') {
107
+ const v = valueFor(c.key, c.input, c.task);
108
+ if (!v.ok) return v;
109
+ const info = KEYS[c.key], when = c.task ? info.task || info.run : info.run;
110
+ return { ok: true, body: bodyFor('set', { key: c.key, value: v.value, task: c.task }),
111
+ confirm: `Set ${info.label}${inTask(c.task)} to ${shown(c.key, v.value, c.task)}? Takes effect ${when}` };
112
+ }
113
+ if (c.kind === 'pin') {
114
+ if (!c.task) return bad('no task');
115
+ const s = specOf(c);
116
+ return s.ok ? { ok: true, body: bodyFor('pin', { task: c.task, spec: s.spec }),
117
+ confirm: `Pin task \`${c.task}\`'s next retry to \`${s.spec}\`? Takes effect ${PIN_APPLIES}` } : s;
118
+ }
119
+ if (c.kind === 'unpin') {
120
+ return c.task ? { ok: true, body: bodyFor('unpin', { task: c.task }),
121
+ confirm: `Remove the pin on task \`${c.task}\`? Its next retry goes back to the escalation policy` } : bad('no task');
122
+ }
123
+ return bad('unknown change');
124
+ }
125
+
126
+ // The sentence after a change went through, from the CLI's answer ({changed, applies}).
127
+ function savedText(data) {
128
+ const d = obj(data), rec = obj(d.changed);
129
+ if (d.changed && String(rec.previous === undefined || rec.previous === null ? '' : rec.previous) === String(rec.value === undefined || rec.value === null ? '' : rec.value)) {
130
+ return 'Nothing changed: it was already so.';
131
+ }
132
+ return typeof d.applies === 'string' && d.applies ? `Saved. Applies ${d.applies}.` : 'Saved.';
133
+ }
134
+
135
+ // The run's controls as they stand (`forge control show`'s `run`): what each shows.
136
+ function runValues(controls) {
137
+ const c = obj(controls), run = obj(c.run);
138
+ return { live: c.live === true, fractal: c.fractal === true,
139
+ qa: LEVELS.includes(run.qa_threshold) ? run.qa_threshold : 'none',
140
+ timeout: num(run.timeout), timeoutMin: minutesOf(run.timeout),
141
+ escalateAfter: num(run.escalate_after), verifyRetries: num(run.verify_retries),
142
+ retryBudget: num(run.retry_budget), infraBudget: num(run.infra_budget) };
143
+ }
144
+ // A task's controls with the effective value of each: an override wins, else the run's value is inherited.
145
+ function taskValues(controls, id) {
146
+ const c = obj(controls), t = obj(obj(c.tasks)[id]), run = runValues(c);
147
+ const level = t.qa_threshold;
148
+ const threshold = LEVELS.includes(level) ? { mode: level, effective: level, from: 'task' }
149
+ : level === 'none' ? { mode: 'none', effective: 'none', from: 'task' }
150
+ : { mode: 'inherit', effective: run.qa, from: 'run' };
151
+ const own = num(t.retry_budget), eff = own !== null ? own : run.retryBudget;
152
+ return { id, known: !!c.tasks && id in c.tasks, status: typeof t.status === 'string' ? t.status : 'PENDING',
153
+ dwarf: t.dwarf || null, qa: t.qa || null, threshold,
154
+ retry: { mode: own !== null ? 'set' : 'inherit', own, effective: eff, used: num(t.retries_used) || 0, from: own !== null ? 'task' : eff !== null ? 'run' : null },
155
+ pin: t.pin || null, next: t.escalation_next || null, stop: t.escalation_stop === true, run };
156
+ }
157
+ const effective = (controls, id) => id === undefined ? runValues(controls) : taskValues(controls, id);
158
+
159
+ // Whether a control can be changed now: {enabled, why, hidden}. Retry and infra budgets only while a scheduler is
160
+ // live; a merged task has nothing left to change; Fractal plans never apply pins (the control is hidden).
161
+ function canEdit(controls, key, id) {
162
+ const c = obj(controls), info = KEYS[key];
163
+ if (key === 'pin') {
164
+ if (c.fractal === true) return { enabled: false, hidden: true, why: 'Fractal plans never apply pins' };
165
+ if (id !== undefined && taskValues(c, id).status === 'MERGED') return { enabled: false, hidden: false, why: 'merged: nothing left to change' };
166
+ return { enabled: true, hidden: false, why: null };
167
+ }
168
+ if (!info) return { enabled: false, hidden: false, why: 'unknown control' };
169
+ if (id !== undefined && taskValues(c, id).status === 'MERGED') return { enabled: false, hidden: false, why: 'merged: nothing left to change' };
170
+ if (info.live && c.live !== true) return { enabled: false, hidden: false, why: LIVE_ONLY };
171
+ return { enabled: true, hidden: false, why: null };
172
+ }
173
+
174
+ // One line of the change log (`control.log`): who, what, previous → new, when.
175
+ function logLine(entry) {
176
+ const e = obj(entry), key = typeof e.key === 'string' ? e.key : '';
177
+ const label = e.action === 'pin' || e.action === 'unpin' || key === 'pin' ? 'Pin' : KEYS[key] ? KEYS[key].label : key || String(e.action || '?');
178
+ const empty = key === 'pin' ? 'no pin' : 'unset';
179
+ const show = v => v === null || v === undefined || v === '' ? empty : key === 'timeout' && minutesOf(Number(v)) !== null ? `${minutesOf(Number(v))} min` : String(v);
180
+ return { who: String(e.actor || '?'), what: e.task ? `${label} · ${e.task}` : label, from: show(e.previous), to: show(e.value), at: num(e.at) };
181
+ }
182
+ function agoText(at, now) {
183
+ const s = num(at) === null ? null : Math.max(0, Math.floor(now - at));
184
+ if (s === null) return '—';
185
+ return s < 60 ? 'just now' : s < 3600 ? `${Math.floor(s / 60)}m ago` : s < 86400 ? `${Math.floor(s / 3600)}h ago` : `${Math.floor(s / 86400)}d ago`;
186
+ }
187
+
188
+ // The implementer aliases seen in runs (GET /api/models), for the pin's suggestions.
189
+ function aliasesOf(models) {
190
+ const out = new Set();
191
+ for (const t of Array.isArray(obj(models).tasks) ? models.tasks : []) {
192
+ for (const a of Array.isArray(obj(t).attempts) ? t.attempts : []) if (a && typeof a.alias === 'string' && a.alias) out.add(a.alias);
193
+ }
194
+ return Array.from(out).sort();
195
+ }
196
+
197
+ /* ---------- pure: an edit in progress survives a rebuild of the inspector ---------- */
198
+
199
+ // Every section is one run (and task): `ctl:<run>:run` or `ctl:<run>:task:<id>`.
200
+ const scopeOf = (runKey, taskId) => `ctl:${runKey}:${taskId ? 'task:' + taskId : 'run'}`;
201
+ // A text field has the focus: typing is under way. A key (button) with the focus is not an edit: nothing is half typed.
202
+ const typing = tag => /^(input|textarea|select)$/i.test(String(tag || ''));
203
+ // A section is being edited while a text field has focus, holds an unsaved value, a confirm is open or a request is out.
204
+ const isEditing = s => !!(s && (s.focus || s.dirty || s.confirm || s.busy));
205
+ // Whether the rebuilt tree takes back the section that was being edited: only for the same run and task. Choosing
206
+ // another run or task builds another scope, so what was half typed goes with it.
207
+ const keepEditing = (held, next) => !!(held && next && held.editing && held.scope && held.scope === next.scope);
208
+ // Controls that arrived while a section was being edited were held back: they are drawn once the edit is over and
209
+ // the keyboard has left the section (a redraw under a pressed key would swallow its click).
210
+ const redrawDue = (drawn, latest, editing, inside) => !!drawn && !!latest && drawn !== latest && !editing && !inside;
211
+
212
+ /* ---------- the page: drawing ---------- */
213
+
214
+ const store = { enabled: null, runs: new Map(), models: null, modelsAsked: false, lists: new Set(), recent: new Map() };
215
+ const RECENT_MS = 9000; // how long a result line stays after the section is rebuilt
216
+ const STALE_MS = 20000;
217
+ let seq = 0;
218
+ const ui = () => root.ForgeUI;
219
+ const doc = () => root.document;
220
+ const path = key => `/api/runs/${encodeURIComponent(key)}/controls`;
221
+ const plateOf = spec => root.ForgeIcons ? root.ForgeIcons.modelPlate(spec) : ui().el('span', spec, 'mono');
222
+ const textNode = t => doc().createTextNode(t);
223
+
224
+ function sync(view) {
225
+ const s = view.section;
226
+ const at = doc().activeElement;
227
+ const on = isEditing({ focus: !!at && s.contains(at) && typing(at.tagName), dirty: Object.keys(view.draft).length > 0,
228
+ confirm: !!s.querySelector('.ui-confirm'), busy: view.inflight });
229
+ if (on) s.dataset.editing = '1'; else delete s.dataset.editing;
230
+ const e = store.runs.get(view.run.key);
231
+ if (s.isConnected && redrawDue(view.drawn, e && e.data, on, !!at && s.contains(at))) show(view);
232
+ }
233
+ // A draft value: the field's text while it differs from what the run holds.
234
+ function touch(view, field, value) {
235
+ if (value === view.base[field]) delete view.draft[field]; else view.draft[field] = value;
236
+ sync(view);
237
+ }
238
+ const field = (view, name, base) => {
239
+ view.base[name] = base;
240
+ if (view.draft[name] === base) delete view.draft[name];
241
+ return name in view.draft ? view.draft[name] : base;
242
+ };
243
+
244
+ function note(view, text) {
245
+ view.drawn = null;
246
+ view.section.replaceChildren(ui().el('h3', 'Controls'), ui().el('p', text, 'muted ctl-note'));
247
+ sync(view);
248
+ }
249
+
250
+ // A row: its label, the control, notes, and slots for the confirm strip and the result line.
251
+ function shell(view, key, label, forInput) {
252
+ const u = ui(), id = 'ctl-' + (++seq), r = { key, id };
253
+ r.box = u.el('div', undefined, 'ctl-row');
254
+ r.box.dataset.row = key;
255
+ r.name = u.el(forInput ? 'label' : 'span', label, 'ctl-label');
256
+ r.name.id = id;
257
+ if (forInput) r.name.htmlFor = id + '-in';
258
+ r.status = u.el('div', undefined, 'ctl-status');
259
+ r.control = u.el('div', undefined, 'ctl-control');
260
+ r.notes = u.el('div', undefined, 'ctl-notes');
261
+ r.confirm = u.el('div', undefined, 'ctl-confirm');
262
+ r.out = u.el('div', undefined, 'ctl-out');
263
+ r.box.append(r.name, r.status, r.control, r.notes, r.confirm, r.out);
264
+ const recent = store.recent.get(view.section.dataset.hold + '|' + key);
265
+ if (recent && Date.now() - recent.at < RECENT_MS) u.outcome(r.out, recent.res, recent.okText, { silent: true });
266
+ return r;
267
+ }
268
+ const help = (r, text, cls) => r.notes.append(ui().el('p', text, 'ctl-help' + (cls ? ' ' + cls : '')));
269
+ const why = (r, text) => { if (text) r.notes.append(ui().el('p', text, 'ctl-why')); };
270
+
271
+ // A bank of push keys (design: a push-button bank whose lamp lights on the pressed key).
272
+ function bank(r, options, current, enabled, pick) {
273
+ const seg = ui().el('div', undefined, 'seg ctl-bank');
274
+ seg.setAttribute('role', 'group');
275
+ seg.setAttribute('aria-labelledby', r.id);
276
+ for (const o of options) {
277
+ const b = ui().el('button', o.label);
278
+ b.type = 'button'; b.dataset.value = o.value; b.disabled = !enabled;
279
+ b.setAttribute('aria-pressed', String(o.value === current));
280
+ if (o.title) b.title = o.title;
281
+ b.addEventListener('click', () => pick(o.value, b));
282
+ seg.append(b);
283
+ }
284
+ return seg;
285
+ }
286
+
287
+ // Ask, send, show the answer. A success redraws every row from the controls the answer carries.
288
+ async function commit(view, r, change, fields, button) {
289
+ const u = ui();
290
+ if (view.inflight) { u.outcome(r.out, { ok: false, status: 409, error: 'busy' }); return; }
291
+ clearOutcome(view, r);
292
+ const plan = prepare(change);
293
+ if (!plan.ok) { remember(view, r, { ok: false, status: 400, error: 'invalid', detail: plan.error }); return; }
294
+ if (button) button.classList.add('is-draft');
295
+ const yes = await u.confirmInline(r.confirm, plan.confirm);
296
+ if (button) button.classList.remove('is-draft');
297
+ if (!yes) { sync(view); return; }
298
+ view.inflight = true; view.section.setAttribute('aria-busy', 'true'); sync(view);
299
+ const res = await u.post(path(view.run.key), plan.body);
300
+ view.inflight = false; view.section.removeAttribute('aria-busy');
301
+ remember(view, r, res, savedText(res.data));
302
+ if (res.ok && obj(res.data).controls && res.data.controls.run) {
303
+ for (const f of fields) delete view.draft[f];
304
+ accept(view.run.key, res.data.controls, view.run.updated);
305
+ draw(view, res.data.controls);
306
+ } else sync(view);
307
+ }
308
+ // Where the keyboard is inside a section, as something that survives the rows being drawn again.
309
+ function focusMark(section) {
310
+ const at = doc().activeElement;
311
+ if (!at || !section.contains(at) || !at.closest) return null;
312
+ const row = at.closest('[data-row]');
313
+ return row ? { row: row.dataset.row, tag: at.tagName, value: at.dataset.value, text: at.tagName === 'BUTTON' ? at.textContent : null } : null;
314
+ }
315
+ // The rows are drawn again (after a change, or a poll): the keyboard goes back to the same key, else to the row's field.
316
+ function refocus(section, mark) {
317
+ const row = mark && Array.from(section.querySelectorAll('[data-row]')).find(n => n.dataset.row === mark.row);
318
+ if (!row) return;
319
+ const live = Array.from(row.querySelectorAll('button:not(:disabled), input:not(:disabled)'));
320
+ const target = live.find(n => n.tagName === mark.tag && n.dataset.value === mark.value && (mark.text === null || n.textContent === mark.text))
321
+ || live.find(n => n.tagName === 'INPUT') || live[0];
322
+ if (target) target.focus({ preventScroll: true });
323
+ }
324
+ // A result line belongs to the edit it answered: a new edit clears it.
325
+ function clearOutcome(view, r) {
326
+ store.recent.delete(view.section.dataset.hold + '|' + r.key);
327
+ Array.from(r.out.children).forEach(c => { if (c.classList.contains('ui-outcome')) c.remove(); });
328
+ }
329
+ function remember(view, r, res, okText) {
330
+ store.recent.set(view.section.dataset.hold + '|' + r.key, { res, okText, at: Date.now() });
331
+ ui().outcome(r.out, res, okText);
332
+ }
333
+
334
+ function liveLamp(view, c) {
335
+ const u = ui(), live = c.live === true, box = u.el('p', undefined, 'ctl-live');
336
+ box.append(u.el('i', undefined, 'lamp ' + (live ? 'st-running' : 'st-pending')),
337
+ u.el('span', live ? 'live' : 'not running', 'plate'), u.el('span', live ? 'a scheduler is running this plan' : 'no scheduler is running this plan', 'muted'));
338
+ return box;
339
+ }
340
+
341
+ // An integer or minutes setting: a field and a Set key; Enter sets.
342
+ function countRow(view, c, o) {
343
+ const u = ui(), r = shell(view, o.key, o.label, true), perm = o.perm;
344
+ const form = u.el('form', undefined, 'ctl-set');
345
+ form.noValidate = true;
346
+ const input = u.el('input');
347
+ input.type = 'text'; input.inputMode = o.decimal ? 'decimal' : 'numeric'; input.autocomplete = 'off'; input.spellcheck = false;
348
+ input.id = r.id + '-in'; input.placeholder = o.placeholder || ''; input.disabled = !perm.enabled;
349
+ const name = o.key + (o.task ? '@' + o.task : '');
350
+ input.value = field(view, name, o.value === null ? '' : String(o.value));
351
+ const set = u.el('button', 'Set'); set.type = 'submit';
352
+ const settle = () => { set.disabled = !perm.enabled || input.value === view.base[name]; };
353
+ settle();
354
+ input.addEventListener('input', () => { touch(view, name, input.value); settle(); clearOutcome(view, r); });
355
+ form.append(input);
356
+ if (o.unit) form.append(u.el('span', o.unit, 'ctl-unit'));
357
+ if (o.inherit) {
358
+ const inh = u.el('button', 'Inherit'); inh.type = 'button'; inh.disabled = !perm.enabled || o.inherit === 'pressed';
359
+ inh.setAttribute('aria-pressed', String(o.inherit === 'pressed')); inh.title = "Drop this task's own value and use the run's";
360
+ inh.addEventListener('click', () => commit(view, r, { kind: 'set', key: o.key, input: 'inherit', task: o.task }, [name], inh));
361
+ form.append(set, inh);
362
+ } else form.append(set);
363
+ form.addEventListener('submit', e => {
364
+ e.preventDefault();
365
+ if (!set.disabled) commit(view, r, { kind: 'set', key: o.key, input: input.value, task: o.task }, [name], set);
366
+ });
367
+ r.control.append(form);
368
+ if (o.help) help(r, o.help);
369
+ if (!perm.enabled) why(r, perm.why);
370
+ return r.box;
371
+ }
372
+
373
+ function levelRow(view, c, o) {
374
+ const r = shell(view, o.key, o.label, false), perm = o.perm;
375
+ const options = (o.inherit ? [{ value: 'inherit', label: 'Inherit', title: "Use the run's threshold" }] : [])
376
+ .concat(LEVELS.map(l => ({ value: l, label: l })), [{ value: 'none', label: 'None', title: o.noneTitle }]);
377
+ r.control.append(bank(r, options, o.current, perm.enabled, (value, button) => {
378
+ if (value !== o.current) commit(view, r, { kind: 'set', key: 'qa-threshold', input: value, task: o.task }, [], button);
379
+ }));
380
+ help(r, o.help);
381
+ if (!perm.enabled) why(r, perm.why);
382
+ return r.box;
383
+ }
384
+
385
+ function changeLog(c) {
386
+ const u = ui(), log = Array.isArray(c.log) ? c.log : [];
387
+ const box = u.el('div', undefined, 'ctl-log');
388
+ box.append(u.el('span', 'Last changes', 'ctl-label'));
389
+ if (!log.length) { box.append(u.el('p', 'none yet', 'muted ctl-help')); return box; }
390
+ const ul = u.el('ul', undefined, 'ctl-changes'), now = Date.now() / 1000;
391
+ for (const entry of log.slice(0, 5)) {
392
+ const l = logLine(entry), li = u.el('li');
393
+ const when = u.el('span', agoText(l.at, now), 'muted');
394
+ if (l.at !== null) when.title = new Date(l.at * 1000).toLocaleString();
395
+ li.append(u.el('span', l.who, 'tag'), u.el('span', l.what, 'ctl-what'), u.el('span', `${l.from} → ${l.to}`, 'mono'), when);
396
+ ul.append(li);
397
+ }
398
+ box.append(ul);
399
+ return box;
400
+ }
401
+
402
+ function drawRun(view, c) {
403
+ const v = runValues(c), body = ui().el('div', undefined, 'ctl-body');
404
+ const perm = key => canEdit(c, key);
405
+ body.append(liveLamp(view, c));
406
+ body.append(levelRow(view, c, { key: 'qa-threshold', label: 'QA threshold', current: v.qa, perm: perm('qa-threshold'),
407
+ help: v.qa === 'none' ? 'The least severe finding that still blocks: no threshold is set.'
408
+ : `The least severe finding that still blocks: ${v.qa} (${LEVELS.slice(0, LEVELS.indexOf(v.qa) + 1).join(', ')} block).`, noneTitle: 'No threshold' }));
409
+ body.append(countRow(view, c, { key: 'timeout', label: 'Timeout', unit: 'minutes', decimal: true, value: v.timeoutMin, placeholder: 'default', perm: perm('timeout'),
410
+ help: v.timeoutMin === null ? "Not set: each model's own limit applies. 0 turns the limit off." : `Each model call stops after ${v.timeoutMin} min. 0 turns the limit off.` }));
411
+ body.append(countRow(view, c, { key: 'escalate-after', label: 'Escalate after', unit: 'failures', value: v.escalateAfter, perm: perm('escalate-after'),
412
+ help: 'Failed attempts before the policy moves a task to a stronger model. 0 turns escalation off.' }));
413
+ body.append(countRow(view, c, { key: 'verify-retries', label: 'Verify retries', unit: 'retries', value: v.verifyRetries, perm: perm('verify-retries'),
414
+ help: 'Times verification is retried after it fails.' }));
415
+ body.append(countRow(view, c, { key: 'retry-budget', label: 'Retry budget', unit: 'retries', value: v.retryBudget, placeholder: 'not set', perm: perm('retry-budget'),
416
+ help: 'Retries a task may use, run-wide.' }));
417
+ body.append(countRow(view, c, { key: 'infra-retries', label: 'Infra retries', unit: 'retries', value: v.infraBudget, placeholder: 'not set', perm: perm('infra-retries'),
418
+ help: 'Retries for infrastructure failures (auth, quota, network), run-wide.' }));
419
+ body.append(changeLog(c));
420
+ return body;
421
+ }
422
+
423
+ function drawTask(view, c, id) {
424
+ const u = ui(), v = taskValues(c, id), body = u.el('div', undefined, 'ctl-body');
425
+ body.append(liveLamp(view, c));
426
+ if (!v.known) { body.append(u.el('p', 'This task is not in the plan\'s controls yet.', 'muted ctl-note')); return body; }
427
+ if (v.status === 'MERGED') { body.append(u.el('p', 'Merged: there is nothing left to change for this task.', 'muted ctl-note')); return body; }
428
+ const th = v.threshold, task = id;
429
+ body.append(levelRow(view, c, { key: 'qa-threshold', label: 'Threshold override', inherit: true, task, current: th.mode, perm: canEdit(c, 'qa-threshold', id),
430
+ noneTitle: 'No threshold for this task',
431
+ help: th.mode === 'inherit' ? `Inherits ${v.run.qa === 'none' ? 'no threshold' : v.run.qa} from the run.`
432
+ : th.mode === 'none' ? 'No threshold for this task, whatever the run sets.' : `${th.mode} for this task, whatever the run sets.` }));
433
+ const rb = v.retry, used = rb.effective === null ? `used ${rb.used}` : `used ${rb.used} of ${rb.effective}`;
434
+ body.append(countRow(view, c, { key: 'retry-budget', label: 'Retry budget', unit: 'retries', task, value: rb.own, placeholder: rb.effective === null ? 'inherit' : String(rb.effective),
435
+ inherit: rb.mode === 'inherit' ? 'pressed' : 'ready', perm: canEdit(c, 'retry-budget', id),
436
+ help: `${rb.mode === 'inherit' ? (rb.effective === null ? 'Inherits the run\'s budget.' : `Inherits ${rb.effective} from the run.`) : `${rb.own} for this task.`} ${used}.` }));
437
+ if (!canEdit(c, 'pin', id).hidden) body.append(pinRow(view, c, v));
438
+ return body;
439
+ }
440
+
441
+ function pinRow(view, c, v) {
442
+ const u = ui(), r = shell(view, 'pin', 'Pin next retry to', false), id = v.id;
443
+ const now = u.el('div', undefined, 'ctl-pin-now');
444
+ if (v.pin) {
445
+ const un = u.el('button', 'Unpin'); un.type = 'button';
446
+ un.title = 'Drop the pin: the next retry goes back to the escalation policy';
447
+ un.addEventListener('click', () => commit(view, r, { kind: 'unpin', task: id }, ['pin.alias', 'pin.effort', 'pin.harness'], un));
448
+ now.append(u.el('span', 'pinned', 'tag'), plateOf(v.pin), un);
449
+ } else now.append(u.el('span', 'no pin', 'muted'));
450
+ r.status.append(now);
451
+ if (v.next) {
452
+ const next = u.el('p', undefined, 'ctl-help');
453
+ next.append(textNode('the policy picked '), plateOf(v.next), textNode(' for the next retry'));
454
+ r.status.append(next);
455
+ }
456
+ if (v.stop) {
457
+ const stop = u.el('p', undefined, 'ctl-stop');
458
+ stop.append(u.el('i', undefined, 'lamp st-fail'), u.el('span', 'policy stopped: split or pin a model'));
459
+ r.status.append(stop);
460
+ }
461
+ // alias (suggestions from the models seen in runs, free text too), effort, optional harness
462
+ const form = u.el('form', undefined, 'ctl-pin');
463
+ form.noValidate = true;
464
+ const alias = u.el('input'), harness = u.el('input'), list = u.el('datalist');
465
+ alias.type = 'text'; harness.type = 'text';
466
+ for (const i of [alias, harness]) { i.autocomplete = 'off'; i.spellcheck = false; }
467
+ alias.setAttribute('list', r.id + '-models');
468
+ list.id = r.id + '-models'; fillList(list);
469
+ if (!store.models) trackList(list);
470
+ alias.value = field(view, 'pin.alias', ''); harness.value = field(view, 'pin.harness', '');
471
+ alias.addEventListener('input', () => touch(view, 'pin.alias', alias.value));
472
+ harness.addEventListener('input', () => touch(view, 'pin.harness', harness.value));
473
+ const captioned = (caption, control, cls) => { const l = u.el('label', undefined, 'filter ' + cls); l.append(u.el('span', caption), control); return l; };
474
+ const effortCaption = u.el('span', 'Effort'), effortBox = u.el('div', undefined, 'filter ctl-effort');
475
+ effortCaption.id = r.id + '-effort';
476
+ const effortBank = bank(r, EFFORTS.map(e => ({ value: e, label: e })), field(view, 'pin.effort', 'default'), true, value => {
477
+ touch(view, 'pin.effort', value);
478
+ for (const b of effortBank.children) b.setAttribute('aria-pressed', String(b.dataset.value === value));
479
+ });
480
+ effortBank.setAttribute('aria-labelledby', effortCaption.id);
481
+ effortBox.append(effortCaption, effortBank);
482
+ const go = u.el('button', 'Pin', 'primary'); go.type = 'submit';
483
+ form.append(captioned('Model alias', alias, 'ctl-alias'), list, captioned('Harness (optional)', harness, 'ctl-harness'), effortBox, go);
484
+ form.addEventListener('submit', e => {
485
+ e.preventDefault();
486
+ commit(view, r, { kind: 'pin', task: id, alias: alias.value, effort: view.draft['pin.effort'] || 'default', harness: harness.value }, ['pin.alias', 'pin.effort', 'pin.harness'], go);
487
+ });
488
+ r.control.append(form);
489
+ help(r, 'Applies at the next full retry, not a QA-only resume.');
490
+ loadModels();
491
+ return r.box;
492
+ }
493
+ function fillList(list) {
494
+ list.replaceChildren();
495
+ for (const a of store.models || []) { const o = ui().el('option'); o.value = a; list.append(o); }
496
+ }
497
+ // Only lists drawn while the aliases are still on their way wait for them (and only while attached: a
498
+ // detached one would keep its whole old section alive); once loaded, fillList reads store.models itself.
499
+ function trackList(list) {
500
+ for (const l of Array.from(store.lists)) if (!l.isConnected) store.lists.delete(l);
501
+ store.lists.add(list);
502
+ }
503
+ function loadModels() {
504
+ if (store.modelsAsked) return;
505
+ store.modelsAsked = true;
506
+ ui().get('/api/models').then(res => {
507
+ if (!res.ok) { store.modelsAsked = false; return; }
508
+ store.models = aliasesOf(res.data);
509
+ for (const l of store.lists) if (l.isConnected) fillList(l);
510
+ store.lists.clear();
511
+ });
512
+ }
513
+
514
+ // Draw a section from `forge control show`.
515
+ function draw(view, c) {
516
+ const s = view.section, mark = focusMark(s);
517
+ view.base = {}; view.drawn = c;
518
+ s.replaceChildren(ui().el('h3', 'Controls'), view.task ? drawTask(view, c, view.task) : drawRun(view, c));
519
+ refocus(s, mark);
520
+ for (const f of Object.keys(view.draft)) if (!(f in view.base)) delete view.draft[f]; // its row is gone: so is the draft
521
+ sync(view);
522
+ }
523
+
524
+ // What the section shows from what is known now: no fetch.
525
+ function show(view) {
526
+ if (!store.enabled) { note(view, 'Read-only: this page cannot change the run.'); return; }
527
+ const e = store.runs.get(view.run.key);
528
+ if (e && e.data) draw(view, e.data);
529
+ else note(view, e && e.error ? `Controls are unavailable: ${e.error}` : 'Loading controls…');
530
+ }
531
+ // New data for a run reaches every section of it that is not being edited.
532
+ function arrive(e) {
533
+ for (const view of Array.from(e.views)) {
534
+ if (!view.section.isConnected) e.views.delete(view);
535
+ else if (!view.section.dataset.editing) show(view);
536
+ }
537
+ }
538
+ function accept(key, controls, fresh) {
539
+ const e = store.runs.get(key);
540
+ if (!e) return;
541
+ e.data = controls; e.text = JSON.stringify(controls); e.error = null; e.at = Date.now(); e.fresh = fresh;
542
+ arrive(e);
543
+ }
544
+ function fetchControls(view) {
545
+ const key = view.run.key, run = view.run;
546
+ let e = store.runs.get(key);
547
+ if (!e) store.runs.set(key, e = { views: new Set(), data: null, text: null, error: null, at: 0, fresh: undefined, pending: false });
548
+ if (e.views.size > 20) for (const v of Array.from(e.views)) if (!v.section.isConnected) e.views.delete(v);
549
+ e.views.add(view);
550
+ if (e.pending || (e.fresh === run.updated && Date.now() - e.at < STALE_MS)) return;
551
+ e.pending = true; e.fresh = run.updated;
552
+ ui().get(path(key)).then(res => {
553
+ e.pending = false; e.at = Date.now();
554
+ if (res.ok) {
555
+ const text = JSON.stringify(res.data);
556
+ if (text === e.text && !e.error) return; // nothing moved: no redraw
557
+ e.data = res.data; e.text = text; e.error = null;
558
+ } else {
559
+ e.error = res.status === 404 ? 'this server has no controls for the run. If forge-ui was started before this version, run `forge ui open` again to restart it.' : res.detail || res.error;
560
+ if (e.data) return; // keep the last good data on screen
561
+ }
562
+ arrive(e);
563
+ });
564
+ }
565
+
566
+ // Mount the Controls section of a run (task undefined) or of one task into `host`.
567
+ function mount(host, ctx, task) {
568
+ const u = ui(), run = ctx.run;
569
+ const section = u.el('section', undefined, 'insp-section ctl');
570
+ section.dataset.hold = scopeOf(run.key, task && task.id);
571
+ section.append(u.el('h3', 'Controls'));
572
+ host.append(section);
573
+ const view = { section, run, task: task ? task.id : null, draft: {}, base: {}, inflight: false, drawn: null };
574
+ section.addEventListener('focusin', () => sync(view));
575
+ section.addEventListener('focusout', () => setTimeout(() => sync(view), 0));
576
+ if (store.enabled === null) {
577
+ note(view, 'Checking whether this page can change the run…');
578
+ u.controlsEnabled().then(on => {
579
+ store.enabled = on || !u.token() ? on : null; // a token but no answer: ask again at the next mount
580
+ if (on) fetchControls(view);
581
+ if (section.isConnected && !section.dataset.editing) show(view);
582
+ });
583
+ return section;
584
+ }
585
+ if (store.enabled) fetchControls(view);
586
+ show(view);
587
+ return section;
588
+ }
589
+ const mountRun = (host, ctx) => mount(host, ctx);
590
+ const mountTask = (host, ctx, task) => mount(host, ctx, task);
591
+
592
+ // Before the inspector is rebuilt: the sections of `parent` being edited, to be put back after. Their focus and
593
+ // caret come back with them (a node taken out of the page loses its focus). A key that only has the keyboard is not an
594
+ // edit: its section is drawn again from the new data, and the keyboard is put back on the same key.
595
+ function hold(parent) {
596
+ const held = Array.from(parent.querySelectorAll('[data-hold][data-editing]'));
597
+ const active = doc().activeElement;
598
+ const sec = active && active.closest ? active.closest('[data-hold]') : null;
599
+ const key = sec && parent.contains(sec) && !held.includes(sec) ? { scope: sec.dataset.hold, mark: focusMark(sec) } : null;
600
+ if (!held.length && !(key && key.mark)) return null;
601
+ const focus = held.some(n => n.contains(active)) ? { node: active, from: active.selectionStart, to: active.selectionEnd, dir: active.selectionDirection } : null;
602
+ const fresh = scope => Array.from(parent.querySelectorAll('[data-hold]')).find(n => keepEditing({ scope, editing: true }, { scope: n.dataset.hold }));
603
+ return { restore() {
604
+ for (const node of held) {
605
+ const next = fresh(node.dataset.hold);
606
+ if (next) next.replaceWith(node);
607
+ }
608
+ if (focus && focus.node.isConnected) {
609
+ focus.node.focus({ preventScroll: true });
610
+ try { if (focus.from !== null && focus.from !== undefined) focus.node.setSelectionRange(focus.from, focus.to, focus.dir || 'none'); } catch (_) { /* not a text field */ }
611
+ }
612
+ const next = key && key.mark ? fresh(key.scope) : null;
613
+ if (next) refocus(next, key.mark);
614
+ } };
615
+ }
616
+
617
+ return { LEVELS, EFFORTS, KEYS, specOf, countOf, secondsOf, minutesOf, valueFor, bodyFor, prepare, savedText, effective, canEdit, logLine, agoText,
618
+ aliasesOf, scopeOf, typing, isEditing, keepEditing, redrawDue, mountRun, mountTask, hold };
619
+ });