@nanobpm/nano-workforce 0.127.0 → 0.128.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/.github/workflows/release.yml +29 -6
  2. package/AGENTS.md +19 -0
  3. package/CHANGELOG.md +7 -0
  4. package/app/agentic/cockpit/cockpit-route.test.ts +21 -0
  5. package/app/agentic/cockpit/cockpit-route.ts +17 -0
  6. package/app/agentic/cockpit/index.ts +16 -0
  7. package/app/agentic/cockpit/supply-boot-past.test.ts +44 -0
  8. package/app/agentic/cockpit/supply-boot.test.ts +2 -2
  9. package/app/agentic/cockpit/supply-boot.ts +76 -10
  10. package/app/agentic/cockpit/supply-render.test.ts +13 -4
  11. package/app/agentic/cockpit/supply-render.ts +14 -2
  12. package/app/agentic/cockpit/transcript-render.ts +6 -2
  13. package/app/agentic/cockpit/transcript-view.ts +9 -0
  14. package/app/agentic/cockpit/worker-detail-render.test.ts +86 -0
  15. package/app/agentic/cockpit/worker-detail-render.ts +88 -0
  16. package/app/agentic/cockpit/worker-detail-view.ts +43 -0
  17. package/app/agentic/correlation-store.test.ts +99 -0
  18. package/app/agentic/correlation-store.ts +162 -0
  19. package/app/agentic/families/presence.family.test.ts +12 -0
  20. package/app/agentic/families/presence.family.ts +14 -0
  21. package/app/agentic/families/relay.family.test.ts +72 -0
  22. package/app/agentic/families/relay.family.ts +130 -1
  23. package/app/agentic/transcript-read.test.ts +55 -3
  24. package/app/agentic/transcript-read.ts +49 -9
  25. package/db/migrations/078_agentic_correlation.sql +32 -0
  26. package/openapi.yaml +26 -0
  27. package/operations/getAgenticTranscript.ts +3 -2
  28. package/operations/listAgenticTranscripts.ts +2 -1
  29. package/package.json +1 -1
  30. package/pages/cockpit/cockpit.css +65 -2
  31. package/pages/cockpit/mount.js +187 -16
@@ -74,22 +74,45 @@ jobs:
74
74
  if: needs.gate.outputs.should_release == 'true'
75
75
  runs-on: ubuntu-latest
76
76
  permissions:
77
- contents: write # create tags, GitHub Releases, push CHANGELOG/version bump
78
- issues: write # comment on released issues
79
- pull-requests: write # comment on released PRs
77
+ # The privileged work (push to `main`, tags, Releases, issue/PR comments) is
78
+ # done with the release App token below — it is the ruleset bypass actor. The
79
+ # job's own GITHUB_TOKEN only needs OIDC for npm Trusted Publishing.
80
+ contents: read
80
81
  id-token: write # OIDC token for npm Trusted Publishing (no NPM_TOKEN)
81
82
  steps:
83
+ # Mint a short-lived token for the dedicated release GitHub App. This App is
84
+ # the ruleset's bypass actor, so @semantic-release/git can push the version
85
+ # bump + tags straight to a protected `main` (the default GITHUB_TOKEN can't:
86
+ # it isn't — and at repo level can't be — a ruleset bypass actor). See
87
+ # AGENTS.md "Releases bypass the ruleset via a dedicated App" and the
88
+ # `main` ruleset's bypass_actors.
89
+ - name: Mint release App token
90
+ id: app-token
91
+ uses: actions/create-github-app-token@v1
92
+ with:
93
+ app-id: ${{ secrets.RELEASE_APP_ID }}
94
+ private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
95
+
82
96
  - name: Checkout
83
97
  uses: actions/checkout@v4
84
98
  with:
85
99
  ref: ${{ github.event.workflow_run.head_sha }} # release the exact gated commit
86
100
  fetch-depth: 0 # semantic-release needs full history + tags
87
101
  persist-credentials: true # let @semantic-release/git push the release commit
102
+ token: ${{ steps.app-token.outputs.token }} # push as the bypass-actor App
103
+
104
+ - name: Resolve App bot user id
105
+ id: app-user
106
+ env:
107
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
108
+ run: |
109
+ id="$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)"
110
+ echo "id=$id" >> "$GITHUB_OUTPUT"
88
111
 
89
112
  - name: Configure git author
90
113
  run: |
91
- git config user.name "github-actions[bot]"
92
- git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
114
+ git config user.name "${{ steps.app-token.outputs.app-slug }}[bot]"
115
+ git config user.email "${{ steps.app-user.outputs.id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com"
93
116
 
94
117
  - name: Setup Node.js
95
118
  uses: actions/setup-node@v4
@@ -107,7 +130,7 @@ jobs:
107
130
 
108
131
  - name: Release
109
132
  env:
110
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
133
+ GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} # bypass-actor App token
111
134
  # No NPM_TOKEN / NODE_AUTH_TOKEN: publishing uses OIDC Trusted Publishing.
112
135
  NPM_CONFIG_PROVENANCE: "true"
113
136
  run: npx semantic-release
package/AGENTS.md CHANGED
@@ -388,6 +388,25 @@ already reddened `main`. Grouping is `ALLGREEN` (SQUASH merge), so any red entry
388
388
  invalidates the whole batch. `Conventional PR title` passes through on
389
389
  `merge_group` (the title was validated when the PR entered the queue).
390
390
 
391
+ ### Releases bypass the ruleset via a dedicated App (do not break this)
392
+
393
+ The ruleset that protects `main` would also reject the **release** automation:
394
+ `semantic-release` (`@semantic-release/git`) pushes the `chore(release): X
395
+ [skip ci]` version/CHANGELOG commit and tags **directly** to `main`, which the
396
+ `pull_request` / `merge_queue` / `required_status_checks` rules forbid.
397
+
398
+ To allow exactly that push — and nothing else — the `main` ruleset lists a
399
+ **dedicated release GitHub App** as an `Integration` **bypass actor**
400
+ (`bypass_mode: always`). The `Release` workflow mints a short-lived token for
401
+ that App (`actions/create-github-app-token`) and hands it to `semantic-release`;
402
+ the job's own `GITHUB_TOKEN` is scoped to `contents: read` + `id-token: write`
403
+ (OIDC npm publish) and is **not** a bypass actor.
404
+
405
+ > ⚠️ **Coupling:** the default `GITHUB_TOKEN` cannot be a repo-level ruleset
406
+ > bypass actor, so the release depends on the App bypass actor staying in the
407
+ > ruleset. **Whenever you edit the `main` ruleset, keep the release App in
408
+ > `bypass_actors`** — dropping it wedges every release (GH013 on the push).
409
+
391
410
  ## Distributed fleet: NANO_WORKFORCE_BASE_URL
392
411
 
393
412
  The abandon and blackboard hooks are how a **distributed worker fleet** calls back
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.128.0](https://github.com/nanobpm/nano-workforce/compare/v0.127.0...v0.128.0) (2026-08-23)
2
+
3
+
4
+ ### Features
5
+
6
+ * **cockpit:** release worker correlation on job end + durable attribution + worker detail history page ([#488](https://github.com/nanobpm/nano-workforce/issues/488)) ([51c1a53](https://github.com/nanobpm/nano-workforce/commit/51c1a539535d6d65177aa4e2e46ff3ce390bec18)), closes [#232](https://github.com/nanobpm/nano-workforce/issues/232)
7
+
1
8
  # [0.127.0](https://github.com/nanobpm/nano-workforce/compare/v0.126.0...v0.127.0) (2026-08-23)
2
9
 
3
10
 
@@ -0,0 +1,21 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { parseCockpitRoute } from "./cockpit-route.ts";
5
+
6
+ test("parses empty and main cockpit hashes as the main route", () => {
7
+ assert.deepEqual(parseCockpitRoute(""), { kind: "main" });
8
+ assert.deepEqual(parseCockpitRoute("#/cockpit"), { kind: "main" });
9
+ assert.deepEqual(parseCockpitRoute("#/cockpit/"), { kind: "main" });
10
+ });
11
+
12
+ test("parses URL-decoded worker detail hashes", () => {
13
+ assert.deepEqual(parseCockpitRoute("#/cockpit/worker/wk-a"), { kind: "worker", instance: "wk-a" });
14
+ assert.deepEqual(parseCockpitRoute("#/cockpit/worker/leaf%2Fwk%201"), { kind: "worker", instance: "leaf/wk 1" });
15
+ });
16
+
17
+ test("empty worker and junk hashes fall back to the main route", () => {
18
+ assert.deepEqual(parseCockpitRoute("#/cockpit/worker/"), { kind: "main" });
19
+ assert.deepEqual(parseCockpitRoute("#/elsewhere"), { kind: "main" });
20
+ assert.deepEqual(parseCockpitRoute("#/cockpit/worker/%E0%A4%A"), { kind: "main" });
21
+ });
@@ -0,0 +1,17 @@
1
+ export type CockpitRoute = { readonly kind: "main" } | { readonly kind: "worker"; readonly instance: string };
2
+
3
+ /** Parse the cockpit hash route. Unknown or malformed hashes fall back to the main list. */
4
+ export function parseCockpitRoute(hash: string): CockpitRoute {
5
+ const route = hash.startsWith("#") ? hash.slice(1) : hash;
6
+ if (route === "" || route === "/cockpit" || route === "/cockpit/") return { kind: "main" };
7
+ const prefix = "/cockpit/worker/";
8
+ if (!route.startsWith(prefix)) return { kind: "main" };
9
+ const raw = route.slice(prefix.length);
10
+ if (raw === "") return { kind: "main" };
11
+ try {
12
+ const instance = decodeURIComponent(raw);
13
+ return instance === "" ? { kind: "main" } : { kind: "worker", instance };
14
+ } catch {
15
+ return { kind: "main" };
16
+ }
17
+ }
@@ -5,6 +5,10 @@
5
5
  //
6
6
  // The DEMAND×supply matrix, missing-agent-type reds, and diversity-SLO lights are OUT OF SCOPE for
7
7
  // this epic (#142) and deferred to the paired enrolment epic #152.
8
+ export {
9
+ type CockpitRoute,
10
+ parseCockpitRoute,
11
+ } from "./cockpit-route.ts";
8
12
  export {
9
13
  bootSupplyCockpit,
10
14
  type CreateTerminal,
@@ -51,3 +55,15 @@ export {
51
55
  type TranscriptView,
52
56
  transcriptsView,
53
57
  } from "./transcript-view.ts";
58
+ export {
59
+ type RenderWorkerDetailOptions,
60
+ renderWorkerDetail,
61
+ type WorkerDetailDom,
62
+ } from "./worker-detail-render.ts";
63
+ export {
64
+ type FoundWorkerDetailView,
65
+ type MissingWorkerDetailView,
66
+ type WorkerCurrentJobView,
67
+ type WorkerDetailView,
68
+ workerDetailView,
69
+ } from "./worker-detail-view.ts";
@@ -138,6 +138,50 @@ test("clicking a past-session button drives a replay", async () => {
138
138
  assert.deepEqual(r.terminalWrites, ["PA", "ST"]);
139
139
  });
140
140
 
141
+ test("opening a worker detail fetches that worker's transcript history and wires replay", async () => {
142
+ const r = rig();
143
+ const fetchedFor: Array<string | undefined> = [];
144
+ const env: SupplyCockpitEnv = {
145
+ ...r.env,
146
+ fetchTranscripts: (instance?: string) => {
147
+ fetchedFor.push(instance);
148
+ return Promise.resolve(transcripts);
149
+ },
150
+ };
151
+ const cockpit = bootSupplyCockpit(env);
152
+ await cockpit.refresh();
153
+ await flush();
154
+
155
+ cockpit.openWorker("wk-a");
156
+ await flush();
157
+ assert.equal(cockpit.currentRoute.kind, "worker");
158
+ assert.equal(cockpit.currentRoute.kind === "worker" ? cockpit.currentRoute.instance : undefined, "wk-a");
159
+ assert.equal(r.host.byData("worker-detail", "wk-a").length, 1, "worker detail header rendered");
160
+ assert.deepEqual(fetchedFor, [undefined, "wk-a"], "main history is global; detail history is instance-filtered");
161
+
162
+ const button = r.host.byClass("cockpit-past-replay").find((b) => b.getAttribute("data-stream") === "job:past");
163
+ button?.dispatch("click");
164
+ await flush();
165
+ assert.equal(cockpit.currentMode, "replay");
166
+ assert.deepEqual(r.terminalWrites, ["PA", "ST"]);
167
+ });
168
+
169
+ test("clicking a worker name opens the detail page without breaking current-job drill", async () => {
170
+ const r = rig();
171
+ const cockpit = bootSupplyCockpit(r.env);
172
+ await cockpit.refresh();
173
+ await flush();
174
+
175
+ r.host.byClass("cockpit-worker")[0]?.dispatch("click");
176
+ await flush();
177
+ assert.equal(cockpit.currentRoute.kind, "worker");
178
+ assert.equal(r.host.byData("worker-detail", "wk-a").length, 1);
179
+
180
+ r.host.byClass("cockpit-worker-current-job")[0]?.dispatch("click");
181
+ assert.equal(cockpit.currentMode, "live");
182
+ assert.equal(cockpit.currentStream, "job:live");
183
+ });
184
+
141
185
  test("replaying after a live drill tears the live stream down and switches to replay", async () => {
142
186
  const r = rig();
143
187
  const cockpit = bootSupplyCockpit(r.env);
@@ -126,11 +126,11 @@ test("drilling into a worker subscribes its relay stream on connect", async () =
126
126
  assert.deepEqual(subs[0]?.payload, { op: "subscribe", stream: "wk-a", from: 0, credit: 1024 });
127
127
  });
128
128
 
129
- test("clicking a rendered worker button drills its stream", async () => {
129
+ test("clicking a rendered worker drill button drills its stream", async () => {
130
130
  const r = rig();
131
131
  const cockpit = bootSupplyCockpit(r.env);
132
132
  await cockpit.refresh();
133
- const button = r.host.byClass("cockpit-worker").find((b) => b.getAttribute("data-stream") === "wk-a");
133
+ const button = r.host.byClass("cockpit-worker-drill").find((b) => b.getAttribute("data-stream") === "wk-a");
134
134
  button?.dispatch("click");
135
135
  assert.equal(cockpit.currentStream, "wk-a");
136
136
  assert.equal(r.sockets.length, 1);
@@ -28,12 +28,15 @@ import {
28
28
  TerminalSession,
29
29
  type TerminalSink,
30
30
  } from "@nanobpm/agentic/cockpit";
31
+ import type { CockpitRoute } from "./cockpit-route.ts";
31
32
  import { renderSupply } from "./supply-render.ts";
32
- import type { SupplyReport } from "./supply-view.ts";
33
+ import type { SupplyReport, SupplyView } from "./supply-view.ts";
33
34
  import { supplyView } from "./supply-view.ts";
34
35
  import { renderTranscripts, replayTranscript, type TranscriptDataReport } from "./transcript-render.ts";
35
36
  import type { TranscriptListReport } from "./transcript-view.ts";
36
37
  import { transcriptsView } from "./transcript-view.ts";
38
+ import { renderWorkerDetail } from "./worker-detail-render.ts";
39
+ import { workerDetailView } from "./worker-detail-view.ts";
37
40
 
38
41
  /** Mounts a terminal into `host` and returns the sink relay output is written to. */
39
42
  export type CreateTerminal = (host: ElementLike) => TerminalSink;
@@ -55,7 +58,7 @@ export interface SupplyCockpitEnv {
55
58
  * Fetches the captured-session list (`GET /agentic/transcripts`) for the "past sessions" history.
56
59
  * Optional: when omitted the past-sessions panel is not rendered (live-only cockpit).
57
60
  */
58
- readonly fetchTranscripts?: () => Promise<TranscriptListReport>;
61
+ readonly fetchTranscripts?: (instance?: string) => Promise<TranscriptListReport>;
59
62
  /**
60
63
  * Fetches a stored transcript's bytes (`GET /agentic/transcripts/{stream}`) for static replay.
61
64
  * Required for the "past sessions" replay to work; must be provided together with {@link fetchTranscripts}.
@@ -99,6 +102,12 @@ export interface SupplyCockpitHandle {
99
102
  drill(stream: string): void;
100
103
  /** Replay a captured past session's stored transcript statically into the terminal (no live worker). */
101
104
  replay(stream: string): Promise<void>;
105
+ /** Open a worker's dedicated detail page. */
106
+ openWorker(instance: string): void;
107
+ /** Return to the main worker list. */
108
+ back(): void;
109
+ /** The current cockpit route. */
110
+ readonly currentRoute: CockpitRoute;
102
111
  /** The stream currently drilled into or replayed, if any. */
103
112
  readonly currentStream: string | undefined;
104
113
  /** Whether the terminal is showing a LIVE stream or a REPLAYED transcript (undefined when idle). */
@@ -152,6 +161,9 @@ class SupplyCockpit implements SupplyCockpitHandle {
152
161
  // True while a #refreshPast() fetch is in flight, so the supply poll never stacks past-fetches and a
153
162
  // hung transcripts endpoint can't accumulate pending calls.
154
163
  #pastRefreshing = false;
164
+ #pastRefreshPending = false;
165
+ #route: CockpitRoute = { kind: "main" };
166
+ #view: SupplyView | undefined;
155
167
  // Bumped by every start()/stop() so an in-flight #tick() from a previous start cycle can't
156
168
  // reschedule after a stop→start race and leave two overlapping poll chains running.
157
169
  #generation = 0;
@@ -248,6 +260,10 @@ class SupplyCockpit implements SupplyCockpitHandle {
248
260
  return this.#mode;
249
261
  }
250
262
 
263
+ get currentRoute(): CockpitRoute {
264
+ return this.#route;
265
+ }
266
+
251
267
  /** Reflect the terminal region's playback mode on the panel (title + `data-terminal-mode`). */
252
268
  #setMode(mode: TerminalMode | undefined, stream: string | undefined): void {
253
269
  this.#mode = mode;
@@ -270,47 +286,75 @@ class SupplyCockpit implements SupplyCockpitHandle {
270
286
  }
271
287
  if (this.#disposed) return;
272
288
  try {
273
- renderSupply(this.#listRegion, this.#env.doc, supplyView(report, { staleAfterMs: this.#env.staleAfterMs }), {
274
- onDrill: (stream) => this.drill(stream),
275
- });
289
+ this.#view = supplyView(report, { staleAfterMs: this.#env.staleAfterMs });
290
+ this.#renderRoute();
276
291
  } catch (err) {
277
292
  this.#env.onError?.(err);
278
293
  }
279
294
  // Fire-and-forget: the "past sessions" refresh must never gate the supply poll's next tick. A
280
295
  // transcripts endpoint that hangs (not just rejects) would otherwise stall #refresh() forever and
281
296
  // wedge the live worker list. #refreshPast is single-flight, so a slow fetch can't pile up either.
282
- void this.#refreshPast();
297
+ void this.#refreshPast(this.#route.kind === "worker" ? this.#route.instance : undefined);
298
+ }
299
+
300
+ #renderRoute(): void {
301
+ const view = this.#view;
302
+ if (view === undefined) return;
303
+ if (this.#route.kind === "worker") {
304
+ renderWorkerDetail(this.#listRegion, this.#env.doc, workerDetailView(view, this.#route.instance), {
305
+ onBack: () => this.back(),
306
+ onDrill: (stream) => this.drill(stream),
307
+ });
308
+ return;
309
+ }
310
+ renderSupply(this.#listRegion, this.#env.doc, view, {
311
+ onDrill: (stream) => this.drill(stream),
312
+ onOpenWorker: (instance) => this.openWorker(instance),
313
+ });
283
314
  }
284
315
 
285
316
  /** Fetch + render the "past sessions" history list, when a transcript source is wired. Independent
286
317
  * of the supply fetch: a transcript-endpoint fault (or hang) never blocks the live worker list. */
287
- async #refreshPast(): Promise<void> {
318
+ async #refreshPast(instance?: string): Promise<void> {
288
319
  const fetchTranscripts = this.#env.fetchTranscripts;
289
320
  if (fetchTranscripts === undefined || this.#pastRegion === undefined) return;
290
321
  // Single-flight: while one past-fetch is outstanding (including a hung one), skip starting another
291
322
  // so the poll can't stack pending fetches against a slow/unresponsive transcripts endpoint.
292
- if (this.#pastRefreshing) return;
323
+ if (this.#pastRefreshing) {
324
+ this.#pastRefreshPending = true;
325
+ return;
326
+ }
293
327
  this.#pastRefreshing = true;
294
328
  try {
295
329
  let report: TranscriptListReport;
296
330
  try {
297
- report = await this.#bounded(fetchTranscripts, "transcripts");
331
+ report = await this.#bounded(() => fetchTranscripts(instance), "transcripts");
298
332
  } catch (err) {
299
333
  if (this.#disposed) return;
300
334
  this.#env.onError?.(err);
301
335
  return;
302
336
  }
303
337
  if (this.#disposed || this.#pastRegion === undefined) return;
338
+ const currentInstance = this.#route.kind === "worker" ? this.#route.instance : undefined;
339
+ if (currentInstance !== instance) {
340
+ this.#pastRefreshPending = true;
341
+ return;
342
+ }
304
343
  try {
305
344
  renderTranscripts(this.#pastRegion, this.#env.doc, transcriptsView(report), {
306
345
  onReplay: (stream) => void this.replay(stream),
307
346
  ...(this.#mode === "replay" && this.#shownStream !== undefined ? { activeStream: this.#shownStream } : {}),
347
+ ...(instance !== undefined ? { title: "Job history", emptyText: "No captured sessions for this worker yet." } : {}),
308
348
  });
309
349
  } catch (err) {
310
350
  this.#env.onError?.(err);
311
351
  }
312
352
  } finally {
313
353
  this.#pastRefreshing = false;
354
+ if (this.#pastRefreshPending && !this.#disposed) {
355
+ this.#pastRefreshPending = false;
356
+ void this.#refreshPast(this.#route.kind === "worker" ? this.#route.instance : undefined);
357
+ }
314
358
  }
315
359
  }
316
360
 
@@ -492,10 +536,32 @@ class SupplyCockpit implements SupplyCockpitHandle {
492
536
  replayTranscript(session, data);
493
537
  this.#setMode("replay", stream);
494
538
  // Re-render the past list so the just-selected session shows as active (best-effort).
495
- void this.#refreshPast();
539
+ void this.#refreshPast(this.#route.kind === "worker" ? this.#route.instance : undefined);
540
+ } catch (err) {
541
+ this.#env.onError?.(err);
542
+ }
543
+ }
544
+
545
+ openWorker(instance: string): void {
546
+ if (this.#disposed) return;
547
+ this.#route = { kind: "worker", instance };
548
+ try {
549
+ this.#renderRoute();
496
550
  } catch (err) {
497
551
  this.#env.onError?.(err);
498
552
  }
553
+ void this.#refreshPast(instance);
554
+ }
555
+
556
+ back(): void {
557
+ if (this.#disposed) return;
558
+ this.#route = { kind: "main" };
559
+ try {
560
+ this.#renderRoute();
561
+ } catch (err) {
562
+ this.#env.onError?.(err);
563
+ }
564
+ void this.#refreshPast();
499
565
  }
500
566
 
501
567
  dispose(): void {
@@ -44,13 +44,22 @@ test("renders one leaf section with a worker row per worker (family, host, jobs,
44
44
  assert.equal(rowB?.byClass("cockpit-supply-jobs")[0]?.text(), "—");
45
45
  });
46
46
 
47
- test("worker buttons carry the drill stream and fire onDrill on click", () => {
47
+ test("worker name buttons open the worker detail route and the inline terminal drill still drills", () => {
48
48
  const host = new FakeElement("body");
49
49
  const drilled: string[] = [];
50
- renderSupply(host, doc, supplyView(sample), { onDrill: (stream) => drilled.push(stream) });
50
+ const opened: string[] = [];
51
+ renderSupply(host, doc, supplyView(sample), {
52
+ onDrill: (stream) => drilled.push(stream),
53
+ onOpenWorker: (instance) => opened.push(instance),
54
+ });
51
55
 
52
- const button = host.byClass("cockpit-worker").find((b) => b.getAttribute("data-stream") === "wk-a");
53
- assert.ok(button, "the worker button was rendered with its stream id");
56
+ const worker = host.byClass("cockpit-worker").find((b) => b.getAttribute("data-instance") === "wk-a");
57
+ assert.ok(worker, "the worker name button was rendered with its instance id");
58
+ worker?.dispatch("click");
59
+ assert.deepEqual(opened, ["wk-a"]);
60
+
61
+ const button = host.byClass("cockpit-worker-drill").find((b) => b.getAttribute("data-stream") === "wk-a");
62
+ assert.ok(button, "the inline live-terminal drill button was rendered with its stream id");
54
63
  button?.dispatch("click");
55
64
  assert.deepEqual(drilled, ["wk-a"]);
56
65
  });
@@ -23,6 +23,8 @@ import type { Liveness, SupplyLeafView, SupplyView, SupplyWorkerView } from "./s
23
23
  export interface RenderSupplyOptions {
24
24
  /** Called with a worker's relay stream id when the operator drills into it. */
25
25
  readonly onDrill?: (stream: string) => void;
26
+ /** Called with a worker instance when the operator opens its dedicated detail page. */
27
+ readonly onOpenWorker?: (instance: string) => void;
26
28
  }
27
29
 
28
30
  /** Handles into the rendered tree the caller may need. */
@@ -54,12 +56,22 @@ function workerRow(doc: DocumentLike, worker: SupplyWorkerView, options: RenderS
54
56
  nameCell.appendChild(dot(doc, worker.liveness));
55
57
  const button = el(doc, "button", "cockpit-worker", worker.instance);
56
58
  button.setAttribute("type", "button");
59
+ button.setAttribute("data-instance", worker.instance);
60
+ const onOpenWorker = options.onOpenWorker;
61
+ if (onOpenWorker !== undefined) {
62
+ button.addEventListener("click", () => onOpenWorker(worker.instance));
63
+ }
64
+ nameCell.appendChild(button);
65
+ const drill = el(doc, "button", "cockpit-worker-drill", "terminal");
66
+ drill.setAttribute("type", "button");
67
+ drill.setAttribute("data-instance", worker.instance);
68
+ drill.setAttribute("data-stream", worker.stream);
57
69
  button.setAttribute("data-stream", worker.stream);
58
70
  const onDrill = options.onDrill;
59
71
  if (onDrill !== undefined) {
60
- button.addEventListener("click", () => onDrill(worker.stream));
72
+ drill.addEventListener("click", () => onDrill(worker.stream));
61
73
  }
62
- nameCell.appendChild(button);
74
+ nameCell.appendChild(drill);
63
75
  row.appendChild(nameCell);
64
76
 
65
77
  row.appendChild(el(doc, "td", "cockpit-td cockpit-supply-family", worker.family));
@@ -23,6 +23,10 @@ export interface RenderTranscriptsOptions {
23
23
  readonly onReplay?: (stream: string) => void;
24
24
  /** The stream currently being replayed, if any — highlighted in the list. */
25
25
  readonly activeStream?: string;
26
+ /** Panel title. Defaults to the global cockpit history label. */
27
+ readonly title?: string;
28
+ /** Empty-state copy. Defaults to the global cockpit history copy. */
29
+ readonly emptyText?: string;
26
30
  }
27
31
 
28
32
  /** Handles into the rendered tree the caller may need. */
@@ -74,14 +78,14 @@ export function renderTranscripts(
74
78
  root.setAttribute("data-session-count", String(view.count));
75
79
 
76
80
  const header = el(doc, "header", "cockpit-past-header");
77
- header.appendChild(el(doc, "h2", "cockpit-past-title", "Past sessions"));
81
+ header.appendChild(el(doc, "h2", "cockpit-past-title", options.title ?? "Past sessions"));
78
82
  const summary = el(doc, "span", "cockpit-past-summary", view.retention !== undefined ? `${view.count} · kept ${view.retention}` : `${view.count}`);
79
83
  summary.setAttribute("data-summary", "past");
80
84
  header.appendChild(summary);
81
85
  root.appendChild(header);
82
86
 
83
87
  if (view.count === 0) {
84
- const empty = el(doc, "div", "cockpit-past-empty", "No captured sessions yet.");
88
+ const empty = el(doc, "div", "cockpit-past-empty", options.emptyText ?? "No captured sessions yet.");
85
89
  empty.setAttribute("data-empty", "true");
86
90
  root.appendChild(empty);
87
91
  host.appendChild(root);
@@ -26,6 +26,9 @@ export interface TranscriptSummaryReport {
26
26
  readonly bpmnProcessId?: string;
27
27
  readonly elementId?: string;
28
28
  readonly planKey?: string;
29
+ readonly instance?: string;
30
+ readonly identity?: string;
31
+ readonly host?: string;
29
32
  }
30
33
 
31
34
  /** The transcript list report the cockpit polls (mirrors `AgenticTranscriptList`). */
@@ -54,6 +57,9 @@ export interface TranscriptView {
54
57
  readonly byteLength: number;
55
58
  /** When the session was captured — completedAt when sealed, else createdAt. */
56
59
  readonly capturedAt: string;
60
+ readonly instance?: string;
61
+ readonly identity?: string;
62
+ readonly host?: string;
57
63
  }
58
64
 
59
65
  /** The full renderable "past sessions" view. */
@@ -106,6 +112,9 @@ function sessionView(t: TranscriptSummaryReport): TranscriptView {
106
112
  size: humanBytes(t.byteLength),
107
113
  byteLength: t.byteLength,
108
114
  capturedAt: t.completedAt ?? t.createdAt,
115
+ ...(t.instance !== undefined ? { instance: t.instance } : {}),
116
+ ...(t.identity !== undefined ? { identity: t.identity } : {}),
117
+ ...(t.host !== undefined ? { host: t.host } : {}),
109
118
  };
110
119
  }
111
120
 
@@ -0,0 +1,86 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { FakeDocument, FakeElement } from "../../../test/agentic-cockpit-doubles.ts";
5
+ import { renderWorkerDetail } from "./worker-detail-render.ts";
6
+ import { supplyView, type SupplyReport } from "./supply-view.ts";
7
+ import { workerDetailView } from "./worker-detail-view.ts";
8
+
9
+ const doc = new FakeDocument();
10
+
11
+ const report: SupplyReport = {
12
+ count: 1,
13
+ workers: [
14
+ {
15
+ instance: "wk-a",
16
+ identity: "leaf-1",
17
+ stream: "job:6494",
18
+ family: "senior",
19
+ host: "h1",
20
+ jobKeys: ["6494"],
21
+ live: true,
22
+ staleMs: 0,
23
+ },
24
+ ],
25
+ leaves: [
26
+ {
27
+ token: "leaf-1",
28
+ workers: [
29
+ {
30
+ instance: "wk-a",
31
+ identity: "leaf-1",
32
+ stream: "job:6494",
33
+ family: "senior",
34
+ host: "h1",
35
+ jobKeys: ["6494"],
36
+ live: true,
37
+ staleMs: 0,
38
+ },
39
+ ],
40
+ },
41
+ ],
42
+ correlations: [
43
+ {
44
+ jobKey: "6494",
45
+ stream: "job:6494",
46
+ bpmnProcessId: "plan-fanout",
47
+ processInstanceKey: "4612",
48
+ planKey: "o/r#142",
49
+ },
50
+ ],
51
+ };
52
+
53
+ test("derives and renders the worker detail header and current job drill", () => {
54
+ const host = new FakeElement("body");
55
+ const drilled: string[] = [];
56
+ const backed: string[] = [];
57
+ const detail = workerDetailView(supplyView(report), "wk-a");
58
+ renderWorkerDetail(host, doc, detail, { onBack: () => backed.push("back"), onDrill: (stream) => drilled.push(stream) });
59
+
60
+ assert.equal(host.byData("worker-detail", "wk-a").length, 1);
61
+ assert.equal(host.byClass("cockpit-worker-detail-identity")[0]?.text(), "leaf-1");
62
+ assert.equal(host.byClass("cockpit-worker-detail-family")[0]?.text(), "senior");
63
+ assert.equal(host.byClass("cockpit-worker-detail-host")[0]?.text(), "h1");
64
+ assert.equal(host.byData("liveness", "live").length >= 1, true);
65
+
66
+ const job = host.byClass("cockpit-worker-current-job")[0];
67
+ assert.equal(job?.getAttribute("data-job-key"), "6494");
68
+ assert.equal(job?.getAttribute("data-stream"), "job:6494");
69
+ assert.equal(job?.text(), "plan-fanout · inst 4612 · o/r#142");
70
+ job?.dispatch("click");
71
+ assert.deepEqual(drilled, ["job:6494"]);
72
+
73
+ host.byClass("cockpit-worker-detail-back")[0]?.dispatch("click");
74
+ assert.deepEqual(backed, ["back"]);
75
+ });
76
+
77
+ test("renders a missing-worker detail state with a back action", () => {
78
+ const host = new FakeElement("body");
79
+ const backed: string[] = [];
80
+ renderWorkerDetail(host, doc, workerDetailView(supplyView(report), "missing"), { onBack: () => backed.push("back") });
81
+
82
+ assert.equal(host.byData("worker-missing", "missing").length, 1);
83
+ assert.equal(host.byClass("cockpit-worker-detail-empty")[0]?.text(), "Worker missing is not in the current supply report.");
84
+ host.byClass("cockpit-worker-detail-back")[0]?.dispatch("click");
85
+ assert.deepEqual(backed, ["back"]);
86
+ });