@nanobpm/nano-workforce 0.195.2 → 0.197.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/nano.app.json +2 -1
- package/package.json +2 -2
- 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/test/agent-link-column.test.ts +66 -0
- package/test/grid-layout-cards.test.ts +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.197.0](https://github.com/nanobpm/nano-workforce/compare/v0.196.0...v0.197.0) (2026-10-01)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **pages:** render every list as the card view ([#838](https://github.com/nanobpm/nano-workforce/issues/838)) ([47b9bad](https://github.com/nanobpm/nano-workforce/commit/47b9bad71fe999cd34e298a2c6f36ce796a402bb)), closes [#837](https://github.com/nanobpm/nano-workforce/issues/837)
|
|
6
|
+
|
|
7
|
+
## [0.196.0](https://github.com/nanobpm/nano-workforce/compare/v0.195.2...v0.196.0) (2026-09-30)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **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)
|
|
12
|
+
|
|
1
13
|
## [0.195.2](https://github.com/nanobpm/nano-workforce/compare/v0.195.1...v0.195.2) (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/nano.app.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.197.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",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
67
|
"@nanobpm/agentic": "^0.14.0",
|
|
68
|
-
"@nanobpm/urban": "^0.
|
|
68
|
+
"@nanobpm/urban": "^0.94.1",
|
|
69
69
|
"bpmn-auto-layout": "^2.0.0-alpha.2"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
@@ -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": [
|
package/pages/home.page.json
CHANGED
|
@@ -173,6 +173,17 @@
|
|
|
173
173
|
"keyField": "process_key"
|
|
174
174
|
}
|
|
175
175
|
},
|
|
176
|
+
{
|
|
177
|
+
"field": "process_key",
|
|
178
|
+
"header": "Agent",
|
|
179
|
+
"template": "Transcript",
|
|
180
|
+
"width": "7rem",
|
|
181
|
+
"link": {
|
|
182
|
+
"kind": "page",
|
|
183
|
+
"page": "cockpit",
|
|
184
|
+
"keyField": "process_key"
|
|
185
|
+
}
|
|
186
|
+
},
|
|
176
187
|
{
|
|
177
188
|
"field": "incident_message",
|
|
178
189
|
"header": "Incident",
|
package/pages/lineage.page.json
CHANGED
|
@@ -122,6 +122,7 @@
|
|
|
122
122
|
"truncate": true,
|
|
123
123
|
"link": { "kind": "processExplorer", "keyField": "process_key" }
|
|
124
124
|
},
|
|
125
|
+
{ "field": "process_key", "header": "Agent", "template": "Transcript", "width": "7rem", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
|
|
125
126
|
{ "field": "pr_count", "header": "PRs", "width": "5rem" },
|
|
126
127
|
{ "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
|
|
127
128
|
],
|
|
@@ -145,6 +146,7 @@
|
|
|
145
146
|
"columns": [
|
|
146
147
|
{ "field": "title", "template": "{{title}}", "header": "PR", "subtitleField": "pr_key", "truncate": true, "linkField": "url" },
|
|
147
148
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
149
|
+
{ "field": "process_key", "header": "Agent", "template": "Transcript", "width": "7rem", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
|
|
148
150
|
{ "field": "current_round", "header": "Round" },
|
|
149
151
|
{ "field": "outcome", "header": "Outcome", "truncate": true },
|
|
150
152
|
{ "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
|
package/pages/overview.page.json
CHANGED
|
@@ -112,6 +112,7 @@
|
|
|
112
112
|
"columns": [
|
|
113
113
|
{ "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "pr_key", "truncate": true, "width": "36%", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } },
|
|
114
114
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
115
|
+
{ "field": "process_key", "header": "Agent", "template": "Transcript", "width": "7rem", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
|
|
115
116
|
{ "field": "incident_message", "header": "Incident", "truncate": true, "badge": { "tone": "danger", "label": "1" } },
|
|
116
117
|
{ "field": "current_round", "template": "{{current_round}} · {{active_worker}}", "header": "Round · Agent" },
|
|
117
118
|
{ "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
|
|
@@ -149,6 +150,7 @@
|
|
|
149
150
|
"columns": [
|
|
150
151
|
{ "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "plan_key", "truncate": true, "width": "36%", "link": { "kind": "page", "page": "epic-detail", "keyField": "plan_key" } },
|
|
151
152
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
153
|
+
{ "field": "process_key", "header": "Agent", "template": "Transcript", "width": "7rem", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
|
|
152
154
|
{ "field": "delivery_label", "header": "Landing" },
|
|
153
155
|
{ "field": "wave_label", "header": "Wave" },
|
|
154
156
|
{ "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
|
|
@@ -186,6 +188,7 @@
|
|
|
186
188
|
"columns": [
|
|
187
189
|
{ "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "feature_key", "truncate": true, "width": "36%", "link": { "kind": "page", "page": "feature", "keyField": "feature_key" } },
|
|
188
190
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
191
|
+
{ "field": "process_key", "header": "Agent", "template": "Transcript", "width": "7rem", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
|
|
189
192
|
{ "field": "delivery_label", "header": "Delivery" },
|
|
190
193
|
{ "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
|
|
191
194
|
],
|
|
@@ -227,6 +230,7 @@
|
|
|
227
230
|
"columns": [
|
|
228
231
|
{ "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "run_key", "truncate": true, "width": "30%", "link": { "kind": "page", "page": "delivery-graph-detail", "keyField": "run_key" } },
|
|
229
232
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
233
|
+
{ "field": "process_key", "header": "Agent", "template": "Transcript", "width": "7rem", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
|
|
230
234
|
{
|
|
231
235
|
"field": "stage",
|
|
232
236
|
"header": "Pipeline",
|
package/pages/tasks.page.json
CHANGED
|
@@ -106,6 +106,7 @@
|
|
|
106
106
|
"header": "Process",
|
|
107
107
|
"link": { "kind": "processExplorer", "keyField": "process_key" }
|
|
108
108
|
},
|
|
109
|
+
{ "field": "process_key", "header": "Agent", "template": "Transcript", "width": "7rem", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
|
|
109
110
|
{ "field": "updated_at", "header": "Updated", "format": "datetime" }
|
|
110
111
|
],
|
|
111
112
|
"rowKey": "user_task_key",
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// #833 — every grid whose cell links a process instance to the explorer also offers an "Agent" cell
|
|
2
|
+
// linking the SAME process_key to the cockpit (which focuses that process: live terminal, else its
|
|
3
|
+
// latest transcript, plus the engine agent history). Guards the class: a new active-run grid can't
|
|
4
|
+
// ship with a process-explorer link but no way to reach its agent — regardless of which column
|
|
5
|
+
// carries the explorer link (Status, Phase, Stage, Process, …).
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
|
|
10
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
11
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// The `link` object of a column, if it has one (no `as`: narrowed through type guards).
|
|
15
|
+
function columnLink(column: unknown): Record<string, unknown> | undefined {
|
|
16
|
+
if (!isObject(column)) return undefined;
|
|
17
|
+
return isObject(column.link) ? column.link : undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The process-explorer link of a column, wherever it lives: a plain column carries it at `link`,
|
|
21
|
+
// but a `pipeline` column carries it on its `locus` (the cell that links the row's process instance —
|
|
22
|
+
// e.g. the Feature runs grid's Pipeline column, pages/feature.page.json). Guarding only `link` would
|
|
23
|
+
// let a pipeline-locus explorer link ship with no Agent column and this test still pass (#834).
|
|
24
|
+
function explorerLink(column: unknown): Record<string, unknown> | undefined {
|
|
25
|
+
const direct = columnLink(column);
|
|
26
|
+
if (direct?.kind === "processExplorer") return direct;
|
|
27
|
+
if (!isObject(column)) return undefined;
|
|
28
|
+
const locus = isObject(column.locus) ? column.locus : undefined;
|
|
29
|
+
const locusLink = locus && isObject(locus.link) ? locus.link : undefined;
|
|
30
|
+
return locusLink?.kind === "processExplorer" ? locusLink : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Collect every `columns` array anywhere in the page tree (top-level grids, tab grids, detail
|
|
34
|
+
// children, …) without asserting the parsed JSON's shape.
|
|
35
|
+
function grids(node: unknown, out: unknown[][]): void {
|
|
36
|
+
if (Array.isArray(node)) {
|
|
37
|
+
for (const n of node) grids(n, out);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (!isObject(node)) return;
|
|
41
|
+
if (Array.isArray(node.columns)) out.push(node.columns);
|
|
42
|
+
for (const v of Object.values(node)) grids(v, out);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test("#833: every process-explorer grid also links its process to the cockpit via an Agent column", () => {
|
|
46
|
+
let checked = 0;
|
|
47
|
+
for (const file of readdirSync("pages").filter((f) => f.endsWith(".page.json"))) {
|
|
48
|
+
const all: unknown[][] = [];
|
|
49
|
+
grids(JSON.parse(readFileSync(`pages/${file}`, "utf8")), all);
|
|
50
|
+
for (const cols of all) {
|
|
51
|
+
// A grid qualifies whenever ANY of its columns links to the process explorer — not just a
|
|
52
|
+
// Status/Phase header, and not just a plain `link`: a pipeline column's `locus.link` counts too
|
|
53
|
+
// — so a Stage/Process/Pipeline (or any future) explorer link is guarded too.
|
|
54
|
+
const explorer = cols.some((c) => explorerLink(c) !== undefined);
|
|
55
|
+
if (!explorer) continue;
|
|
56
|
+
checked++;
|
|
57
|
+
const agent = cols.find((c) => isObject(c) && c.header === "Agent");
|
|
58
|
+
assert.deepEqual(
|
|
59
|
+
columnLink(agent),
|
|
60
|
+
{ kind: "page", page: "cockpit", keyField: "process_key" },
|
|
61
|
+
`${file}: grid missing the Agent → cockpit link`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
assert.ok(checked > 0, "found no process-explorer grids to check");
|
|
66
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// #837 — every dataGrid renders as the urban card list at every width: the fixed-max-width shell
|
|
2
|
+
// clips wide tables on any monitor, while the card view shows every field.
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
|
|
7
|
+
test("#837: the pages surface opts into the app-wide card layout", () => {
|
|
8
|
+
const manifest = JSON.parse(readFileSync("nano.app.json", "utf8"));
|
|
9
|
+
assert.equal(manifest.surfaces?.pages?.gridLayout, "cards");
|
|
10
|
+
});
|