@nanobpm/nano-workforce 0.195.1 → 0.196.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/CHANGELOG.md +12 -0
- package/app/agentic/cockpit/cockpit-route.test.ts +15 -1
- package/app/agentic/cockpit/cockpit-route.ts +40 -11
- package/app/agentic/cockpit/mount.test.ts +391 -0
- package/app/convergeGate.test.ts +73 -0
- package/app/github.ts +78 -7
- package/package.json +1 -1
- package/pages/cockpit/cockpit.css +17 -0
- package/pages/cockpit/mount.js +280 -17
- package/pages/cockpit/standalone.html +9 -3
- package/pages/delivery-graph-detail.page.json +1 -0
- package/pages/delivery-graphs.page.json +3 -2
- package/pages/epic-detail.page.json +2 -0
- package/pages/epic.page.json +1 -0
- package/pages/feature.page.json +1 -0
- package/pages/home.page.json +11 -0
- package/pages/lineage.page.json +2 -0
- package/pages/overview.page.json +4 -0
- package/pages/tasks.page.json +1 -0
- package/resources/prompts/review-round.md +10 -1
- package/test/agent-link-column.test.ts +66 -0
package/app/github.ts
CHANGED
|
@@ -153,16 +153,31 @@ export interface ReviewThread {
|
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
/** A suppressed / low-confidence Copilot advisory parsed out of a review body. Its `key` is the
|
|
156
|
-
* line-stable identity (survives a line drift); `label` is the human-facing
|
|
157
|
-
*
|
|
156
|
+
* line-stable identity (survives a line drift); `label` is the human-facing locator shown in block
|
|
157
|
+
* reasons.
|
|
158
|
+
*
|
|
159
|
+
* Two source formats populate this shape, so the per-field shapes below vary by variant:
|
|
160
|
+
* - LEGACY "Suppressed comments" block (`parseLegacySuppressedBlock`): a `**path:line**` header
|
|
161
|
+
* with real `line` (> 0), `text` is the first non-empty prose line after the header, and `label`
|
|
162
|
+
* is `path:line`.
|
|
163
|
+
* - `ccr-overview-v2` file-table findings (`parseOverviewV2TableFindings`, issue #835): `text` is a
|
|
164
|
+
* finding clause from the table's notes column (no header, no source line), so `line` is the
|
|
165
|
+
* sentinel `0` and `label` is `<path> (review overview)` rather than `path:line`.
|
|
166
|
+
*
|
|
167
|
+
* Consumers must therefore treat `line === 0` as "not line-addressable" (an overview finding), NOT
|
|
168
|
+
* as a real line, and must not assume `label` is always `path:line`. */
|
|
158
169
|
export interface SuppressedAdvisory {
|
|
159
170
|
path: string;
|
|
171
|
+
/** Source line of the advisory. Real (> 0) for a legacy `**path:line**` header; the sentinel `0`
|
|
172
|
+
* for a `ccr-overview-v2` table finding, which carries no source line and is not line-addressable. */
|
|
160
173
|
line: number;
|
|
161
|
-
/** The advisory prose
|
|
174
|
+
/** The advisory prose: the first non-empty line after the header (legacy block) or a finding
|
|
175
|
+
* clause from the overview table's notes column (`ccr-overview-v2`). Used for the stable fingerprint. */
|
|
162
176
|
text: string;
|
|
163
177
|
/** Line-stable identity: `<path>#<fingerprint>` of the normalized prose. Survives line drift. */
|
|
164
178
|
key: string;
|
|
165
|
-
/** Human-facing
|
|
179
|
+
/** Human-facing label for block-reason messages: `path:line` for a legacy advisory, or
|
|
180
|
+
* `<path> (review overview)` for a line-less `ccr-overview-v2` table finding. */
|
|
166
181
|
label: string;
|
|
167
182
|
}
|
|
168
183
|
|
|
@@ -250,11 +265,67 @@ export function questionFingerprint(text: string): string {
|
|
|
250
265
|
return fingerprint(normalizeAdvisoryText(text));
|
|
251
266
|
}
|
|
252
267
|
|
|
253
|
-
/** Parse Copilot's suppressed / low-confidence advisories out of a review body
|
|
254
|
-
*
|
|
255
|
-
*
|
|
268
|
+
/** Parse Copilot's suppressed / low-confidence advisories out of a review body, supporting BOTH
|
|
269
|
+
* formats Copilot emits:
|
|
270
|
+
*
|
|
271
|
+
* 1. The legacy `<summary>Suppressed comments (N)</summary>` block — each advisory is a bold
|
|
272
|
+
* `**path:line**` header followed by the advisory prose (`parseLegacySuppressedBlock`).
|
|
273
|
+
* 2. The newer `ccr-overview-v2` review body (issue #835), which has NO "Suppressed comments"
|
|
274
|
+
* block — findings appear only as clauses in the "What changed" file table's notes column,
|
|
275
|
+
* each ending `(<severity>, N vote[s])` (`parseOverviewV2TableFindings`).
|
|
276
|
+
*
|
|
277
|
+
* Returns de-duplicated advisories from both formats (empty when neither is present). */
|
|
256
278
|
export function parseSuppressedAdvisories(reviewBody: string | null | undefined): SuppressedAdvisory[] {
|
|
257
279
|
const body = reviewBody ?? "";
|
|
280
|
+
const out: SuppressedAdvisory[] = [];
|
|
281
|
+
const seen = new Set<string>();
|
|
282
|
+
for (const a of [...parseLegacySuppressedBlock(body), ...parseOverviewV2TableFindings(body)]) {
|
|
283
|
+
if (seen.has(a.key)) continue;
|
|
284
|
+
seen.add(a.key);
|
|
285
|
+
out.push(a);
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** A finding clause's trailing `(<severity>, N vote[s])` tag in the ccr-overview-v2 file table. */
|
|
291
|
+
const V2_FINDING_TAG = /\s*\(\s*[a-z]+\s*,\s*\d+\s+votes?\s*\)/gi;
|
|
292
|
+
|
|
293
|
+
/** Copilot's `ccr-overview-v2` review body (issue #835) has NO "Suppressed comments" block. Findings
|
|
294
|
+
* that got an inline comment are threads (gated via `isResolved`), but others appear ONLY as clauses
|
|
295
|
+
* in the "What changed" file table's notes column, each ending `(<severity>, N vote[s])`, e.g.
|
|
296
|
+
* `Adds X; A is wrong (moderate, 1 vote), and B is missing (nit, 3 votes).` Each such clause is an
|
|
297
|
+
* advisory keyed on `<path> :: <clause>` — the vote tag is EXCLUDED so a re-vote next round keeps the
|
|
298
|
+
* key stable. The first clause keeps any leading row summary (`Adds X; A is wrong`): Copilot does not
|
|
299
|
+
* always emit a summary (`Maven is declared…; requirements should model…` is ONE finding), so cutting
|
|
300
|
+
* at `; ` would silently truncate a finding — keeping it is fail-CLOSED. Table findings carry
|
|
301
|
+
* no line, so `line` is 0. Findings that duplicate an inline thread are still returned: fail-CLOSED
|
|
302
|
+
* (an extra ack is cheap; a finding silently dropped is the false-OPEN this gate prevents). */
|
|
303
|
+
function parseOverviewV2TableFindings(body: string): SuppressedAdvisory[] {
|
|
304
|
+
if (!/<!--\s*ccr-overview-v\d+\s*-->/i.test(body)) return [];
|
|
305
|
+
const out: SuppressedAdvisory[] = [];
|
|
306
|
+
const rowRe = /^\|\s*`([^`]+)`\s*\|(.*)\|\s*$/;
|
|
307
|
+
for (const raw of body.split(/\r?\n/)) {
|
|
308
|
+
const row = rowRe.exec(raw);
|
|
309
|
+
if (!row) continue;
|
|
310
|
+
const path = row[1].replace(/\u200b|\u200c|\u200d|\ufeff/g, "").trim();
|
|
311
|
+
const notes = row[2].trim();
|
|
312
|
+
let prev = 0;
|
|
313
|
+
V2_FINDING_TAG.lastIndex = 0;
|
|
314
|
+
let m: RegExpExecArray | null;
|
|
315
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: canonical regex-exec accumulation loop
|
|
316
|
+
while ((m = V2_FINDING_TAG.exec(notes)) !== null) {
|
|
317
|
+
const seg = notes.slice(prev, m.index);
|
|
318
|
+
prev = m.index + m[0].length;
|
|
319
|
+
const text = seg.replace(/^[\s,;.]*(?:and\s+)?/i, "").trim();
|
|
320
|
+
if (!text) continue;
|
|
321
|
+
out.push({ path, line: 0, text, key: advisoryStableKey(path, text), label: `${path} (review overview)` });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The legacy "Suppressed comments (N)" block: `**path:line**` headers followed by advisory prose. */
|
|
328
|
+
function parseLegacySuppressedBlock(body: string): SuppressedAdvisory[] {
|
|
258
329
|
const idx = body.search(/Suppressed comments\s*\(/i);
|
|
259
330
|
if (idx < 0) return [];
|
|
260
331
|
// Scan only from the "Suppressed comments" marker onward so a `**path:line**` elsewhere in the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.196.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -545,3 +545,20 @@
|
|
|
545
545
|
border-top: 1px solid var(--cockpit-edge);
|
|
546
546
|
padding-top: 6px;
|
|
547
547
|
}
|
|
548
|
+
|
|
549
|
+
/* ── Process focus banner (#833): the cockpit opened from an "Agent" grid link. ── */
|
|
550
|
+
|
|
551
|
+
.cockpit-focus[data-focus="none"] { display: none; }
|
|
552
|
+
|
|
553
|
+
.cockpit-focus {
|
|
554
|
+
display: flex;
|
|
555
|
+
gap: 12px;
|
|
556
|
+
align-items: center;
|
|
557
|
+
padding: 6px 10px;
|
|
558
|
+
margin-bottom: 8px;
|
|
559
|
+
border: 1px solid var(--cockpit-edge);
|
|
560
|
+
border-radius: 6px;
|
|
561
|
+
font-size: 13px;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
.cockpit-focus-clear { margin-left: auto; }
|
package/pages/cockpit/mount.js
CHANGED
|
@@ -35,18 +35,53 @@ function isPosInt(value) {
|
|
|
35
35
|
return Number.isSafeInteger(value) && value > 0;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
function decodeSegment(raw) {
|
|
39
|
+
if (raw === "") return undefined;
|
|
40
|
+
try {
|
|
41
|
+
const value = decodeURIComponent(raw);
|
|
42
|
+
return value === "" ? undefined : value;
|
|
43
|
+
} catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Mirrors app/agentic/cockpit/cockpit-route.ts `parseCockpitRoute` (keep in lockstep).
|
|
38
49
|
function parseCockpitRoute(hash) {
|
|
39
50
|
const route = hash.startsWith("#") ? hash.slice(1) : hash;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
51
|
+
const worker = "/cockpit/worker/";
|
|
52
|
+
if (route.startsWith(worker)) {
|
|
53
|
+
const instance = decodeSegment(route.slice(worker.length));
|
|
54
|
+
return instance === undefined ? { kind: "main" } : { kind: "worker", instance };
|
|
55
|
+
}
|
|
56
|
+
const process = "/cockpit/process/";
|
|
57
|
+
if (route.startsWith(process)) {
|
|
58
|
+
const processInstanceKey = decodeSegment(route.slice(process.length));
|
|
59
|
+
return processInstanceKey === undefined ? { kind: "main" } : { kind: "process", processInstanceKey };
|
|
60
|
+
}
|
|
61
|
+
return { kind: "main" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Mirrors app/agentic/cockpit/cockpit-route.ts `parseHostCockpitParam` (#833): the process key an
|
|
65
|
+
// "Agent" grid link put on the HOST page's `#/cockpit/<param>` route.
|
|
66
|
+
function parseHostCockpitParam(hash) {
|
|
67
|
+
const route = hash.startsWith("#") ? hash.slice(1) : hash;
|
|
68
|
+
const prefix = "/cockpit/";
|
|
69
|
+
if (!route.startsWith(prefix)) return undefined;
|
|
43
70
|
const raw = route.slice(prefix.length);
|
|
44
|
-
if (raw
|
|
71
|
+
if (raw.includes("/")) return undefined;
|
|
72
|
+
return decodeSegment(raw);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The host page's cockpit param, when this module runs in a same-origin App-View iframe. A
|
|
76
|
+
// cross-origin parent throws on `location` access — no focus then.
|
|
77
|
+
function hostCockpitParam() {
|
|
45
78
|
try {
|
|
46
|
-
const
|
|
47
|
-
|
|
79
|
+
const parent = window.parent;
|
|
80
|
+
if (parent == null || parent === window) return undefined;
|
|
81
|
+
const hash = parent.location?.hash;
|
|
82
|
+
return typeof hash === "string" ? parseHostCockpitParam(hash) : undefined;
|
|
48
83
|
} catch {
|
|
49
|
-
return
|
|
84
|
+
return undefined;
|
|
50
85
|
}
|
|
51
86
|
}
|
|
52
87
|
|
|
@@ -836,6 +871,10 @@ export function mountCockpit(host, opts = {}) {
|
|
|
836
871
|
const terminalNote = el(doc, "p", "cockpit-terminal-note");
|
|
837
872
|
terminalNote.setAttribute("data-terminal-note", "none");
|
|
838
873
|
terminalPanel.appendChild(terminalNote);
|
|
874
|
+
// Process-focus banner (#833): shown when the cockpit was opened from an "Agent" grid link.
|
|
875
|
+
const focusBanner = el(doc, "div", "cockpit-focus");
|
|
876
|
+
focusBanner.setAttribute("data-focus", "none");
|
|
877
|
+
shell.appendChild(focusBanner);
|
|
839
878
|
shell.appendChild(listRegion);
|
|
840
879
|
shell.appendChild(terminalPanel);
|
|
841
880
|
shell.appendChild(pastRegion);
|
|
@@ -865,6 +904,40 @@ export function mountCockpit(host, opts = {}) {
|
|
|
865
904
|
let agentRefreshing = false;
|
|
866
905
|
let agentRefreshPending = false;
|
|
867
906
|
let shownAgentInstanceKey;
|
|
907
|
+
// In-flight guard for an auto-triggered agent-history read, distinct from `shownAgentInstanceKey`.
|
|
908
|
+
// The focus auto-opens the newest run's history once; marking it `shown` only AFTER the read renders
|
|
909
|
+
// (in viewAgentHistory) means a failed/timed-out read leaves `shown` unset so a later poll retries —
|
|
910
|
+
// while this in-flight key still prevents a duplicate request stacking on the one outstanding (#834).
|
|
911
|
+
let autoHistoryKey;
|
|
912
|
+
// Process focus (#833). `focusKey` is the linked process instance; `focusKeys` grows to every
|
|
913
|
+
// process instance in its tree that hosts one of its agents (a call-activity CHILD runs the agent
|
|
914
|
+
// job, so its correlation carries the child key). While `focusAuto` holds, the terminal follows the
|
|
915
|
+
// focus (live drill if a job is running, else the newest captured transcript); any operator
|
|
916
|
+
// selection clears it so the cockpit never yanks the panel away from what they chose.
|
|
917
|
+
let lastReport;
|
|
918
|
+
let focusKey;
|
|
919
|
+
let focusKeys = new Set();
|
|
920
|
+
let focusAuto = false;
|
|
921
|
+
// The per-tree transcript-scan lock, scoped to the focus generation that holds it rather than a
|
|
922
|
+
// shared boolean: a stale scan from a superseded focus must NOT block the new focus's scan, and its
|
|
923
|
+
// late settle must NOT release the new focus's lock (review #834). `undefined` = free.
|
|
924
|
+
let focusReplayGen;
|
|
925
|
+
// Bumped by every setFocus(); captured before each focus-scoped async read (agent-instances list,
|
|
926
|
+
// per-tree transcript scan, history detail) so a response that resolves after the focus changed —
|
|
927
|
+
// including an A→B→A flip where the key matches again — is dropped instead of mutating the new
|
|
928
|
+
// focus's `focusKeys`, terminal, or agent detail (review #834).
|
|
929
|
+
let focusGen = 0;
|
|
930
|
+
// The stream of a live drill the FOCUS auto-selected (vs. one the operator picked). When that job
|
|
931
|
+
// finishes (its correlation drops out) the auto drill is torn down so the focus can fall back to the
|
|
932
|
+
// newest capture; a manually chosen stream is never auto-closed.
|
|
933
|
+
let autoDrillStream;
|
|
934
|
+
// Rate-limit the per-tree transcript scan: a fan-out focus with many children and no captures would
|
|
935
|
+
// otherwise re-scan every child on every supply poll. Scan at most once per focusScanMinMs.
|
|
936
|
+
const focusScanMinMs = staleAfterMs;
|
|
937
|
+
let focusScanAt = 0;
|
|
938
|
+
// Monotonic token for the agent-history DETAIL read, so a slow response for a previously selected
|
|
939
|
+
// run can't overwrite a newer selection within the same focus.
|
|
940
|
+
let detailToken = 0;
|
|
868
941
|
|
|
869
942
|
function setMode(next, stream) {
|
|
870
943
|
mode = next;
|
|
@@ -903,10 +976,10 @@ export function mountCockpit(host, opts = {}) {
|
|
|
903
976
|
function renderRoute() {
|
|
904
977
|
if (view == null) return;
|
|
905
978
|
if (route.kind === "worker") {
|
|
906
|
-
renderWorkerDetail(listRegion, doc, workerDetailView(view, route.instance), backToMain,
|
|
979
|
+
renderWorkerDetail(listRegion, doc, workerDetailView(view, route.instance), backToMain, userDrill);
|
|
907
980
|
return;
|
|
908
981
|
}
|
|
909
|
-
renderSupply(listRegion, doc, view,
|
|
982
|
+
renderSupply(listRegion, doc, view, userDrill, openWorker);
|
|
910
983
|
}
|
|
911
984
|
|
|
912
985
|
function openWorker(instance) {
|
|
@@ -932,6 +1005,13 @@ export function mountCockpit(host, opts = {}) {
|
|
|
932
1005
|
|
|
933
1006
|
function onHashChange() {
|
|
934
1007
|
route = parseCockpitRoute(location.hash);
|
|
1008
|
+
if (route.kind === "process") {
|
|
1009
|
+
if (route.processInstanceKey !== focusKey) setFocus(route.processInstanceKey);
|
|
1010
|
+
} else if (focusKey != null) {
|
|
1011
|
+
// Navigated to the main/worker route: the URL no longer selects a process, so drop the focus —
|
|
1012
|
+
// otherwise the banner and the background focus polls keep following a process the view left (#834).
|
|
1013
|
+
setFocus(undefined);
|
|
1014
|
+
}
|
|
935
1015
|
try {
|
|
936
1016
|
renderRoute();
|
|
937
1017
|
} catch (err) {
|
|
@@ -942,6 +1022,132 @@ export function mountCockpit(host, opts = {}) {
|
|
|
942
1022
|
|
|
943
1023
|
window.addEventListener("hashchange", onHashChange);
|
|
944
1024
|
|
|
1025
|
+
function userDrill(stream) {
|
|
1026
|
+
focusAuto = false;
|
|
1027
|
+
autoDrillStream = undefined;
|
|
1028
|
+
drillInto(stream);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function userReplay(stream) {
|
|
1032
|
+
focusAuto = false;
|
|
1033
|
+
autoDrillStream = undefined;
|
|
1034
|
+
return replayInto(stream);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function renderFocus() {
|
|
1038
|
+
focusBanner.replaceChildren();
|
|
1039
|
+
if (focusKey == null) {
|
|
1040
|
+
focusBanner.setAttribute("data-focus", "none");
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
focusBanner.setAttribute("data-focus", focusKey);
|
|
1044
|
+
focusBanner.appendChild(el(doc, "span", "cockpit-focus-label", `Focused on process instance ${focusKey}`));
|
|
1045
|
+
const clear = el(doc, "button", "cockpit-focus-clear", "Show all");
|
|
1046
|
+
clear.setAttribute("type", "button");
|
|
1047
|
+
clear.addEventListener("click", () => clearFocus());
|
|
1048
|
+
focusBanner.appendChild(clear);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// "Show all" must clear the URL that selected the focus, not just the banner — otherwise the focus
|
|
1052
|
+
// route survives in the address bar and a refresh/share reopens the process the operator just
|
|
1053
|
+
// dismissed (review #834). Standalone, the focus lives in our own `#/cockpit/process/<key>` hash, so
|
|
1054
|
+
// navigating it to `#/cockpit` fires onHashChange, which clears the focus. Embedded, the focus derives
|
|
1055
|
+
// from the host page's `#/cockpit/<param>` hash; rewrite the parent hash to `#/cockpit` so it stops
|
|
1056
|
+
// re-seeding the focus (a cross-origin parent throws on access — fall back to a local clear).
|
|
1057
|
+
function clearFocus() {
|
|
1058
|
+
if (route.kind === "process") {
|
|
1059
|
+
location.hash = "#/cockpit";
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
try {
|
|
1063
|
+
const parent = window.parent;
|
|
1064
|
+
if (parent != null && parent !== window && parseHostCockpitParam(parent.location?.hash ?? "") != null) {
|
|
1065
|
+
parent.location.hash = "#/cockpit";
|
|
1066
|
+
}
|
|
1067
|
+
} catch {
|
|
1068
|
+
// cross-origin host — can't touch its hash; fall through to a local focus clear.
|
|
1069
|
+
}
|
|
1070
|
+
setFocus(undefined);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function setFocus(key) {
|
|
1074
|
+
// A new focus invalidates every in-flight focus-scoped read (list/scan/detail) and the terminal
|
|
1075
|
+
// that was following the old focus. Bump the generation first so any awaited result now resolving
|
|
1076
|
+
// is rejected, then hard-reset the panels the old focus owned.
|
|
1077
|
+
focusGen++;
|
|
1078
|
+
focusKey = key;
|
|
1079
|
+
focusKeys = new Set(key == null ? [] : [key]);
|
|
1080
|
+
focusAuto = key != null;
|
|
1081
|
+
autoDrillStream = undefined;
|
|
1082
|
+
focusScanAt = 0;
|
|
1083
|
+
shownAgentInstanceKey = undefined;
|
|
1084
|
+
autoHistoryKey = undefined;
|
|
1085
|
+
// Clear the agent-history LIST alongside the detail: leaving the old focus's list up until the new
|
|
1086
|
+
// focus's reads land would let a click on a stale row start a detail read under the new focus's
|
|
1087
|
+
// generation, surfacing A's history beneath B's banner (review #834).
|
|
1088
|
+
agentRegion.replaceChildren();
|
|
1089
|
+
agentDetailRegion.replaceChildren();
|
|
1090
|
+
teardownTerminal();
|
|
1091
|
+
setMode(undefined, undefined);
|
|
1092
|
+
renderFocus();
|
|
1093
|
+
void focusTick();
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// Follow the focus: drill into a live job of any process in the focused tree; otherwise replay
|
|
1097
|
+
// the newest captured transcript of that tree (only while the terminal is idle).
|
|
1098
|
+
async function autoFocusTerminal() {
|
|
1099
|
+
if (!focusAuto || disposed) return;
|
|
1100
|
+
const gen = focusGen;
|
|
1101
|
+
const live = (lastReport?.correlations ?? []).find(
|
|
1102
|
+
(c) => c.processInstanceKey != null && focusKeys.has(c.processInstanceKey) && typeof c.stream === "string" && c.stream !== "",
|
|
1103
|
+
);
|
|
1104
|
+
if (live) {
|
|
1105
|
+
autoDrillStream = live.stream;
|
|
1106
|
+
drillInto(live.stream);
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
// The auto-followed live job finished — its correlation is gone but the terminal is still parked in
|
|
1110
|
+
// `live` mode on that stream. Close only THIS auto drill (never an operator's manual selection) so
|
|
1111
|
+
// the focus can fall back to the newest capture below.
|
|
1112
|
+
if (autoDrillStream != null && mode === "live" && drill?.stream === autoDrillStream) {
|
|
1113
|
+
autoDrillStream = undefined;
|
|
1114
|
+
teardownTerminal();
|
|
1115
|
+
setMode(undefined, undefined);
|
|
1116
|
+
}
|
|
1117
|
+
if (mode != null) return;
|
|
1118
|
+
// A scan already running for THIS focus generation blocks a duplicate; a lock still held by a
|
|
1119
|
+
// superseded focus (gen mismatch) must NOT block this focus's scan (review #834).
|
|
1120
|
+
if (focusReplayGen === gen) return;
|
|
1121
|
+
// Throttle the per-tree transcript scan (a focus with many children and no captures would otherwise
|
|
1122
|
+
// re-scan every child every poll). The first scan after a focus change runs immediately (focusScanAt=0).
|
|
1123
|
+
if (Date.now() - focusScanAt < focusScanMinMs) return;
|
|
1124
|
+
focusScanAt = Date.now();
|
|
1125
|
+
focusReplayGen = gen;
|
|
1126
|
+
try {
|
|
1127
|
+
const transcripts = [];
|
|
1128
|
+
for (const key of focusKeys) {
|
|
1129
|
+
const url = new URL(transcriptsUrl, location.href);
|
|
1130
|
+
url.searchParams.set("processInstanceKey", key);
|
|
1131
|
+
const report = await boundedJson(url.href);
|
|
1132
|
+
if (gen !== focusGen || disposed) return;
|
|
1133
|
+
transcripts.push(...(report.transcripts ?? []));
|
|
1134
|
+
}
|
|
1135
|
+
const newest = transcriptsView({ count: transcripts.length, transcripts }).sessions[0];
|
|
1136
|
+
if (newest && gen === focusGen && focusAuto && mode == null && !disposed) await replayInto(newest.stream);
|
|
1137
|
+
} catch (err) {
|
|
1138
|
+
onError(err);
|
|
1139
|
+
} finally {
|
|
1140
|
+
// Only release the lock if THIS scan still owns it: a newer focus may have claimed it while we
|
|
1141
|
+
// awaited, and clearing it then would let a duplicate scan stack under the new focus.
|
|
1142
|
+
if (focusReplayGen === gen) focusReplayGen = undefined;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
async function focusTick() {
|
|
1147
|
+
await refreshAgentHistory();
|
|
1148
|
+
await autoFocusTerminal();
|
|
1149
|
+
}
|
|
1150
|
+
|
|
945
1151
|
function drillInto(stream) {
|
|
946
1152
|
if (disposed || (mode === "live" && drill?.stream === stream)) return;
|
|
947
1153
|
// Claim the terminal region: bump the op token so an in-flight replay drops its stale result.
|
|
@@ -1113,7 +1319,7 @@ export function mountCockpit(host, opts = {}) {
|
|
|
1113
1319
|
pastRegion,
|
|
1114
1320
|
doc,
|
|
1115
1321
|
transcriptsView(report),
|
|
1116
|
-
|
|
1322
|
+
userReplay,
|
|
1117
1323
|
mode === "replay" ? shownStream : undefined,
|
|
1118
1324
|
instance == null ? "Past sessions" : "Job history",
|
|
1119
1325
|
instance == null ? "No captured sessions yet." : "No captured sessions for this worker yet.",
|
|
@@ -1143,6 +1349,7 @@ export function mountCockpit(host, opts = {}) {
|
|
|
1143
1349
|
}
|
|
1144
1350
|
if (disposed) return;
|
|
1145
1351
|
try {
|
|
1352
|
+
lastReport = report;
|
|
1146
1353
|
view = supplyView(report, staleAfterMs);
|
|
1147
1354
|
renderRoute();
|
|
1148
1355
|
} catch (err) {
|
|
@@ -1150,8 +1357,9 @@ export function mountCockpit(host, opts = {}) {
|
|
|
1150
1357
|
}
|
|
1151
1358
|
// Fire-and-forget: a hung transcripts endpoint must never stall the supply poll's next tick.
|
|
1152
1359
|
void refreshPast(routeInstance());
|
|
1153
|
-
// Same discipline for the engine agent-history list (issue #745): single-flight + bounded.
|
|
1154
|
-
|
|
1360
|
+
// Same discipline for the engine agent-history list (issue #745): single-flight + bounded. Under a
|
|
1361
|
+
// process focus (#833) the list is focus-filtered and the terminal follows the focus.
|
|
1362
|
+
void focusTick();
|
|
1155
1363
|
}
|
|
1156
1364
|
|
|
1157
1365
|
// The engine-native SETTLED agent-history endpoints (issue #745/#747), anchored module-relatively
|
|
@@ -1180,6 +1388,25 @@ export function mountCockpit(host, opts = {}) {
|
|
|
1180
1388
|
}
|
|
1181
1389
|
}
|
|
1182
1390
|
|
|
1391
|
+
// The engine AgentInstances of one focused process TREE (#833): those owned by the instance itself
|
|
1392
|
+
// and those whose ROOT it is (an agent in a call-activity child). Their process keys join
|
|
1393
|
+
// `focusKeys` so live-job correlations on a child instance match the focus.
|
|
1394
|
+
async function focusedAgentInstances(key, gen) {
|
|
1395
|
+
const byKey = new Map();
|
|
1396
|
+
for (const param of ["processInstanceKey", "rootProcessInstanceKey"]) {
|
|
1397
|
+
const url = new URL(agentInstancesUrl, location.href);
|
|
1398
|
+
url.searchParams.set(param, key);
|
|
1399
|
+
const report = await boundedJson(url.href);
|
|
1400
|
+
// The focus changed while this list was in flight — drop it rather than fold a stale tree's keys
|
|
1401
|
+
// into the new focus's `focusKeys`.
|
|
1402
|
+
if (gen !== focusGen || disposed) return { count: 0, instances: [] };
|
|
1403
|
+
for (const i of report.instances ?? []) byKey.set(i.agentInstanceKey, i);
|
|
1404
|
+
}
|
|
1405
|
+
const instances = [...byKey.values()];
|
|
1406
|
+
for (const i of instances) if (i.processInstanceKey) focusKeys.add(i.processInstanceKey);
|
|
1407
|
+
return { count: instances.length, instances };
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1183
1410
|
async function refreshAgentHistory() {
|
|
1184
1411
|
// Single-flight (mirrors refreshPast): a slow/hung engine read endpoint never stacks fetches nor
|
|
1185
1412
|
// gates the supply poll. The list is engine-global (settled AgentInstances), so it is not route-filtered.
|
|
@@ -1190,17 +1417,34 @@ export function mountCockpit(host, opts = {}) {
|
|
|
1190
1417
|
agentRefreshing = true;
|
|
1191
1418
|
try {
|
|
1192
1419
|
let report;
|
|
1420
|
+
const key = focusKey;
|
|
1421
|
+
const gen = focusGen;
|
|
1193
1422
|
try {
|
|
1194
|
-
report = await boundedJson(agentInstancesUrl);
|
|
1423
|
+
report = key == null ? await boundedJson(agentInstancesUrl) : await focusedAgentInstances(key, gen);
|
|
1195
1424
|
} catch (err) {
|
|
1196
1425
|
onError(err);
|
|
1197
1426
|
return;
|
|
1198
1427
|
}
|
|
1199
|
-
if (disposed) return;
|
|
1428
|
+
if (disposed || gen !== focusGen) return;
|
|
1429
|
+
let sessions;
|
|
1200
1430
|
try {
|
|
1201
|
-
|
|
1431
|
+
sessions = agentSessionsView(report);
|
|
1432
|
+
renderAgentSessions(agentRegion, doc, sessions, viewAgentHistory, shownAgentInstanceKey);
|
|
1202
1433
|
} catch (err) {
|
|
1203
1434
|
onError(err);
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
// Under a focus, open the newest run's history once so the linked agent is on screen. Fire it
|
|
1438
|
+
// WITHOUT awaiting: `focusTick` follows autoFocusTerminal right after this resolves, so a slow
|
|
1439
|
+
// (bounded) history-detail read must not stall the terminal from following the focused live job.
|
|
1440
|
+
// Gate on the in-flight `autoHistoryKey`, NOT `shownAgentInstanceKey`: the run is marked shown
|
|
1441
|
+
// only after its detail actually renders (viewAgentHistory), so a read that failed or timed out
|
|
1442
|
+
// leaves `shown` unset and a later poll retries it — while `autoHistoryKey` still stops a second
|
|
1443
|
+
// request stacking on the one already outstanding (#834).
|
|
1444
|
+
const newest = sessions.sessions[0];
|
|
1445
|
+
if (key != null && shownAgentInstanceKey == null && newest != null && autoHistoryKey !== newest.agentInstanceKey) {
|
|
1446
|
+
autoHistoryKey = newest.agentInstanceKey;
|
|
1447
|
+
void viewAgentHistory(newest.agentInstanceKey);
|
|
1204
1448
|
}
|
|
1205
1449
|
} finally {
|
|
1206
1450
|
agentRefreshing = false;
|
|
@@ -1213,14 +1457,22 @@ export function mountCockpit(host, opts = {}) {
|
|
|
1213
1457
|
|
|
1214
1458
|
async function viewAgentHistory(agentInstanceKey) {
|
|
1215
1459
|
if (disposed) return;
|
|
1460
|
+
// Capture the focus generation and a fresh selection token; a response that resolves after the
|
|
1461
|
+
// focus changed or after a newer run was selected is dropped instead of overwriting the current view.
|
|
1462
|
+
const gen = focusGen;
|
|
1463
|
+
const token = ++detailToken;
|
|
1216
1464
|
let report;
|
|
1217
1465
|
try {
|
|
1218
1466
|
report = await boundedJson(agentHistoryReadUrl(agentInstanceKey));
|
|
1219
1467
|
} catch (err) {
|
|
1220
1468
|
onError(err);
|
|
1469
|
+
// The auto-open read failed: release the in-flight key so a later poll retries it (the run was
|
|
1470
|
+
// never marked shown). A stale superseded read is left latched — a newer selection already
|
|
1471
|
+
// replaced it.
|
|
1472
|
+
if (gen === focusGen && autoHistoryKey === agentInstanceKey) autoHistoryKey = undefined;
|
|
1221
1473
|
return;
|
|
1222
1474
|
}
|
|
1223
|
-
if (disposed) return;
|
|
1475
|
+
if (disposed || gen !== focusGen || token !== detailToken) return;
|
|
1224
1476
|
try {
|
|
1225
1477
|
shownAgentInstanceKey = agentInstanceKey;
|
|
1226
1478
|
renderAgentHistory(agentDetailRegion, doc, agentHistoryView(report));
|
|
@@ -1228,6 +1480,9 @@ export function mountCockpit(host, opts = {}) {
|
|
|
1228
1480
|
void refreshAgentHistory();
|
|
1229
1481
|
} catch (err) {
|
|
1230
1482
|
onError(err);
|
|
1483
|
+
} finally {
|
|
1484
|
+
// The auto-open read settled: release the in-flight key (no-op for a manual or superseded read).
|
|
1485
|
+
if (gen === focusGen && autoHistoryKey === agentInstanceKey) autoHistoryKey = undefined;
|
|
1231
1486
|
}
|
|
1232
1487
|
}
|
|
1233
1488
|
|
|
@@ -1261,8 +1516,16 @@ export function mountCockpit(host, opts = {}) {
|
|
|
1261
1516
|
window.removeEventListener("hashchange", onHashChange);
|
|
1262
1517
|
}
|
|
1263
1518
|
|
|
1519
|
+
const initialFocus = route.kind === "process" ? route.processInstanceKey : hostCockpitParam();
|
|
1520
|
+
if (initialFocus != null) {
|
|
1521
|
+
focusKey = initialFocus;
|
|
1522
|
+
focusKeys = new Set([initialFocus]);
|
|
1523
|
+
focusAuto = true;
|
|
1524
|
+
}
|
|
1525
|
+
renderFocus();
|
|
1526
|
+
|
|
1264
1527
|
start();
|
|
1265
|
-
return { start, stop, dispose, refresh, drill:
|
|
1528
|
+
return { start, stop, dispose, refresh, drill: userDrill, replay: userReplay, viewAgentHistory, focus: setFocus };
|
|
1266
1529
|
}
|
|
1267
1530
|
|
|
1268
1531
|
/**
|
|
@@ -34,14 +34,20 @@
|
|
|
34
34
|
// credentials via the URL fragment — the report guard secret via #secret= (sent as
|
|
35
35
|
// x-hook-secret) and the relay credentials via #token= and #capability= — NOT the query string,
|
|
36
36
|
// so they never leak via server access logs, browser history, or the Referer header. The
|
|
37
|
-
// fragment
|
|
37
|
+
// sensitive fragment keys are stripped from the address bar immediately after they are read,
|
|
38
|
+
// while any OTHER fragment — notably a `#/cockpit/process/<key>` focus route (#833) — is
|
|
39
|
+
// preserved so mountCockpit can still read it and focus that process.
|
|
38
40
|
const params = new URLSearchParams(location.search);
|
|
39
41
|
const secrets = new URLSearchParams(location.hash.slice(1));
|
|
40
42
|
const hookSecret = secrets.get("secret") ?? undefined;
|
|
41
43
|
const relayToken = secrets.get("token") ?? undefined;
|
|
42
44
|
const relayCapability = secrets.get("capability") ?? undefined;
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
+
if (hookSecret !== undefined || relayToken !== undefined || relayCapability !== undefined) {
|
|
46
|
+
secrets.delete("secret");
|
|
47
|
+
secrets.delete("token");
|
|
48
|
+
secrets.delete("capability");
|
|
49
|
+
const rest = secrets.toString();
|
|
50
|
+
history.replaceState(null, "", location.pathname + location.search + (rest ? `#${rest}` : ""));
|
|
45
51
|
}
|
|
46
52
|
mountCockpit(document.getElementById("cockpit-root"), {
|
|
47
53
|
reportUrl: params.get("report") ?? undefined,
|
|
@@ -96,6 +96,7 @@
|
|
|
96
96
|
"columns": [
|
|
97
97
|
{ "field": "title", "template": "{{title}}", "header": "Graph", "subtitleField": "run_key", "truncate": true, "width": "30%" },
|
|
98
98
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
99
|
+
{ "field": "process_key", "header": "Agent", "template": "Transcript", "width": "7rem", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
|
|
99
100
|
{
|
|
100
101
|
"field": "stage",
|
|
101
102
|
"header": "Pipeline",
|
|
@@ -132,14 +132,15 @@
|
|
|
132
132
|
{ "label": "All", "filter": [] }
|
|
133
133
|
],
|
|
134
134
|
"columns": [
|
|
135
|
-
{ "field": "title", "template": "{{title}}", "header": "Graph", "subtitleField": "run_key", "truncate": true, "width": "
|
|
135
|
+
{ "field": "title", "template": "{{title}}", "header": "Graph", "subtitleField": "run_key", "truncate": true, "width": "16%", "link": { "kind": "page", "page": "delivery-graph-detail", "keyField": "run_key" } },
|
|
136
136
|
{ "field": "status", "header": "Status", "truncate": true, "width": "10%", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
137
|
+
{ "field": "process_key", "header": "Agent", "template": "Transcript", "truncate": true, "width": "5%", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
|
|
137
138
|
{ "field": "process_key", "header": "Instance", "width": "8%", "truncate": true, "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
138
139
|
{
|
|
139
140
|
"field": "stage",
|
|
140
141
|
"header": "Pipeline",
|
|
141
142
|
"kind": "pipeline",
|
|
142
|
-
"width": "
|
|
143
|
+
"width": "15%",
|
|
143
144
|
"stages": [
|
|
144
145
|
{ "key": "Requested", "label": "Requested" },
|
|
145
146
|
{ "key": "Implementing", "label": "Implementing" },
|
|
@@ -149,6 +149,7 @@
|
|
|
149
149
|
{ "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "plan_key", "truncate": true, "width": "34%", "linkField": "issue_url" },
|
|
150
150
|
{ "field": "epic_phase", "header": "Phase" },
|
|
151
151
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
152
|
+
{ "field": "process_key", "header": "Agent", "template": "Transcript", "width": "7rem", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
|
|
152
153
|
{ "field": "delivery", "header": "Delivery" },
|
|
153
154
|
{ "field": "wait_gate_label", "header": "Wait gate" },
|
|
154
155
|
{ "field": "promotion_state", "header": "Promotion" },
|
|
@@ -242,6 +243,7 @@
|
|
|
242
243
|
{ "field": "task_id", "header": "Task" },
|
|
243
244
|
{ "field": "title", "header": "Title" },
|
|
244
245
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
246
|
+
{ "field": "process_key", "header": "Agent", "template": "Transcript", "width": "7rem", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
|
|
245
247
|
{ "field": "pr_key", "header": "PR", "linkField": "pr_url" },
|
|
246
248
|
{ "field": "summary", "header": "Summary" },
|
|
247
249
|
{ "field": "updated_at", "header": "Updated", "format": "datetime" }
|
package/pages/epic.page.json
CHANGED
|
@@ -121,6 +121,7 @@
|
|
|
121
121
|
"columns": [
|
|
122
122
|
{ "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "plan_key", "truncate": true, "width": "38%", "link": { "kind": "page", "page": "epic-detail", "keyField": "plan_key" } },
|
|
123
123
|
{ "field": "epic_phase", "header": "Phase", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
124
|
+
{ "field": "process_key", "header": "Agent", "template": "Transcript", "width": "7rem", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
|
|
124
125
|
{ "field": "delivery_label", "header": "Delivery", "truncate": true },
|
|
125
126
|
{ "field": "task_count", "header": "Tasks" },
|
|
126
127
|
{ "field": "issue_number", "header": "Issue", "linkField": "issue_url" },
|
package/pages/feature.page.json
CHANGED
|
@@ -136,6 +136,7 @@
|
|
|
136
136
|
"notInPathField": "stage_skipped",
|
|
137
137
|
"locus": { "field": "process_key", "link": { "kind": "processExplorer", "keyField": "process_key" } }
|
|
138
138
|
},
|
|
139
|
+
{ "field": "process_key", "header": "Agent", "template": "Transcript", "width": "7rem", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
|
|
139
140
|
{ "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
|
|
140
141
|
],
|
|
141
142
|
"rowActions": [
|