amicus 4.2.1 → 4.4.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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +46 -1
- package/README.md +8 -4
- package/bin/amicus.js +5 -0
- package/electron/ipc-workspace.js +283 -0
- package/electron/main.js +27 -0
- package/electron/preload-workspace.js +40 -0
- package/electron/workspace-shell.js +85 -0
- package/electron/workspace-ui/index.html +111 -0
- package/electron/workspace-ui/live-model.js +101 -0
- package/electron/workspace-ui/md-lite.js +119 -0
- package/electron/workspace-ui/workspace-app.js +240 -0
- package/electron/workspace-ui/workspace-matrix.js +212 -0
- package/electron/workspace-ui/workspace-panels.js +226 -0
- package/electron/workspace-ui/workspace-render.js +271 -0
- package/electron/workspace-ui/workspace-verbs.js +247 -0
- package/electron/workspace-ui/workspace.css +172 -0
- package/package.json +1 -1
- package/schemas/council-run-live.schema.json +57 -0
- package/schemas/council-run.schema.json +14 -0
- package/schemas/event.schema.json +15 -0
- package/schemas/progress.schema.json +37 -0
- package/schemas/run-live.schema.json +15 -0
- package/schemas/spend.schema.json +26 -1
- package/schemas/wave-live.schema.json +15 -0
- package/skills/second-opinion/MODEL-NOTES.md +53 -5
- package/src/cli-handlers-council-run.js +86 -8
- package/src/cli-handlers-run.js +26 -0
- package/src/cli-handlers-spend.js +94 -32
- package/src/cli-handlers-watch.js +116 -0
- package/src/cli.js +58 -1
- package/src/council/briefings.js +35 -2
- package/src/council/run-budget.js +224 -0
- package/src/council/run-chair.js +10 -2
- package/src/council/run-debate.js +5 -1
- package/src/council/run-launch.js +58 -7
- package/src/council/run-stages.js +30 -3
- package/src/council/run.js +44 -15
- package/src/headless.js +356 -15
- package/src/mcp-council-awareness.js +98 -3
- package/src/mcp-council-run.js +28 -4
- package/src/mcp-notify.js +54 -0
- package/src/mcp-server.js +51 -1
- package/src/mcp-spend.js +125 -0
- package/src/mcp-tools.js +39 -0
- package/src/mcp-wait.js +28 -2
- package/src/observe/council-legs.js +183 -0
- package/src/observe/events.js +156 -0
- package/src/observe/follow.js +26 -0
- package/src/observe/live-doc.js +56 -0
- package/src/observe/on-complete.js +117 -0
- package/src/observe/watch-render.js +168 -0
- package/src/opencode-client.js +15 -3
- package/src/sidecar/child-sessions.js +198 -0
- package/src/sidecar/continue.js +32 -0
- package/src/sidecar/conversation-mirror.js +111 -37
- package/src/sidecar/fallback-chains.js +65 -0
- package/src/sidecar/fanout-budget.js +71 -0
- package/src/sidecar/fanout-leg-fallback.js +189 -0
- package/src/sidecar/fanout-leg.js +81 -27
- package/src/sidecar/fanout-retry.js +208 -0
- package/src/sidecar/fanout-validate.js +42 -4
- package/src/sidecar/fanout.js +54 -41
- package/src/sidecar/progress.js +5 -0
- package/src/sidecar/resume.js +12 -0
- package/src/sidecar/start.js +13 -1
- package/src/sidecar/tool-part.js +196 -0
- package/src/sidecar/workspace-window.js +62 -0
- package/src/spend-query.js +119 -0
- package/src/utils/env-num.js +42 -0
- package/src/utils/error-classify.js +31 -0
- package/src/utils/model-tiers.js +1 -1
- package/src/utils/path-fence.js +82 -0
- package/src/utils/pricing.js +98 -9
- package/src/utils/spend-ledger.js +24 -1
- package/src/workspace/artifact-guard.js +187 -0
- package/src/workspace/blind-mode.js +32 -0
- package/src/workspace/fold-format.js +95 -0
- package/src/workspace/live-normalize.js +156 -0
- package/src/workspace/matrix-model.js +94 -0
- package/src/workspace/run-detail.js +223 -0
- package/src/workspace/run-scan.js +148 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Council Workspace — DOM painters (run list, header, stage rail, seats,
|
|
3
|
+
* banner, cost). Every string lands via textContent/createTextNode; keyed
|
|
4
|
+
* updates for seat rows (no full re-render per tick, spec §5.2).
|
|
5
|
+
*/
|
|
6
|
+
(function () {
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
/** Element builder: children that are strings become TEXT nodes (never markup). */
|
|
10
|
+
function el(tag, attrs, children) {
|
|
11
|
+
var node = document.createElement(tag);
|
|
12
|
+
if (attrs) {
|
|
13
|
+
Object.keys(attrs).forEach(function (k) {
|
|
14
|
+
if (k === 'className') { node.className = attrs[k]; }
|
|
15
|
+
else if (k === 'dataset') {
|
|
16
|
+
Object.keys(attrs[k]).forEach(function (d) { node.dataset[d] = attrs[k][d]; });
|
|
17
|
+
} else if (k.indexOf('on') === 0 && typeof attrs[k] === 'function') {
|
|
18
|
+
node.addEventListener(k.slice(2), attrs[k]);
|
|
19
|
+
} else if (k.indexOf('on') === 0) {
|
|
20
|
+
// A non-function on* value must never fall through to setAttribute below — that
|
|
21
|
+
// would write a live inline event-handler attribute (a DOM-injection sink) from data.
|
|
22
|
+
/* skip */
|
|
23
|
+
} else if (attrs[k] === false || attrs[k] === null || attrs[k] === undefined) {
|
|
24
|
+
/* skip */
|
|
25
|
+
} else { node.setAttribute(k, String(attrs[k])); }
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
(children || []).forEach(function (c) {
|
|
29
|
+
if (c === null || c === undefined) { return; }
|
|
30
|
+
node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
|
31
|
+
});
|
|
32
|
+
return node;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function chip(text, kind) {
|
|
36
|
+
return el('span', { className: 'chip ' + (kind || ''), title: text }, [text]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Dual-name display flip (blind mode). pair = {model, label}. */
|
|
40
|
+
function display(pair, blind) {
|
|
41
|
+
if (!pair) { return '—'; }
|
|
42
|
+
return blind && pair.label ? pair.label : (pair.model || '—');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function relTime(iso) {
|
|
46
|
+
if (!iso) { return '—'; }
|
|
47
|
+
var ms = Date.now() - Date.parse(iso);
|
|
48
|
+
if (!isFinite(ms) || ms < 0) { return iso; }
|
|
49
|
+
var m = Math.floor(ms / 60000);
|
|
50
|
+
if (m < 1) { return 'just now'; }
|
|
51
|
+
if (m < 60) { return m + 'm ago'; }
|
|
52
|
+
var h = Math.floor(m / 60);
|
|
53
|
+
if (h < 48) { return h + 'h ago'; }
|
|
54
|
+
return Math.floor(h / 24) + 'd ago';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** display() for a bare model id, tolerating a missing labelOf (defaults to no label known). */
|
|
58
|
+
function displayModel(model, blindOn, labelOf) {
|
|
59
|
+
return display({ model: model, label: labelOf ? labelOf(model) : null }, blindOn);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ⚠️ R4 COUNCIL REVIEW (fourth live paid council, major, unanimous): renderRunList used to
|
|
63
|
+
// interpolate `row.chair` (a raw model id) directly into the run-row-sub line, bypassing
|
|
64
|
+
// display() — every other identity surface (seats, cost rows, revote titles) masks
|
|
65
|
+
// correctly through it. Christian has ruled blind mode DOES mask the roster (see
|
|
66
|
+
// docs/council.md and the v4.4 spec §6/resolved-Q2 amendment) — thread blind + labelOf
|
|
67
|
+
// through so the chair name masks here too. `labelOf` here is the CURRENTLY-OPEN run's
|
|
68
|
+
// labelByModel lookup (workspace-app.js), so it only resolves a label for the row that IS
|
|
69
|
+
// the open run; other rows' chairs have no label data available and degrade gracefully to
|
|
70
|
+
// the raw id — a reading aid, not a security control (§6.1), so best-effort is correct.
|
|
71
|
+
function renderRunList(container, rows, selectedId, onOpen, blindOn, labelOf) {
|
|
72
|
+
container.textContent = '';
|
|
73
|
+
rows.forEach(function (row) {
|
|
74
|
+
if (row.error) {
|
|
75
|
+
container.appendChild(el('li', { className: 'error-row', title: row.pointerPath || '' }, [
|
|
76
|
+
el('div', { className: 'run-row-top' }, [
|
|
77
|
+
el('span', { className: 'mono' }, [row.runId]),
|
|
78
|
+
el('span', {}, ['unreadable']),
|
|
79
|
+
]),
|
|
80
|
+
el('div', { className: 'run-row-sub' }, [row.runDir || row.error]),
|
|
81
|
+
]));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
var li = el('li', {
|
|
85
|
+
className: row.runId === selectedId ? 'selected' : '',
|
|
86
|
+
tabindex: '-1',
|
|
87
|
+
dataset: { runId: row.runId },
|
|
88
|
+
onclick: function () { onOpen(row.runId); },
|
|
89
|
+
}, [
|
|
90
|
+
el('div', { className: 'run-row-top' }, [
|
|
91
|
+
el('span', { className: 'mono' }, [row.runId]),
|
|
92
|
+
chip(row.status, row.status),
|
|
93
|
+
]),
|
|
94
|
+
el('div', { className: 'run-row-sub' }, [
|
|
95
|
+
relTime(row.startedAt),
|
|
96
|
+
String(row.bench.length) + ' seats',
|
|
97
|
+
'chair ' + displayModel(row.chair, blindOn, labelOf),
|
|
98
|
+
row.overallVerdict || '',
|
|
99
|
+
row.costDisplay || '',
|
|
100
|
+
].filter(Boolean).map(function (t) { return el('span', {}, [t]); })),
|
|
101
|
+
]);
|
|
102
|
+
container.appendChild(li);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ⚠️ R4 COUNCIL REVIEW (fourth live paid council, major, unanimous): the bench/critic/chair
|
|
107
|
+
// chips interpolated raw model ids directly, bypassing display() — the seat table right
|
|
108
|
+
// below masks correctly, so a live blind run showed an unmasked roster in its own header.
|
|
109
|
+
// Lenses (`run.lenses`) are review-style slugs (e.g. 'skeptic'), never model identities —
|
|
110
|
+
// left unmasked, matching the raw display used elsewhere for non-identity chips.
|
|
111
|
+
function renderHeaderChips(container, run, blindOn, labelOf) {
|
|
112
|
+
container.textContent = '';
|
|
113
|
+
container.appendChild(chip(run.status || 'unknown', run.status));
|
|
114
|
+
(Array.isArray(run.bench) ? run.bench : []).forEach(function (m) {
|
|
115
|
+
container.appendChild(chip(displayModel(m, blindOn, labelOf), ''));
|
|
116
|
+
});
|
|
117
|
+
if (run.critic) { container.appendChild(chip('critic: ' + displayModel(run.critic, blindOn, labelOf), '')); }
|
|
118
|
+
(Array.isArray(run.lenses) ? run.lenses : []).forEach(function (s) {
|
|
119
|
+
container.appendChild(chip('lens: ' + s, ''));
|
|
120
|
+
});
|
|
121
|
+
container.appendChild(chip('chair: ' + displayModel(run.chair, blindOn, labelOf), ''));
|
|
122
|
+
if (run.options && run.options.gateway) { container.appendChild(chip('gw: ' + run.options.gateway, '')); }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* v4.4 §8: `costExact` (optional, defaults TRUE so every pre-v4.4 5-arg call
|
|
127
|
+
* site is unchanged) says whether `costAmount` is the whole bill. When false —
|
|
128
|
+
* i.e. some seat reported no usage at all — a gauge reading "50% of budget" is
|
|
129
|
+
* affirmatively misleading, because the true figure can only be HIGHER. The
|
|
130
|
+
* bar still fills to the known fraction (that much is real), but the gauge is
|
|
131
|
+
* marked `unknown` for the hatched CSS treatment and the readout is prefixed
|
|
132
|
+
* `≥` so the number is never mistaken for a measurement.
|
|
133
|
+
*/
|
|
134
|
+
function renderGauge(fillEl, textEl, costAmount, maxCost, totalDisplay, costExact) {
|
|
135
|
+
var gauge = fillEl.parentElement;
|
|
136
|
+
var exact = costExact === undefined ? true : !!costExact;
|
|
137
|
+
var prefix = exact ? '' : '≥ ';
|
|
138
|
+
gauge.classList.toggle('unknown', !exact);
|
|
139
|
+
if (maxCost === null || costAmount === null) {
|
|
140
|
+
fillEl.style.width = '0%';
|
|
141
|
+
gauge.classList.remove('over');
|
|
142
|
+
textEl.textContent = prefix + totalDisplay + (maxCost !== null ? ' / $' + maxCost.toFixed(2) : '');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
var pct = Math.min(100, (costAmount / maxCost) * 100);
|
|
146
|
+
fillEl.style.width = pct.toFixed(1) + '%';
|
|
147
|
+
gauge.classList.toggle('over', costAmount >= maxCost);
|
|
148
|
+
textEl.textContent = prefix + totalDisplay + ' / $' + maxCost.toFixed(2);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function renderStageRail(container, stageRail) {
|
|
152
|
+
container.textContent = '';
|
|
153
|
+
(stageRail || []).forEach(function (s) {
|
|
154
|
+
// Default once and reuse everywhere below — the aria-label used to interpolate the raw
|
|
155
|
+
// `s.status` while className defaulted it, so an entry with no status read "…: undefined".
|
|
156
|
+
var status = s.status || 'pending';
|
|
157
|
+
var mark = status === 'complete' ? '✓ ' : (status === 'running' ? '▶ ' : '· ');
|
|
158
|
+
container.appendChild(el('span', {
|
|
159
|
+
className: 'stage ' + status,
|
|
160
|
+
title: (s.startedAt || '') + (s.completedAt ? ' → ' + s.completedAt : ''),
|
|
161
|
+
'aria-label': s.label + ': ' + status,
|
|
162
|
+
}, [mark + s.label]));
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Keyed seat rows: update in place per seat id/model; remove leavers. */
|
|
167
|
+
function renderSeats(tbody, seats, blindOn, labelOf) {
|
|
168
|
+
// Build a key -> row map from the existing children ONCE per call, instead of a CSS
|
|
169
|
+
// attribute-selector lookup per seat. `tr[data-key="..."]` requires escaping the key for
|
|
170
|
+
// CSS string-literal syntax; escaping only the query side (as the brief's original code
|
|
171
|
+
// did) while the stored `dataset.key` stays raw means a key containing `"` or `\` can
|
|
172
|
+
// never match its own row — the lookup misses every tick and the row is re-appended
|
|
173
|
+
// forever. A plain object lookup sidesteps the escaping problem entirely.
|
|
174
|
+
var existing = {};
|
|
175
|
+
Array.prototype.slice.call(tbody.children).forEach(function (row) {
|
|
176
|
+
existing[row.dataset.key] = row;
|
|
177
|
+
});
|
|
178
|
+
var seen = {};
|
|
179
|
+
seats.forEach(function (seat) {
|
|
180
|
+
// ⚠️ DE-ROT (F37): `seat.id` is now always set by seatsFromRunStats (`model:role`), so
|
|
181
|
+
// debate rebuttal/revote rows no longer collide with the seat row. The `|| seat.model`
|
|
182
|
+
// fallback covers live seats, whose taskId-derived id is already unique.
|
|
183
|
+
var key = String(seat.id || seat.model);
|
|
184
|
+
seen[key] = true;
|
|
185
|
+
var row = existing[key];
|
|
186
|
+
// ⚠️ DE-ROT (F35): seat.lastActivity is the ISO `leg.lastActivityAt`; format it HERE.
|
|
187
|
+
// seatCells lives in live-model.js, which loads first and has no access to relTime.
|
|
188
|
+
var view = Object.assign({}, seat, {
|
|
189
|
+
lastActivity: seat.lastActivity ? relTime(seat.lastActivity) : null,
|
|
190
|
+
});
|
|
191
|
+
var cells = window.AmicusLive.seatCells(view, blindOn, labelOf);
|
|
192
|
+
if (!row) {
|
|
193
|
+
row = el('tr', { dataset: { key: key } }, cells.map(function (c, i) {
|
|
194
|
+
return el('td', { className: i >= 4 && i <= 6 ? 'num' : (i === 8 ? 'stalled-flag' : '') }, [c]);
|
|
195
|
+
}));
|
|
196
|
+
tbody.appendChild(row);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
cells.forEach(function (c, i) {
|
|
200
|
+
var td = row.children[i];
|
|
201
|
+
if (td && td.textContent !== c) { td.textContent = c; }
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
Array.prototype.slice.call(tbody.children).forEach(function (row) {
|
|
205
|
+
if (!seen[row.dataset.key]) { row.remove(); }
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function renderBanner(bannerEl, text, kind) {
|
|
210
|
+
if (!text) { bannerEl.hidden = true; bannerEl.textContent = ''; return; }
|
|
211
|
+
bannerEl.hidden = false;
|
|
212
|
+
bannerEl.className = 'banner ' + (kind || '');
|
|
213
|
+
bannerEl.textContent = text;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function renderCost(container, cost, blindOn, labelOf) {
|
|
217
|
+
container.textContent = '';
|
|
218
|
+
var head = el('tr', {}, ['Seat', 'Role', 'Status', 'Duration', 'Cost'].map(function (h, i) {
|
|
219
|
+
return el('th', { className: i >= 3 ? 'num' : '' }, [h]);
|
|
220
|
+
}));
|
|
221
|
+
var rows = (cost.rows || []).map(function (r) {
|
|
222
|
+
// Single definition of the blind-mode flip (`display()`, above) — this used to
|
|
223
|
+
// hand-duplicate seatCells' ternary, which meant seatCells' blind-mode test never
|
|
224
|
+
// protected this second surface.
|
|
225
|
+
var name = display({ model: r.model, label: labelOf ? labelOf(r.model) : null }, blindOn);
|
|
226
|
+
var dur = r.durationMs === null ? '—' : Math.round(r.durationMs / 1000) + 's';
|
|
227
|
+
return el('tr', {}, [
|
|
228
|
+
el('td', {}, [name]),
|
|
229
|
+
el('td', {}, [r.role || '—']),
|
|
230
|
+
el('td', {}, [r.status || '—']),
|
|
231
|
+
el('td', { className: 'num' }, [dur]),
|
|
232
|
+
el('td', { className: 'num' }, [r.costDisplay || '—']),
|
|
233
|
+
]);
|
|
234
|
+
});
|
|
235
|
+
var total = el('tr', {}, [
|
|
236
|
+
el('td', {}, [el('strong', {}, ['Run total'])]),
|
|
237
|
+
el('td', {}, ['']), el('td', {}, ['']), el('td', {}, ['']),
|
|
238
|
+
el('td', { className: 'num' }, [cost.totalDisplay || '—']),
|
|
239
|
+
]);
|
|
240
|
+
var table = el('table', { className: 'table' }, [el('thead', {}, [head]), el('tbody', {}, rows.concat([total]))]);
|
|
241
|
+
container.appendChild(table);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Prose panels: one titled section per artifact, markdown-lite rendered. */
|
|
245
|
+
function renderProseSections(container, sections) {
|
|
246
|
+
container.textContent = '';
|
|
247
|
+
sections.forEach(function (s) {
|
|
248
|
+
var host = el('div', { className: 'prose-section', dataset: { artifact: s.name } }, [
|
|
249
|
+
el('h3', {}, [s.title]),
|
|
250
|
+
]);
|
|
251
|
+
var body = el('div', {}, []);
|
|
252
|
+
if (s.error) {
|
|
253
|
+
body.appendChild(el('p', { className: 'empty-note' }, [s.error]));
|
|
254
|
+
} else {
|
|
255
|
+
window.AmicusMd.renderMdLite(body, s.text, document);
|
|
256
|
+
if (s.truncated) {
|
|
257
|
+
body.appendChild(el('p', { className: 'truncate-note' }, ['Truncated at 200 KB — open the run folder for the full file.']));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
host.appendChild(body);
|
|
261
|
+
container.appendChild(host);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
window.AmicusRender = {
|
|
266
|
+
el: el, chip: chip, display: display, relTime: relTime, renderRunList: renderRunList,
|
|
267
|
+
renderHeaderChips: renderHeaderChips, renderGauge: renderGauge, renderStageRail: renderStageRail,
|
|
268
|
+
renderSeats: renderSeats, renderBanner: renderBanner, renderCost: renderCost,
|
|
269
|
+
renderProseSections: renderProseSections,
|
|
270
|
+
};
|
|
271
|
+
})();
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Council Workspace — action verbs (v4.4 §5, ⚠️ DE-ROT F05 split of
|
|
3
|
+
* workspace-app.js). `doFold` is the only verb live in this task; Task 15
|
|
4
|
+
* adds the live poll loop (startLiveLoop/stopLiveLoop/applyLive) and Task 16
|
|
5
|
+
* adds the abort confirm dialog (openAbortDialog) into the seams below.
|
|
6
|
+
*
|
|
7
|
+
* Loads BEFORE workspace-app.js, so every function here reads
|
|
8
|
+
* `window.AmicusApp` at CALL time — never captured at this file's own load
|
|
9
|
+
* time, since AmicusApp does not exist until workspace-app.js (last in load
|
|
10
|
+
* order) publishes it.
|
|
11
|
+
*/
|
|
12
|
+
(function () {
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
// Task 16: a LOCAL `$` for the one-time, module-load-time abort-dialog wiring below —
|
|
16
|
+
// the DOM (unlike window.AmicusApp) already exists at this file's load time, since
|
|
17
|
+
// index.html's script tags sit after all the markup. Never used to read AmicusApp state.
|
|
18
|
+
function $(id) { return document.getElementById(id); }
|
|
19
|
+
|
|
20
|
+
function doFold() {
|
|
21
|
+
var A = window.AmicusApp;
|
|
22
|
+
var btn = A.$('fold-btn');
|
|
23
|
+
A.invoke('workspace:fold', A.state.runId).then(function (res) {
|
|
24
|
+
if (res.ok) {
|
|
25
|
+
btn.textContent = 'Folded ✓';
|
|
26
|
+
btn.disabled = true;
|
|
27
|
+
btn.title = res.already ? 'Already folded this session' : 'Fold written to the launching terminal';
|
|
28
|
+
} else {
|
|
29
|
+
btn.title = res.error || 'fold failed';
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---- live loop (spec §4.3; Task 15) -----------------------------------
|
|
35
|
+
// ⚠️ DE-ROT (F42): stopLiveLoop can only clearTimeout a SCHEDULED tick — it cannot cancel an
|
|
36
|
+
// invoke() already in flight, and the visibilitychange/blur/focus listeners at the bottom of
|
|
37
|
+
// this file re-enter startLiveLoop freely. Without a generation guard those forked chains
|
|
38
|
+
// outlive stopLiveLoop, reassign state.liveTimer (orphaning the timer the event just
|
|
39
|
+
// scheduled), and paint run A's legs into run B after openRun() swaps state.detail underneath
|
|
40
|
+
// them. The epoch + PER-TICK runId pin below are mandatory, not optional hardening.
|
|
41
|
+
function stopLiveLoop() {
|
|
42
|
+
var A = window.AmicusApp;
|
|
43
|
+
A.state.liveEpoch = (A.state.liveEpoch || 0) + 1; // invalidates any in-flight tick
|
|
44
|
+
if (A.state.liveTimer) { clearTimeout(A.state.liveTimer); A.state.liveTimer = null; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function liveState(terminal) {
|
|
48
|
+
return {
|
|
49
|
+
terminal: terminal,
|
|
50
|
+
visible: document.visibilityState === 'visible',
|
|
51
|
+
focused: document.hasFocus(),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function startLiveLoop() {
|
|
56
|
+
var A = window.AmicusApp;
|
|
57
|
+
stopLiveLoop();
|
|
58
|
+
var d = A.state.detail;
|
|
59
|
+
if (!d || !d.run || window.AmicusLive.TERMINAL_STATUSES.indexOf(d.run.status) !== -1) { return; }
|
|
60
|
+
var epoch = A.state.liveEpoch; // F42: stopLiveLoop() above just bumped it; this chain owns it
|
|
61
|
+
var tick = function () {
|
|
62
|
+
// F42: pin the id PER TICK and SEND the pinned id — matching the reply against
|
|
63
|
+
// state.runId while the request carried state.runId would still let a post-switch
|
|
64
|
+
// request hit run B.
|
|
65
|
+
var runId = A.state.runId;
|
|
66
|
+
// ⚠️ Code review round 2, finding 3: this MUST be `.then(onFulfilled, onRejected)`, not
|
|
67
|
+
// `.then(onFulfilled).catch(onRejected)`. The two look equivalent but are not: with a
|
|
68
|
+
// trailing `.catch`, a THROW inside onFulfilled (e.g. applyLive dereferencing a malformed
|
|
69
|
+
// payload, or a missing DOM id) is itself routed to the same catch and silently rescheduled
|
|
70
|
+
// — the loop keeps polling at full cadence, paints nothing, and logs nothing, indistinguishable
|
|
71
|
+
// from a healthy live view. With the two-argument form, onRejected only ever sees a
|
|
72
|
+
// REJECTED invoke() (a real IPC/network failure); a throw inside onFulfilled propagates as
|
|
73
|
+
// its own unhandled rejection instead, which surfaces (devtools/console) rather than being
|
|
74
|
+
// absorbed.
|
|
75
|
+
A.invoke('workspace:get-live', runId).then(function (live) {
|
|
76
|
+
if (epoch !== A.state.liveEpoch || runId !== A.state.runId) { return; } // stale chain
|
|
77
|
+
applyLive(live);
|
|
78
|
+
var terminal = !!(live && live.ok && live.terminal);
|
|
79
|
+
if (terminal) {
|
|
80
|
+
stopLiveLoop();
|
|
81
|
+
// ⚠️ Code review round 2, minor: openRun() is fire-and-forget here with no consumer —
|
|
82
|
+
// a rejected final refresh (e.g. the run folder vanished mid-read) must not strand the
|
|
83
|
+
// transient "refreshing…" banner forever, since the loop has already stopped and no
|
|
84
|
+
// later tick will ever repaint it.
|
|
85
|
+
A.openRun(runId).catch(function (err) {
|
|
86
|
+
console.error('workspace live loop: final get-run refresh failed', err);
|
|
87
|
+
window.AmicusRender.renderBanner(A.$('banner'),
|
|
88
|
+
'Run ended, but refreshing the final details failed — reopen the run to retry.', '');
|
|
89
|
+
});
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
A.state.liveTimer = setTimeout(tick, window.AmicusLive.pollDelay(liveState(false)));
|
|
93
|
+
}, function (err) {
|
|
94
|
+
// A genuinely rejected invoke() (IPC/network failure) — reschedule unless superseded,
|
|
95
|
+
// but log it; silently retrying forever with no trace was the code-review finding.
|
|
96
|
+
if (epoch !== A.state.liveEpoch || runId !== A.state.runId) { return; }
|
|
97
|
+
console.error('workspace live loop: workspace:get-live failed', err);
|
|
98
|
+
A.state.liveTimer = setTimeout(tick, window.AmicusLive.pollDelay(liveState(false)));
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
A.state.liveTimer = setTimeout(tick, 0);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function applyLive(live) {
|
|
105
|
+
var A = window.AmicusApp;
|
|
106
|
+
var R = window.AmicusRender;
|
|
107
|
+
if (!live || !live.ok) {
|
|
108
|
+
// A1 failure: keep last-known panels; flag the live layer only (spec §9)
|
|
109
|
+
// 'live' (alongside 'info') is a marker class, not a style — see the clear-arm note below.
|
|
110
|
+
R.renderBanner(A.$('banner'), 'live data unavailable' + (live && live.error ? ' — ' + live.error : ''), 'info live');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
// ⚠️ Code review round 2, minor: was `if (live.seats.length)`, which skips renderSeats
|
|
114
|
+
// entirely for an empty array — so a roster that shrinks to zero between stages (the
|
|
115
|
+
// composed doc's `legs` is only populated while a stage is active) leaves the PREVIOUS
|
|
116
|
+
// stage's rows frozen on screen forever, since renderSeats' own leaver-removal never runs.
|
|
117
|
+
// `live.seats` is always an array per the LiveModel contract (never undefined/null) when
|
|
118
|
+
// `live.ok` is true, so this is simply "always paint," empty roster included.
|
|
119
|
+
if (live.seats) {
|
|
120
|
+
R.renderSeats(A.$('seats-body'), live.seats, A.state.blind, A.labelOf);
|
|
121
|
+
}
|
|
122
|
+
// F42: state.detail can be swapped/absent under a tick — never deref .derived unguarded.
|
|
123
|
+
var derived = A.state.detail && A.state.detail.derived ? A.state.detail.derived : null;
|
|
124
|
+
if (live.stages) {
|
|
125
|
+
// ⚠️ DE-ROT (F41): a raw-name fallback would show the RAW stage name for every stage that
|
|
126
|
+
// starts AFTER run-open (most of them — state.detail is a frozen run-open snapshot).
|
|
127
|
+
// STAGE_LABELS is mirrored onto window.AmicusLive (Task 12/14) for exactly this reason;
|
|
128
|
+
// label from the mirror, not the snapshot.
|
|
129
|
+
// ⚠️ DE-ROT (F40): live stage entries carry no startedAt/completedAt, so reading them off
|
|
130
|
+
// `s` wipes the durable times already on screen on the very first tick. Merge onto the
|
|
131
|
+
// run-open snapshot row instead (a stage that begins mid-session shows no times until the
|
|
132
|
+
// terminal openRun() refresh — status still updates every tick).
|
|
133
|
+
R.renderStageRail(A.$('stage-rail'), live.stages.map(function (s) {
|
|
134
|
+
var prior = (derived ? derived.stageRail.find(function (r) { return r.name === s.name; }) : null) || {};
|
|
135
|
+
return {
|
|
136
|
+
name: s.name,
|
|
137
|
+
label: window.AmicusLive.STAGE_LABELS[s.name] || s.name,
|
|
138
|
+
status: s.status || 'pending',
|
|
139
|
+
startedAt: prior.startedAt || null,
|
|
140
|
+
completedAt: prior.completedAt || null,
|
|
141
|
+
};
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
// ⚠️ DE-ROT (F39): guard on costAmount ALONE. costDisplay is raw formatCost output and can be
|
|
145
|
+
// '?' (source 'unknown') or '—' (null cost); letting either string fall through would
|
|
146
|
+
// overwrite the durable total with a stage-scoped placeholder. The value is ACTIVE-STAGE
|
|
147
|
+
// spend, not a run total — labelled, never sold as one. The durable total returns on the
|
|
148
|
+
// terminal openRun() refresh.
|
|
149
|
+
if (live.costAmount !== null) {
|
|
150
|
+
// v4.4 §8: 6th arg = costExact. A stage carrying an unpriced leg draws the
|
|
151
|
+
// indeterminate gauge + `≥` readout rather than a confident percentage.
|
|
152
|
+
R.renderGauge(A.$('cost-gauge-fill'), A.$('cost-gauge-text'),
|
|
153
|
+
live.costAmount, derived ? derived.cost.maxCost : null,
|
|
154
|
+
(live.costDisplay || '—') + ' (this stage)',
|
|
155
|
+
live.costExact !== false);
|
|
156
|
+
}
|
|
157
|
+
// Dead-run banner: DATA-LAYER flags only (A4) — never GUI heuristics.
|
|
158
|
+
// ⚠️ Task 14 review: flags.crashed really means "errored with a reason" — finalize() maps
|
|
159
|
+
// ANY exit code 1 to status:'error' with run.error set, including a clean, zero-spend,
|
|
160
|
+
// no-legs-ever-launched validation failure. "No leg activity — may be dead, abort to
|
|
161
|
+
// reclaim it" is dishonest there on two counts: leg activity may be irrelevant to why it
|
|
162
|
+
// failed, and status:'error' is already TERMINAL — the process has already exited, so
|
|
163
|
+
// there is nothing left to abort. Give crashed its own, honest copy with no abort claim;
|
|
164
|
+
// it is terminal, so this same tick's terminal branch (above) immediately calls openRun(),
|
|
165
|
+
// which repaints the banner with the real run.error text via renderBanners() — one tick of
|
|
166
|
+
// blast radius. Only `stalled` (a still-running, no-activity heuristic) gets the abort
|
|
167
|
+
// remedy, since only a non-terminal run can plausibly still be aborted.
|
|
168
|
+
if (live.flags.crashed) {
|
|
169
|
+
R.renderBanner(A.$('banner'), 'Run reported an error — refreshing with the final result…', '');
|
|
170
|
+
} else if (live.flags.stalled) {
|
|
171
|
+
var mins = live.flags.stalledForSeconds ? Math.round(live.flags.stalledForSeconds / 60) : null;
|
|
172
|
+
// ⚠️ Code review round 2, finding 1: tagged 'live' (a marker class, no CSS of its own —
|
|
173
|
+
// see workspace.css, which styles only 'warn'/'info') so the clear arm below can find it.
|
|
174
|
+
// `stalled` is recomputed per read and simply omitted once activity resumes
|
|
175
|
+
// (src/mcp-council-awareness.js), so this banner is expected to be transient — but the OLD
|
|
176
|
+
// clear arm only ever matched the 'info' class, so a `''`-kind stalled banner painted here
|
|
177
|
+
// could never be cleared again: `renderBanner(el, text, '')` sets `className = 'banner '`,
|
|
178
|
+
// permanently failing `classList.contains('info')` from that point on, for the rest of the
|
|
179
|
+
// session, even once `flags.stalled` goes back to false on the very next tick.
|
|
180
|
+
R.renderBanner(A.$('banner'),
|
|
181
|
+
'No leg activity' + (mins ? ' for ' + mins + 'm' : '') + ' — the run may be dead. Abort to reclaim it; everything on disk stays browsable.',
|
|
182
|
+
'live');
|
|
183
|
+
A.$('abort-btn').hidden = false; // the remedy ships beside the diagnosis (Task 16 wires it)
|
|
184
|
+
} else if (A.$('banner').classList.contains('live')) {
|
|
185
|
+
// Restores whatever the DURABLE banner actually says (nothing, a schemaVersion mismatch,
|
|
186
|
+
// run.error, …) instead of just blanking it — a live-layer banner (info-unavailable or
|
|
187
|
+
// stalled) must hand back to the true state once neither condition holds, not stomp a
|
|
188
|
+
// real warning that predates it.
|
|
189
|
+
A.renderBanners();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---- abort (Task 16; spec §8) -----------------------------------------
|
|
194
|
+
// Confirm-gated: the dialog only ever shows/hides; workspace:abort-run is invoked
|
|
195
|
+
// solely from the confirm button below, never from opening the dialog itself.
|
|
196
|
+
function openAbortDialog() {
|
|
197
|
+
var A = window.AmicusApp;
|
|
198
|
+
A.$('dialog-abort').hidden = false;
|
|
199
|
+
A.$('dialog-abort-cancel').focus(); // Esc/Enter both land on Cancel — the safe default
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
$('abort-btn').addEventListener('click', openAbortDialog);
|
|
203
|
+
$('dialog-abort-cancel').addEventListener('click', function () {
|
|
204
|
+
$('dialog-abort').hidden = true;
|
|
205
|
+
});
|
|
206
|
+
$('dialog-abort-confirm').addEventListener('click', function () {
|
|
207
|
+
var A = window.AmicusApp;
|
|
208
|
+
var btn = $('dialog-abort-confirm');
|
|
209
|
+
if (btn.disabled) { return; } // a rapid second click while the first is in flight: no-op
|
|
210
|
+
btn.disabled = true;
|
|
211
|
+
A.invoke('workspace:abort-run', A.state.runId).then(function (res) {
|
|
212
|
+
btn.disabled = false;
|
|
213
|
+
$('dialog-abort').hidden = true;
|
|
214
|
+
if (!res.ok) {
|
|
215
|
+
window.AmicusRender.renderBanner(A.$('banner'), 'Abort failed: ' + (res.error || res.detail || 'unknown'), '');
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
stopLiveLoop();
|
|
219
|
+
A.openRun(A.state.runId); // re-read: status flips to aborted, grey chip, no live poll
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ⚠️ DE-ROT (F42): these three re-enter startLiveLoop on every focus/blur/visibility flip.
|
|
224
|
+
// That is only safe because startLiveLoop() calls stopLiveLoop() first, which bumps
|
|
225
|
+
// state.liveEpoch — any tick already awaiting invoke() sees the mismatch and drops instead of
|
|
226
|
+
// rescheduling itself. Without the epoch bump each flip forks a second poll chain that no stop
|
|
227
|
+
// can reach. Registered once at this file's load time (verbs.js loads before workspace-app.js,
|
|
228
|
+
// so window.AmicusApp does not exist yet — the callback bodies read it at call time via
|
|
229
|
+
// startLiveLoop/stopLiveLoop, never here).
|
|
230
|
+
document.addEventListener('visibilitychange', function () {
|
|
231
|
+
var A = window.AmicusApp;
|
|
232
|
+
if (A.state.liveTimer) { startLiveLoop(); }
|
|
233
|
+
});
|
|
234
|
+
window.addEventListener('blur', function () {
|
|
235
|
+
var A = window.AmicusApp;
|
|
236
|
+
if (A.state.liveTimer) { startLiveLoop(); }
|
|
237
|
+
});
|
|
238
|
+
window.addEventListener('focus', function () {
|
|
239
|
+
var A = window.AmicusApp;
|
|
240
|
+
if (A.state.liveTimer) { startLiveLoop(); }
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
window.AmicusVerbs = {
|
|
244
|
+
doFold: doFold, startLiveLoop: startLiveLoop, stopLiveLoop: stopLiveLoop, applyLive: applyLive,
|
|
245
|
+
openAbortDialog: openAbortDialog,
|
|
246
|
+
};
|
|
247
|
+
})();
|