@nanobpm/nano-workforce 0.126.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 (54) hide show
  1. package/.github/workflows/invariants.yml +8 -0
  2. package/.github/workflows/pr-title-lint.yml +9 -1
  3. package/.github/workflows/release.yml +37 -8
  4. package/AGENTS.md +37 -0
  5. package/CHANGELOG.md +14 -0
  6. package/app/agentCompletion.ts +11 -0
  7. package/app/agentic/cockpit/cockpit-route.test.ts +21 -0
  8. package/app/agentic/cockpit/cockpit-route.ts +17 -0
  9. package/app/agentic/cockpit/index.ts +16 -0
  10. package/app/agentic/cockpit/supply-boot-past.test.ts +44 -0
  11. package/app/agentic/cockpit/supply-boot.test.ts +2 -2
  12. package/app/agentic/cockpit/supply-boot.ts +76 -10
  13. package/app/agentic/cockpit/supply-render.test.ts +13 -4
  14. package/app/agentic/cockpit/supply-render.ts +14 -2
  15. package/app/agentic/cockpit/transcript-render.ts +6 -2
  16. package/app/agentic/cockpit/transcript-view.ts +9 -0
  17. package/app/agentic/cockpit/worker-detail-render.test.ts +86 -0
  18. package/app/agentic/cockpit/worker-detail-render.ts +88 -0
  19. package/app/agentic/cockpit/worker-detail-view.ts +43 -0
  20. package/app/agentic/correlation-store.test.ts +99 -0
  21. package/app/agentic/correlation-store.ts +162 -0
  22. package/app/agentic/families/presence.family.test.ts +12 -0
  23. package/app/agentic/families/presence.family.ts +14 -0
  24. package/app/agentic/families/relay.family.test.ts +72 -0
  25. package/app/agentic/families/relay.family.ts +130 -1
  26. package/app/agentic/transcript-read.test.ts +55 -3
  27. package/app/agentic/transcript-read.ts +49 -9
  28. package/app/agentic/vocab/demand-report.test.ts +23 -10
  29. package/app/pollUserTasks.test.ts +66 -2
  30. package/app/service.ts +21 -8
  31. package/app/tasksPage.test.ts +44 -0
  32. package/app/userTasks.test.ts +19 -0
  33. package/app/userTasks.ts +13 -1
  34. package/db/migrations/077_user_tasks_form_key.sql +25 -0
  35. package/db/migrations/078_agentic_correlation.sql +32 -0
  36. package/docs/adr/0006-delivery-units-one-representation.md +59 -20
  37. package/e2e/delivery-graph.e2e.ts +7 -0
  38. package/e2e/feature-preflight.e2e.ts +7 -0
  39. package/e2e/inter-epic-dependency.e2e.ts +8 -0
  40. package/e2e/plan-fanout-preflight.e2e.ts +7 -0
  41. package/e2e/plan-fanout-sla.e2e.ts +4 -3
  42. package/e2e/plan-fanout.e2e.ts +2 -2
  43. package/e2e/readiness-gate.e2e.ts +8 -37
  44. package/e2e/support/probe-exec.test.ts +78 -0
  45. package/e2e/support/probe-exec.ts +69 -0
  46. package/e2e/support/time.test.ts +42 -0
  47. package/e2e/support/time.ts +33 -0
  48. package/openapi.yaml +26 -0
  49. package/operations/getAgenticTranscript.ts +3 -2
  50. package/operations/listAgenticTranscripts.ts +2 -1
  51. package/package.json +3 -3
  52. package/pages/cockpit/cockpit.css +65 -2
  53. package/pages/cockpit/mount.js +187 -16
  54. package/pages/tasks.page.json +24 -554
@@ -11,11 +11,19 @@ name: Whole-repo invariants (merge-skew guard)
11
11
  #
12
12
  # This lean workflow re-asserts those whole-repo invariants where the merge actually happens:
13
13
  # - `merge_group` — the queue's PROSPECTIVE merged commit, so a skew is blocked BEFORE it lands.
14
+ # THIS is the merge-skew guard: `derived == f(sources_A ∪ sources_B)`.
15
+ # - `pull_request` — required so the SAME check (matched by job name) also reports on the PR head.
16
+ # GitHub's merge queue will not enqueue a PR until every required status check
17
+ # has reported on it, and a check that fires only on `merge_group` never does —
18
+ # it stays "expected" and the PR can never enter the queue (deadlock). The PR-head
19
+ # run also validates the PR's OWN tree (a self-collision / stale artifact the PR
20
+ # introduces alone), catching it before the queue rather than at merge time.
14
21
  # - `push: [main]` — a fast backstop that fails a `main`-scoped build within minutes if something
15
22
  # slipped through, instead of first surfacing on an unrelated open PR.
16
23
  # - `schedule` — a daily catch-all for any skew introduced by a merge that bypassed the queue.
17
24
  on:
18
25
  merge_group:
26
+ pull_request:
19
27
  push:
20
28
  branches: [main]
21
29
  schedule:
@@ -7,6 +7,13 @@ name: PR title lint
7
7
  on:
8
8
  pull_request:
9
9
  types: [opened, edited, synchronize, reopened]
10
+ # Required status checks in the `main` merge queue must ALSO report on the
11
+ # queue's speculative `merge_group` commit, or the queue stalls forever waiting
12
+ # for a check that only ran on the PR. A `merge_group` commit has no PR title to
13
+ # lint (the title was already validated when the PR entered the queue), so the
14
+ # job runs with its only step skipped and reports success — the green the queue
15
+ # needs, without re-linting.
16
+ merge_group:
10
17
 
11
18
  permissions:
12
19
  pull-requests: read
@@ -16,7 +23,8 @@ jobs:
16
23
  name: Conventional PR title
17
24
  runs-on: ubuntu-latest
18
25
  steps:
19
- - uses: amannn/action-semantic-pull-request@v5
26
+ - if: github.event_name == 'pull_request'
27
+ uses: amannn/action-semantic-pull-request@v5
20
28
  env:
21
29
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
22
30
  with:
@@ -43,8 +43,14 @@ jobs:
43
43
  REPO: ${{ github.repository }}
44
44
  run: |
45
45
  set -euo pipefail
46
- # These must ALL be `success` on the release commit before we ship. Keep
47
- # in lock-step with the `main` ruleset's required status checks.
46
+ # These must ALL be `success` on the release commit before we ship.
47
+ # This is the `main` ruleset's required-check set MINUS the PR-only
48
+ # checks: `Conventional PR title` gates the merge queue but never runs
49
+ # on `push:main` (it has no PR title to lint on a merge commit — see
50
+ # pr-title-lint.yml), so requiring it here would deadlock every release.
51
+ # With the merge queue enabled these checks already passed on the
52
+ # speculative merged commit before it landed; this `push:main` gate is
53
+ # the backstop. Keep in lock-step with the ruleset's required checks.
48
54
  required=("typecheck + test (Node)" "whole-repo invariants")
49
55
  # Dedupe to ONE row per check name — the latest run (max .id) — so re-runs
50
56
  # can't leave us reading a stale conclusion. Slurp every page into a
@@ -68,22 +74,45 @@ jobs:
68
74
  if: needs.gate.outputs.should_release == 'true'
69
75
  runs-on: ubuntu-latest
70
76
  permissions:
71
- contents: write # create tags, GitHub Releases, push CHANGELOG/version bump
72
- issues: write # comment on released issues
73
- 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
74
81
  id-token: write # OIDC token for npm Trusted Publishing (no NPM_TOKEN)
75
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
+
76
96
  - name: Checkout
77
97
  uses: actions/checkout@v4
78
98
  with:
79
99
  ref: ${{ github.event.workflow_run.head_sha }} # release the exact gated commit
80
100
  fetch-depth: 0 # semantic-release needs full history + tags
81
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"
82
111
 
83
112
  - name: Configure git author
84
113
  run: |
85
- git config user.name "github-actions[bot]"
86
- 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"
87
116
 
88
117
  - name: Setup Node.js
89
118
  uses: actions/setup-node@v4
@@ -101,7 +130,7 @@ jobs:
101
130
 
102
131
  - name: Release
103
132
  env:
104
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
133
+ GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} # bypass-actor App token
105
134
  # No NPM_TOKEN / NODE_AUTH_TOKEN: publishing uses OIDC Trusted Publishing.
106
135
  NPM_CONFIG_PROVENANCE: "true"
107
136
  run: npx semantic-release
package/AGENTS.md CHANGED
@@ -370,6 +370,43 @@ agents:
370
370
  - **Never `git push --force` on `main`;** use `--force-with-lease` on feature
371
371
  branches.
372
372
 
373
+ ### Merging to `main` (merge queue)
374
+
375
+ `main` is protected by an **active ruleset** with a **GitHub merge queue**. You
376
+ do not merge a PR directly — you **add it to the queue** (UI "Merge when ready",
377
+ or the product merge loop's queued-merge path). The queue then builds the
378
+ **prospective merged commit** (`merge_group`) and only lands it if the required
379
+ checks pass **on that speculative tree**:
380
+
381
+ - `typecheck + test (Node)`, `whole-repo invariants`, `Conventional PR title`.
382
+
383
+ `whole-repo invariants` (the merge-skew guard, #366) runs only on the
384
+ `merge_group` commit and on `push:main`, never on a plain PR — so a
385
+ green-in-isolation PR that would collide once merged (a duplicate migration
386
+ prefix, a layout/artifact drift) is **blocked at merge time**, not after it has
387
+ already reddened `main`. Grouping is `ALLGREEN` (SQUASH merge), so any red entry
388
+ invalidates the whole batch. `Conventional PR title` passes through on
389
+ `merge_group` (the title was validated when the PR entered the queue).
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
+
373
410
  ## Distributed fleet: NANO_WORKFORCE_BASE_URL
374
411
 
375
412
  The abandon and blackboard hooks are how a **distributed worker fleet** calls back
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
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
+
8
+ # [0.127.0](https://github.com/nanobpm/nano-workforce/compare/v0.126.0...v0.127.0) (2026-08-23)
9
+
10
+
11
+ ### Features
12
+
13
+ * **tasks:** collapse Tasks page to one user_tasks list, complete via engine forms ([#461](https://github.com/nanobpm/nano-workforce/issues/461)) ([#484](https://github.com/nanobpm/nano-workforce/issues/484)) ([bbc0745](https://github.com/nanobpm/nano-workforce/commit/bbc0745ddc7b5f79cfee5096dfbd07cf29ea0f38)), closes [nano-ide#457](https://github.com/nano-ide/issues/457) [#445](https://github.com/nanobpm/nano-workforce/issues/445) [#458](https://github.com/nanobpm/nano-workforce/issues/458)
14
+
1
15
  # [0.126.0](https://github.com/nanobpm/nano-workforce/compare/v0.125.0...v0.126.0) (2026-08-23)
2
16
 
3
17
 
@@ -122,6 +122,17 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
122
122
  // (returns `null`) and the completer accepts the captured form variables for the emit binder to type.
123
123
  };
124
124
 
125
+ /** The fixed `.form` linkage (a `zeebe:formDefinition formId`) that governs a fixed-form escalation
126
+ * kind's completion, or `undefined` for a kind with no static form (the delivery-graph `human` node,
127
+ * which renders DIFFERENT forms per node — its `formKey` only ever comes from the engine at runtime).
128
+ * Exposed so the Tasks-inbox poller can denormalise a row's `form_key` from this SAME single source of
129
+ * truth when the raw `/v2/user-tasks/search` result omits the engine-resolved key (issue #461) — the
130
+ * REST gateway addresses a deployed form by value whether that value is a deploy key or an authored
131
+ * form id, so this id resolves the same deployed `.form` the completer validates against. */
132
+ export function escalationFormId(elementId: string): string | undefined {
133
+ return ESCALATION_FORM_BY_ELEMENT[elementId];
134
+ }
135
+
125
136
  /** A field's `conditional.hide` rule, parsed from the FEEL subset the `.form` files use
126
137
  * (`=<ref> != "<value>"` / `=<ref> == "<value>"`). A required field is only enforced when it is
127
138
  * actually shown, so an "answer only when resolution=answer" field is not demanded on the abandon
@@ -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