@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/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.196.0](https://github.com/nanobpm/nano-workforce/compare/v0.195.2...v0.196.0) (2026-09-30)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **cockpit:** 'Agent' link on active rows opens the cockpit focused on that process ([#834](https://github.com/nanobpm/nano-workforce/issues/834)) ([c9c07a3](https://github.com/nanobpm/nano-workforce/commit/c9c07a3886d065d407260c2dabe2abb410fc7d2d)), closes [#833](https://github.com/nanobpm/nano-workforce/issues/833)
|
|
6
|
+
|
|
7
|
+
## [0.195.2](https://github.com/nanobpm/nano-workforce/compare/v0.195.1...v0.195.2) (2026-09-30)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **converge-gate:** gate Copilot ccr-overview-v2 table findings ([#835](https://github.com/nanobpm/nano-workforce/issues/835)) ([#836](https://github.com/nanobpm/nano-workforce/issues/836)) ([8e3c0b6](https://github.com/nanobpm/nano-workforce/commit/8e3c0b690b7a212b0fcd4904b9e50ab7589e5bda))
|
|
12
|
+
|
|
1
13
|
## [0.195.1](https://github.com/nanobpm/nano-workforce/compare/v0.195.0...v0.195.1) (2026-09-30)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { test } from "node:test";
|
|
3
3
|
|
|
4
|
-
import { parseCockpitRoute } from "./cockpit-route.ts";
|
|
4
|
+
import { parseCockpitRoute, parseHostCockpitParam } from "./cockpit-route.ts";
|
|
5
5
|
|
|
6
6
|
test("parses empty and main cockpit hashes as the main route", () => {
|
|
7
7
|
assert.deepEqual(parseCockpitRoute(""), { kind: "main" });
|
|
@@ -19,3 +19,17 @@ test("empty worker and junk hashes fall back to the main route", () => {
|
|
|
19
19
|
assert.deepEqual(parseCockpitRoute("#/elsewhere"), { kind: "main" });
|
|
20
20
|
assert.deepEqual(parseCockpitRoute("#/cockpit/worker/%E0%A4%A"), { kind: "main" });
|
|
21
21
|
});
|
|
22
|
+
|
|
23
|
+
test("#833: parses a process-focus hash into the process route", () => {
|
|
24
|
+
assert.deepEqual(parseCockpitRoute("#/cockpit/process/2251799813685249"), { kind: "process", processInstanceKey: "2251799813685249" });
|
|
25
|
+
assert.deepEqual(parseCockpitRoute("#/cockpit/process/"), { kind: "main" });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("#833: the host page's #/cockpit/<process_key> param (an 'Agent' grid link) focuses that process", () => {
|
|
29
|
+
assert.equal(parseHostCockpitParam("#/cockpit/2251799813685249"), "2251799813685249");
|
|
30
|
+
assert.equal(parseHostCockpitParam("#/cockpit/a%20b"), "a b");
|
|
31
|
+
assert.equal(parseHostCockpitParam("#/cockpit"), undefined);
|
|
32
|
+
assert.equal(parseHostCockpitParam("#/cockpit/"), undefined);
|
|
33
|
+
assert.equal(parseHostCockpitParam("#/overview/123"), undefined);
|
|
34
|
+
assert.equal(parseHostCockpitParam("#/cockpit/%E0%A4%A"), undefined);
|
|
35
|
+
});
|
|
@@ -1,17 +1,46 @@
|
|
|
1
|
-
export type CockpitRoute =
|
|
1
|
+
export type CockpitRoute =
|
|
2
|
+
| { readonly kind: "main" }
|
|
3
|
+
| { readonly kind: "worker"; readonly instance: string }
|
|
4
|
+
/** Focused on one process instance (#833) — the target of an "Agent" grid link. */
|
|
5
|
+
| { readonly kind: "process"; readonly processInstanceKey: string };
|
|
6
|
+
|
|
7
|
+
function decodeSegment(raw: string): string | undefined {
|
|
8
|
+
if (raw === "") return undefined;
|
|
9
|
+
try {
|
|
10
|
+
const value = decodeURIComponent(raw);
|
|
11
|
+
return value === "" ? undefined : value;
|
|
12
|
+
} catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
2
16
|
|
|
3
17
|
/** Parse the cockpit hash route. Unknown or malformed hashes fall back to the main list. */
|
|
4
18
|
export function parseCockpitRoute(hash: string): CockpitRoute {
|
|
5
19
|
const route = hash.startsWith("#") ? hash.slice(1) : hash;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
return { kind: "main" };
|
|
20
|
+
const worker = "/cockpit/worker/";
|
|
21
|
+
if (route.startsWith(worker)) {
|
|
22
|
+
const instance = decodeSegment(route.slice(worker.length));
|
|
23
|
+
return instance === undefined ? { kind: "main" } : { kind: "worker", instance };
|
|
24
|
+
}
|
|
25
|
+
const process = "/cockpit/process/";
|
|
26
|
+
if (route.startsWith(process)) {
|
|
27
|
+
const processInstanceKey = decodeSegment(route.slice(process.length));
|
|
28
|
+
return processInstanceKey === undefined ? { kind: "main" } : { kind: "process", processInstanceKey };
|
|
16
29
|
}
|
|
30
|
+
return { kind: "main" };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The process-instance key carried by the HOST page's `#/cockpit/<param>` route (#833). An "Agent" grid
|
|
35
|
+
* link (`{ kind: "page", page: "cockpit", keyField: "process_key" }`) navigates the host page — the
|
|
36
|
+
* cockpit embed is an iframe with its own hash — so the embed reads the param from its same-origin
|
|
37
|
+
* parent. Only a single, decodable segment counts; anything else is undefined (no focus).
|
|
38
|
+
*/
|
|
39
|
+
export function parseHostCockpitParam(hash: string): string | undefined {
|
|
40
|
+
const route = hash.startsWith("#") ? hash.slice(1) : hash;
|
|
41
|
+
const prefix = "/cockpit/";
|
|
42
|
+
if (!route.startsWith(prefix)) return undefined;
|
|
43
|
+
const raw = route.slice(prefix.length);
|
|
44
|
+
if (raw.includes("/")) return undefined;
|
|
45
|
+
return decodeSegment(raw);
|
|
17
46
|
}
|
|
@@ -99,6 +99,7 @@ test("the rendered transcript region sits directly beneath the Workers — suppl
|
|
|
99
99
|
const shell = document.querySelector(".cockpit-shell");
|
|
100
100
|
const order = [...(shell?.children ?? [])].map((c: { className: string }) => c.className);
|
|
101
101
|
assertEquals(order, [
|
|
102
|
+
"cockpit-focus",
|
|
102
103
|
"cockpit-supply-region",
|
|
103
104
|
"cockpit-terminal",
|
|
104
105
|
"cockpit-past-region",
|
|
@@ -407,3 +408,393 @@ test("agent-history tool call with an empty-string elementId renders no empty ()
|
|
|
407
408
|
restore();
|
|
408
409
|
}
|
|
409
410
|
});
|
|
411
|
+
|
|
412
|
+
// #833 — an "Agent" grid link lands on the cockpit focused on ONE process instance. The agent may run
|
|
413
|
+
// in a CHILD instance (call activity), so the focus resolves the engine AgentInstances whose ROOT is
|
|
414
|
+
// the linked key and matches live job correlations against every instance in that tree.
|
|
415
|
+
const PI_ROOT = "pi-root";
|
|
416
|
+
const FOCUS_INSTANCE = {
|
|
417
|
+
agentInstanceKey: "ai-7",
|
|
418
|
+
status: "THINKING",
|
|
419
|
+
processInstanceKey: "pi-child",
|
|
420
|
+
rootProcessInstanceKey: PI_ROOT,
|
|
421
|
+
elementId: "implement-task",
|
|
422
|
+
creationDate: "2024-01-01T00:00:00Z",
|
|
423
|
+
};
|
|
424
|
+
const FOCUS_HISTORY = {
|
|
425
|
+
agentInstanceKey: "ai-7",
|
|
426
|
+
count: 1,
|
|
427
|
+
instance: FOCUS_INSTANCE,
|
|
428
|
+
records: [
|
|
429
|
+
{
|
|
430
|
+
historyItemKey: "h-0",
|
|
431
|
+
agentInstanceKey: "ai-7",
|
|
432
|
+
loopIteration: 0,
|
|
433
|
+
role: "ASSISTANT",
|
|
434
|
+
commitStatus: "COMMITTED",
|
|
435
|
+
content: [{ contentType: "TEXT", text: "working on the focused process" }],
|
|
436
|
+
toolCalls: [],
|
|
437
|
+
},
|
|
438
|
+
],
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
function focusStub(opts: { correlations: unknown[]; transcripts?: unknown[]; replay?: unknown; seen?: string[] }) {
|
|
442
|
+
return (url: string): Promise<unknown> => {
|
|
443
|
+
opts.seen?.push(url);
|
|
444
|
+
const ok = (body: unknown) => Promise.resolve({ ok: true, status: 200, json: async () => body });
|
|
445
|
+
if (url.includes("/supply")) return ok({ leaves: [], workers: [], correlations: opts.correlations });
|
|
446
|
+
if (/\/agent-instances\/[^/?]+\/history/.test(url)) return ok(FOCUS_HISTORY);
|
|
447
|
+
if (url.includes("/agent-instances")) {
|
|
448
|
+
const root = url.includes(`rootProcessInstanceKey=${PI_ROOT}`);
|
|
449
|
+
return ok(root ? { count: 1, instances: [FOCUS_INSTANCE] } : { count: 0, instances: [] });
|
|
450
|
+
}
|
|
451
|
+
if (/[?&]stream=/.test(url)) return ok(opts.replay);
|
|
452
|
+
if (url.includes("/transcripts")) {
|
|
453
|
+
const mine = (opts.transcripts ?? []).filter((t) => url.includes(`processInstanceKey=${(t as { processInstanceKey: string }).processInstanceKey}`));
|
|
454
|
+
return ok({ count: mine.length, transcripts: mine });
|
|
455
|
+
}
|
|
456
|
+
return Promise.resolve({ ok: false, status: 404, json: async () => ({}) });
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function settle(): Promise<void> {
|
|
461
|
+
for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
test("#833: a process-focus route live-drills the worker running a job of a CHILD instance and shows its agent history", async () => {
|
|
465
|
+
const restore = installEnv(focusStub({ correlations: [{ jobKey: "j1", stream: "wk-a/j1", processInstanceKey: "pi-child" }] }));
|
|
466
|
+
(globalThis as { location: { hash: string } }).location.hash = `#/cockpit/process/${PI_ROOT}`;
|
|
467
|
+
try {
|
|
468
|
+
const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
|
|
469
|
+
const handle = mountCockpit(document.getElementById("root"), OPTS);
|
|
470
|
+
try {
|
|
471
|
+
await settle();
|
|
472
|
+
assert((document.querySelector(".cockpit-focus")?.textContent ?? "").includes(PI_ROOT), "the focus banner names the process");
|
|
473
|
+
assertEquals(document.querySelector(".cockpit-terminal")?.getAttribute("data-terminal-mode"), "live");
|
|
474
|
+
assert(StubWebSocket.instances.length > 0, "a relay socket was opened for the focused job");
|
|
475
|
+
const transcript = document.querySelector('.cockpit-agent-transcript[data-agent-instance-key="ai-7"]');
|
|
476
|
+
assert((transcript?.textContent ?? "").includes("working on the focused process"), "the focused agent's history is shown");
|
|
477
|
+
} finally {
|
|
478
|
+
handle.dispose();
|
|
479
|
+
}
|
|
480
|
+
} finally {
|
|
481
|
+
restore();
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("#833: with no live job, the process focus replays the newest captured transcript of that process", async () => {
|
|
486
|
+
const replay = {
|
|
487
|
+
stream: "wk-a/j9",
|
|
488
|
+
from: 0,
|
|
489
|
+
gap: false,
|
|
490
|
+
nextOffset: 1,
|
|
491
|
+
entries: [{ offset: 0, chunk: envChunk("message", { role: "assistant", text: "finished earlier" }) }],
|
|
492
|
+
};
|
|
493
|
+
const transcripts = [
|
|
494
|
+
{ stream: "wk-a/j9", lifecycle: "ephemeral", status: "completed", createdAt: "2024-01-01T00:00:00Z", nextOffset: 1, byteLength: 10, chunkCount: 1, processInstanceKey: "pi-child" },
|
|
495
|
+
];
|
|
496
|
+
const restore = installEnv(focusStub({ correlations: [], transcripts, replay }));
|
|
497
|
+
(globalThis as { location: { hash: string } }).location.hash = `#/cockpit/process/${PI_ROOT}`;
|
|
498
|
+
try {
|
|
499
|
+
const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
|
|
500
|
+
const handle = mountCockpit(document.getElementById("root"), OPTS);
|
|
501
|
+
try {
|
|
502
|
+
await settle();
|
|
503
|
+
assertEquals(document.querySelector(".cockpit-terminal")?.getAttribute("data-terminal-mode"), "replay");
|
|
504
|
+
assert((document.querySelector('[data-terminal="host"]')?.textContent ?? "").includes("finished earlier"));
|
|
505
|
+
} finally {
|
|
506
|
+
handle.dispose();
|
|
507
|
+
}
|
|
508
|
+
} finally {
|
|
509
|
+
restore();
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("#833: embedded, the host page's #/cockpit/<process_key> (the grid 'Agent' link) focuses the cockpit", async () => {
|
|
514
|
+
const restore = installEnv(focusStub({ correlations: [] }));
|
|
515
|
+
const win = (globalThis as { window: Record<string, unknown> }).window;
|
|
516
|
+
const savedParent = win.parent;
|
|
517
|
+
win.parent = { location: { hash: `#/cockpit/${PI_ROOT}` } };
|
|
518
|
+
try {
|
|
519
|
+
const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
|
|
520
|
+
const handle = mountCockpit(document.getElementById("root"), OPTS);
|
|
521
|
+
try {
|
|
522
|
+
await settle();
|
|
523
|
+
assert((document.querySelector(".cockpit-focus")?.textContent ?? "").includes(PI_ROOT), "the host param focused the cockpit");
|
|
524
|
+
} finally {
|
|
525
|
+
handle.dispose();
|
|
526
|
+
}
|
|
527
|
+
} finally {
|
|
528
|
+
win.parent = savedParent;
|
|
529
|
+
restore();
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test("#833: no focus route → no focus banner", async () => {
|
|
534
|
+
const restore = installEnv(fetchStub());
|
|
535
|
+
try {
|
|
536
|
+
const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
|
|
537
|
+
const handle = mountCockpit(document.getElementById("root"), OPTS);
|
|
538
|
+
try {
|
|
539
|
+
await settle();
|
|
540
|
+
assertEquals(document.querySelector(".cockpit-focus")?.getAttribute("data-focus"), "none");
|
|
541
|
+
} finally {
|
|
542
|
+
handle.dispose();
|
|
543
|
+
}
|
|
544
|
+
} finally {
|
|
545
|
+
restore();
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// #834 review — a MUTABLE fetch stub: the supply correlations and per-tree transcripts can change
|
|
550
|
+
// between polls, so a test can model a focused live job finishing and its session becoming a capture.
|
|
551
|
+
function mutableFocusStub(state: { correlations: unknown[]; transcripts: unknown[]; replay?: unknown; roots?: string[] }) {
|
|
552
|
+
return (url: string): Promise<unknown> => {
|
|
553
|
+
const ok = (body: unknown) => Promise.resolve({ ok: true, status: 200, json: async () => body });
|
|
554
|
+
if (url.includes("/supply")) return ok({ leaves: [], workers: [], correlations: state.correlations });
|
|
555
|
+
if (/\/agent-instances\/[^/?]+\/history/.test(url)) return ok(FOCUS_HISTORY);
|
|
556
|
+
if (url.includes("/agent-instances")) {
|
|
557
|
+
const root = (state.roots ?? [PI_ROOT]).some((r) => url.includes(`rootProcessInstanceKey=${r}`));
|
|
558
|
+
return ok(root ? { count: 1, instances: [FOCUS_INSTANCE] } : { count: 0, instances: [] });
|
|
559
|
+
}
|
|
560
|
+
if (/[?&]stream=/.test(url)) return ok(state.replay);
|
|
561
|
+
if (url.includes("/transcripts")) {
|
|
562
|
+
const mine = state.transcripts.filter((t) => url.includes(`processInstanceKey=${(t as { processInstanceKey: string }).processInstanceKey}`));
|
|
563
|
+
return ok({ count: mine.length, transcripts: mine });
|
|
564
|
+
}
|
|
565
|
+
return Promise.resolve({ ok: false, status: 404, json: async () => ({}) });
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
test("#834: when the auto-followed live job finishes, the focus falls back to the newest capture", async () => {
|
|
570
|
+
const replay = {
|
|
571
|
+
stream: "wk-a/j1",
|
|
572
|
+
from: 0,
|
|
573
|
+
gap: false,
|
|
574
|
+
nextOffset: 1,
|
|
575
|
+
entries: [{ offset: 0, chunk: envChunk("message", { role: "assistant", text: "captured after finish" }) }],
|
|
576
|
+
};
|
|
577
|
+
const state = {
|
|
578
|
+
correlations: [{ jobKey: "j1", stream: "wk-a/j1", processInstanceKey: "pi-child" }] as unknown[],
|
|
579
|
+
transcripts: [] as unknown[],
|
|
580
|
+
replay,
|
|
581
|
+
};
|
|
582
|
+
const restore = installEnv(mutableFocusStub(state));
|
|
583
|
+
(globalThis as { location: { hash: string } }).location.hash = `#/cockpit/process/${PI_ROOT}`;
|
|
584
|
+
try {
|
|
585
|
+
const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
|
|
586
|
+
const handle = mountCockpit(document.getElementById("root"), OPTS);
|
|
587
|
+
try {
|
|
588
|
+
await settle();
|
|
589
|
+
assertEquals(document.querySelector(".cockpit-terminal")?.getAttribute("data-terminal-mode"), "live");
|
|
590
|
+
// The followed job finishes: its live correlation drops out and its session becomes a capture.
|
|
591
|
+
state.correlations = [];
|
|
592
|
+
state.transcripts = [
|
|
593
|
+
{ stream: "wk-a/j1", lifecycle: "ephemeral", status: "completed", createdAt: "2024-01-01T00:00:00Z", nextOffset: 1, byteLength: 10, chunkCount: 1, processInstanceKey: "pi-child" },
|
|
594
|
+
];
|
|
595
|
+
await handle.refresh();
|
|
596
|
+
await settle();
|
|
597
|
+
assertEquals(document.querySelector(".cockpit-terminal")?.getAttribute("data-terminal-mode"), "replay");
|
|
598
|
+
assert((document.querySelector('[data-terminal="host"]')?.textContent ?? "").includes("captured after finish"), "the newest capture replays after the live job ends");
|
|
599
|
+
} finally {
|
|
600
|
+
handle.dispose();
|
|
601
|
+
}
|
|
602
|
+
} finally {
|
|
603
|
+
restore();
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test("#834: changing the focused process resets the terminal — it never keeps showing the old process's live stream", async () => {
|
|
608
|
+
const state = {
|
|
609
|
+
correlations: [{ jobKey: "j1", stream: "wk-a/j1", processInstanceKey: "pi-child" }] as unknown[],
|
|
610
|
+
transcripts: [] as unknown[],
|
|
611
|
+
replay: undefined as unknown,
|
|
612
|
+
};
|
|
613
|
+
const restore = installEnv(mutableFocusStub(state));
|
|
614
|
+
(globalThis as { location: { hash: string } }).location.hash = `#/cockpit/process/${PI_ROOT}`;
|
|
615
|
+
try {
|
|
616
|
+
const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
|
|
617
|
+
const handle = mountCockpit(document.getElementById("root"), OPTS);
|
|
618
|
+
try {
|
|
619
|
+
await settle();
|
|
620
|
+
assertEquals(document.querySelector(".cockpit-terminal")?.getAttribute("data-terminal-mode"), "live");
|
|
621
|
+
// Focus a DIFFERENT process with no live job and no capture: the terminal must reset to idle,
|
|
622
|
+
// not keep replaying process A's live stream.
|
|
623
|
+
handle.focus("pi-other");
|
|
624
|
+
await settle();
|
|
625
|
+
assert((document.querySelector(".cockpit-focus")?.textContent ?? "").includes("pi-other"), "the banner names the new focus");
|
|
626
|
+
assertEquals(document.querySelector(".cockpit-terminal")?.getAttribute("data-terminal-mode"), "idle");
|
|
627
|
+
} finally {
|
|
628
|
+
handle.dispose();
|
|
629
|
+
}
|
|
630
|
+
} finally {
|
|
631
|
+
restore();
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
test("#834: navigating from a process route to the main route clears the focus", async () => {
|
|
636
|
+
const restore = installEnv(focusStub({ correlations: [] }));
|
|
637
|
+
(globalThis as { location: { hash: string } }).location.hash = `#/cockpit/process/${PI_ROOT}`;
|
|
638
|
+
try {
|
|
639
|
+
const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
|
|
640
|
+
const handle = mountCockpit(document.getElementById("root"), OPTS);
|
|
641
|
+
try {
|
|
642
|
+
await settle();
|
|
643
|
+
assert((document.querySelector(".cockpit-focus")?.textContent ?? "").includes(PI_ROOT), "the process route focused the cockpit");
|
|
644
|
+
// Navigate away to the main route: the URL no longer selects a process, so the focus must clear
|
|
645
|
+
// (the banner and background focus polls must not keep following the process the view left).
|
|
646
|
+
// linkedom's dispatchEvent rejects a native Event, so construct the event from the stub window.
|
|
647
|
+
(globalThis as { location: { hash: string } }).location.hash = "#/cockpit";
|
|
648
|
+
const win = (globalThis as { window: { Event: new (t: string) => unknown; dispatchEvent: (e: unknown) => void } }).window;
|
|
649
|
+
win.dispatchEvent(new win.Event("hashchange"));
|
|
650
|
+
await settle();
|
|
651
|
+
assertEquals(document.querySelector(".cockpit-focus")?.getAttribute("data-focus"), "none");
|
|
652
|
+
} finally {
|
|
653
|
+
handle.dispose();
|
|
654
|
+
}
|
|
655
|
+
} finally {
|
|
656
|
+
restore();
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
test("#834: a failed auto-opened history read is retried on a later poll", async () => {
|
|
661
|
+
// The history read fails on the FIRST attempt (ok:false → boundedJson throws), then succeeds. The
|
|
662
|
+
// focus must not latch the run as shown on the failed read: a later poll re-fires it and lands the detail.
|
|
663
|
+
let historyCalls = 0;
|
|
664
|
+
const state = { correlations: [] as unknown[], transcripts: [] as unknown[], replay: undefined as unknown };
|
|
665
|
+
const base = mutableFocusStub(state);
|
|
666
|
+
const restore = installEnv((url: string) => {
|
|
667
|
+
if (/\/agent-instances\/[^/?]+\/history/.test(url)) {
|
|
668
|
+
historyCalls++;
|
|
669
|
+
if (historyCalls === 1) return Promise.resolve({ ok: false, status: 500, json: async () => ({}) });
|
|
670
|
+
}
|
|
671
|
+
return base(url);
|
|
672
|
+
});
|
|
673
|
+
(globalThis as { location: { hash: string } }).location.hash = `#/cockpit/process/${PI_ROOT}`;
|
|
674
|
+
try {
|
|
675
|
+
const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
|
|
676
|
+
const handle = mountCockpit(document.getElementById("root"), OPTS);
|
|
677
|
+
try {
|
|
678
|
+
await settle();
|
|
679
|
+
assertEquals(historyCalls, 1, "the focus auto-opened the newest run's history once");
|
|
680
|
+
assert(
|
|
681
|
+
(document.querySelector(".cockpit-agent-transcript")?.textContent ?? "").includes("working on the focused process") === false,
|
|
682
|
+
"the failed read rendered nothing",
|
|
683
|
+
);
|
|
684
|
+
// A later poll retries the failed read; the detail now renders.
|
|
685
|
+
await handle.refresh();
|
|
686
|
+
await settle();
|
|
687
|
+
assert(historyCalls >= 2, "the failed auto-open was retried on a later poll");
|
|
688
|
+
assert(
|
|
689
|
+
(document.querySelector(".cockpit-agent-transcript")?.textContent ?? "").includes("working on the focused process"),
|
|
690
|
+
"the retried read rendered the focused agent's history",
|
|
691
|
+
);
|
|
692
|
+
} finally {
|
|
693
|
+
handle.dispose();
|
|
694
|
+
}
|
|
695
|
+
} finally {
|
|
696
|
+
restore();
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
test("#834: 'Show all' clears the focus route from the URL so a refresh does not reopen the process", async () => {
|
|
701
|
+
const restore = installEnv(focusStub({ correlations: [] }));
|
|
702
|
+
(globalThis as { location: { hash: string } }).location.hash = `#/cockpit/process/${PI_ROOT}`;
|
|
703
|
+
try {
|
|
704
|
+
const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
|
|
705
|
+
const handle = mountCockpit(document.getElementById("root"), OPTS);
|
|
706
|
+
try {
|
|
707
|
+
await settle();
|
|
708
|
+
assert((document.querySelector(".cockpit-focus")?.textContent ?? "").includes(PI_ROOT), "the process route focused the cockpit");
|
|
709
|
+
// Clicking "Show all" must navigate the focus route out of the URL, not just hide the banner —
|
|
710
|
+
// otherwise refreshing/sharing `#/cockpit/process/<key>` reopens the process just cleared (#834).
|
|
711
|
+
const clear = document.querySelector(".cockpit-focus-clear");
|
|
712
|
+
assert(clear != null, "the focus banner offers a 'Show all' control");
|
|
713
|
+
const win = (globalThis as { window: { Event: new (t: string) => unknown; dispatchEvent: (e: unknown) => void } }).window;
|
|
714
|
+
(clear as { dispatchEvent: (e: unknown) => void }).dispatchEvent(new win.Event("click"));
|
|
715
|
+
assertEquals((globalThis as { location: { hash: string } }).location.hash, "#/cockpit", "'Show all' navigated the URL back to the cockpit root");
|
|
716
|
+
// The resulting hashchange then clears the focus, exactly as a real browser navigation would.
|
|
717
|
+
win.dispatchEvent(new win.Event("hashchange"));
|
|
718
|
+
await settle();
|
|
719
|
+
assertEquals(document.querySelector(".cockpit-focus")?.getAttribute("data-focus"), "none");
|
|
720
|
+
} finally {
|
|
721
|
+
handle.dispose();
|
|
722
|
+
}
|
|
723
|
+
} finally {
|
|
724
|
+
restore();
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
test("#834: switching the focused process clears the old focus's agent-history list immediately", async () => {
|
|
729
|
+
const state = { correlations: [] as unknown[], transcripts: [] as unknown[], replay: undefined as unknown };
|
|
730
|
+
const restore = installEnv(mutableFocusStub(state));
|
|
731
|
+
(globalThis as { location: { hash: string } }).location.hash = `#/cockpit/process/${PI_ROOT}`;
|
|
732
|
+
try {
|
|
733
|
+
const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
|
|
734
|
+
const handle = mountCockpit(document.getElementById("root"), OPTS);
|
|
735
|
+
try {
|
|
736
|
+
await settle();
|
|
737
|
+
assert(document.querySelector(".cockpit-agent-region .cockpit-agent-history") != null, "focus A rendered its agent-history list");
|
|
738
|
+
// The switch must blank the list SYNCHRONOUSLY, not leave A's rows up until B's reads land: a click
|
|
739
|
+
// on a stale A row would otherwise start a detail read under B's focus generation (#834).
|
|
740
|
+
handle.focus("pi-other");
|
|
741
|
+
assertEquals(document.querySelector(".cockpit-agent-region")?.childElementCount, 0, "the old focus's history list was cleared immediately on focus change");
|
|
742
|
+
} finally {
|
|
743
|
+
handle.dispose();
|
|
744
|
+
}
|
|
745
|
+
} finally {
|
|
746
|
+
restore();
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
test("#834: a superseded focus's in-flight transcript scan does not block the new focus's scan", async () => {
|
|
751
|
+
// Park focus A's per-tree transcript scan on a never-settling fetch, then switch to B. With a shared
|
|
752
|
+
// scan lock, B would skip its scan until A's request settled (up to the 15s timeout); the lock must be
|
|
753
|
+
// scoped to the focus generation so B scans immediately and replays its own capture (#834).
|
|
754
|
+
let releaseA: () => void = () => {};
|
|
755
|
+
const aParked = new Promise<void>((r) => {
|
|
756
|
+
releaseA = r;
|
|
757
|
+
});
|
|
758
|
+
const bReplay = {
|
|
759
|
+
stream: "wk-b/j9",
|
|
760
|
+
from: 0,
|
|
761
|
+
gap: false,
|
|
762
|
+
nextOffset: 1,
|
|
763
|
+
entries: [{ offset: 0, chunk: envChunk("message", { role: "assistant", text: "B capture replays" }) }],
|
|
764
|
+
};
|
|
765
|
+
const state = { correlations: [] as unknown[], transcripts: [] as unknown[], replay: bReplay, roots: [] as string[] };
|
|
766
|
+
const base = mutableFocusStub(state);
|
|
767
|
+
const restore = installEnv(async (url: string) => {
|
|
768
|
+
if (url.includes("/transcripts") && url.includes(`processInstanceKey=${PI_ROOT}`)) {
|
|
769
|
+
await aParked; // focus A's scan hangs here until released
|
|
770
|
+
return { ok: true, status: 200, json: async () => ({ count: 0, transcripts: [] }) };
|
|
771
|
+
}
|
|
772
|
+
return base(url);
|
|
773
|
+
});
|
|
774
|
+
(globalThis as { location: { hash: string } }).location.hash = `#/cockpit/process/${PI_ROOT}`;
|
|
775
|
+
try {
|
|
776
|
+
const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
|
|
777
|
+
const handle = mountCockpit(document.getElementById("root"), OPTS);
|
|
778
|
+
try {
|
|
779
|
+
await settle();
|
|
780
|
+
// A is focused; its transcript scan is parked awaiting `aParked` and holds the scan lock.
|
|
781
|
+
assertEquals(document.querySelector(".cockpit-terminal")?.getAttribute("data-terminal-mode"), "idle");
|
|
782
|
+
// Switch to B, which HAS a capture: its scan must run despite A's lock still being held.
|
|
783
|
+
state.transcripts = [
|
|
784
|
+
{ stream: "wk-b/j9", lifecycle: "ephemeral", status: "completed", createdAt: "2024-01-01T00:00:00Z", nextOffset: 1, byteLength: 10, chunkCount: 1, processInstanceKey: "pi-bee" },
|
|
785
|
+
];
|
|
786
|
+
handle.focus("pi-bee");
|
|
787
|
+
await settle();
|
|
788
|
+
assertEquals(document.querySelector(".cockpit-terminal")?.getAttribute("data-terminal-mode"), "replay");
|
|
789
|
+
assert(
|
|
790
|
+
(document.querySelector('[data-terminal="host"]')?.textContent ?? "").includes("B capture replays"),
|
|
791
|
+
"the new focus replayed its capture even though the superseded focus's scan is still in flight",
|
|
792
|
+
);
|
|
793
|
+
} finally {
|
|
794
|
+
releaseA();
|
|
795
|
+
handle.dispose();
|
|
796
|
+
}
|
|
797
|
+
} finally {
|
|
798
|
+
restore();
|
|
799
|
+
}
|
|
800
|
+
});
|
package/app/convergeGate.test.ts
CHANGED
|
@@ -1123,3 +1123,76 @@ test("the blocked-comments escalation routes through gw-escalated toward an answ
|
|
|
1123
1123
|
// The human sees the gate's own reason (the unresolved threads / unacknowledged advisories).
|
|
1124
1124
|
assertStringIncludes(task[0], "convergeBlockReason");
|
|
1125
1125
|
});
|
|
1126
|
+
|
|
1127
|
+
// ── Copilot ccr-overview-v2 review format (issue #835) ──────────────────────
|
|
1128
|
+
// The v2 overview has no "Suppressed comments" block; findings without an inline thread live only in
|
|
1129
|
+
// the "What changed" file table as `… (<severity>, N vote[s])` clauses. Paths carry zero-width spaces.
|
|
1130
|
+
const V2_REVIEW_BODY = [
|
|
1131
|
+
"<!-- ccr-overview-v2 -->",
|
|
1132
|
+
"",
|
|
1133
|
+
"## Copilot review overview",
|
|
1134
|
+
"",
|
|
1135
|
+
"<details open>",
|
|
1136
|
+
"<summary><strong>Open (1)</strong></summary>",
|
|
1137
|
+
"",
|
|
1138
|
+
"- [Replace HEAD with a valid bootstrap commit SHA](#discussion_r1) · New",
|
|
1139
|
+
"</details>",
|
|
1140
|
+
"",
|
|
1141
|
+
"<details>",
|
|
1142
|
+
"<summary><strong>What changed in this PR</strong></summary>",
|
|
1143
|
+
"",
|
|
1144
|
+
"| File | Summary and final review notes |",
|
|
1145
|
+
"|---|---|",
|
|
1146
|
+
"| `skills/\u200bcamunda-forms/\u200bportability.json` | Adds dependency requirements. |",
|
|
1147
|
+
"| `skills/\u200bcamunda-job-workers/\u200bportability.json` | Adds dependency requirements; Camunda version conflicts with the documented Node fallback (moderate, 1 vote), and Java/TypeScript runtime paths are missing (moderate, 1 vote). |",
|
|
1148
|
+
"| `release-please-config.json` | Configures automated releases; `bootstrap-sha` must be removed or replaced with a full base commit SHA (moderate, 3 votes). |",
|
|
1149
|
+
"</details>",
|
|
1150
|
+
].join("\n");
|
|
1151
|
+
|
|
1152
|
+
test("parseSuppressedAdvisories: ccr-overview-v2 table findings are gated advisories (#835)", () => {
|
|
1153
|
+
const advisories = parseSuppressedAdvisories(V2_REVIEW_BODY);
|
|
1154
|
+
assertEquals(
|
|
1155
|
+
advisories.map((a) => [a.path, a.text]),
|
|
1156
|
+
[
|
|
1157
|
+
[
|
|
1158
|
+
"skills/camunda-job-workers/portability.json",
|
|
1159
|
+
"Adds dependency requirements; Camunda version conflicts with the documented Node fallback",
|
|
1160
|
+
],
|
|
1161
|
+
["skills/camunda-job-workers/portability.json", "Java/TypeScript runtime paths are missing"],
|
|
1162
|
+
[
|
|
1163
|
+
"release-please-config.json",
|
|
1164
|
+
"Configures automated releases; `bootstrap-sha` must be removed or replaced with a full base commit SHA",
|
|
1165
|
+
],
|
|
1166
|
+
],
|
|
1167
|
+
);
|
|
1168
|
+
// Keyed on path + prose (vote count excluded, so a re-vote next round keeps the same key).
|
|
1169
|
+
assertEquals(
|
|
1170
|
+
advisories[1].key,
|
|
1171
|
+
advisoryStableKey("skills/camunda-job-workers/portability.json", "Java/TypeScript runtime paths are missing"),
|
|
1172
|
+
);
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
test("parseSuppressedAdvisories: a v2 table finding is acked by the canonical nano-ack marker and unblocks the gate (#835)", () => {
|
|
1176
|
+
const advisories = parseSuppressedAdvisories(V2_REVIEW_BODY);
|
|
1177
|
+
const threads: ReviewThread[] = advisories.map((a) => ({
|
|
1178
|
+
isResolved: true,
|
|
1179
|
+
path: a.path,
|
|
1180
|
+
bodies: [`Declined. nano-ack: ${a.path} :: ${a.text}`],
|
|
1181
|
+
}));
|
|
1182
|
+
const blocked = evaluateConvergeGate({ unresolvedThreadCount: 0, suppressedAdvisories: advisories, acknowledgedKeys: [] });
|
|
1183
|
+
assertEquals(blocked.convergeBlocked, true);
|
|
1184
|
+
const r = evaluateConvergeGate({
|
|
1185
|
+
unresolvedThreadCount: 0,
|
|
1186
|
+
suppressedAdvisories: advisories,
|
|
1187
|
+
acknowledgedKeys: parseAckedAdvisories(threads),
|
|
1188
|
+
});
|
|
1189
|
+
assertEquals(r.convergeBlocked, false);
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
test("parseSuppressedAdvisories: v2 table rows without findings yield nothing; non-v2 tables are ignored (#835)", () => {
|
|
1193
|
+
assertEquals(
|
|
1194
|
+
parseSuppressedAdvisories("<!-- ccr-overview-v2 -->\n| `a.ts` | Adds a thing. |"),
|
|
1195
|
+
[],
|
|
1196
|
+
);
|
|
1197
|
+
assertEquals(parseSuppressedAdvisories("| `a.ts` | Broken thing (moderate, 1 vote). |"), []);
|
|
1198
|
+
});
|