@nanobpm/nano-workforce 0.55.0 → 0.56.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.
@@ -0,0 +1,39 @@
1
+ // nano-workforce — the jobKey ⇄ process/plan correlation family (ADR 0056, H6 / #149).
2
+ //
3
+ // The closing slice's family module. Like every sibling it plugs into the H0 (#143) seam
4
+ // (`../registry.ts`) as ONE NEW FILE and never edits `main.ts`, `drainAndExit`, or any shared boot
5
+ // line — the auto-discovery loader (`../loader.ts`) finds it by the `*.family.ts` suffix and the seam
6
+ // mounts + tears it down.
7
+ //
8
+ // Unlike presence/relay it owns NO channel message family: correlation is an app-side observation
9
+ // (jobKey ⇄ process-instance / plan), fed by the orchestrator that dispatches agentic jobs, not a new
10
+ // wire conversation (the Camunda-8 job protocol is untouched — ADR 0056). So `mount` simply installs a
11
+ // fresh {@link CorrelationRegistry} as the process-wide singleton the supply report (H5) reads, and
12
+ // `teardown` clears it. The registry is the single canonical join the cockpit uses to line a worker's
13
+ // terminal up with "that process instance / this plan".
14
+
15
+ import { CorrelationRegistry, setCurrentCorrelation } from "../correlation.ts";
16
+ import type { AgenticContext, AgenticFamily } from "../registry.ts";
17
+
18
+ /** The stable family name this module registers under the seam. */
19
+ export const CORRELATION_FAMILY = "correlation";
20
+
21
+ let registry: CorrelationRegistry | undefined;
22
+
23
+ /** The H6 correlation family: install the correlation registry singleton on mount, clear on teardown. */
24
+ export const family: AgenticFamily = {
25
+ name: CORRELATION_FAMILY,
26
+
27
+ mount(ctx: AgenticContext): void {
28
+ registry = new CorrelationRegistry();
29
+ setCurrentCorrelation(registry);
30
+ ctx.log.info("agentic correlation mounted", { family: CORRELATION_FAMILY });
31
+ },
32
+
33
+ teardown(): void {
34
+ registry = undefined;
35
+ setCurrentCorrelation(undefined);
36
+ },
37
+ };
38
+
39
+ export default family;
@@ -0,0 +1,135 @@
1
+ # The agentic visibility cockpit — operator guide
2
+
3
+ > **Scope: SUPPLY side only.** This guide covers the *supply* half of the agentic
4
+ > visibility plane (ADR 0056) — the live worker registry and the drill-into-a-worker
5
+ > terminal. The **demand** side — the demand×supply matrix by network,
6
+ > missing-agent-type reds, and diversity-SLO lights — is a separate concern,
7
+ > deferred to the **enrolment epic #152**. Nothing here shows demand.
8
+
9
+ ## What the cockpit shows you
10
+
11
+ The cockpit is a read-only, advisory window onto the fleet of agentic workers
12
+ connected to this app. It answers two operator questions:
13
+
14
+ 1. **Who is here?** — every connected worker, grouped by the leaf token it
15
+ authenticated under, with its declared **family** and **host**, its current
16
+ **jobs**, the **process instance / plan** each job belongs to, and a
17
+ **liveness** dot (live / stale / down).
18
+ 2. **What is that worker doing right now?** — click a worker (or a specific
19
+ process/plan) to open its **live terminal**, streamed off the relay.
20
+
21
+ It is **advisory**: it never gates, locks, or influences any BPMN sequence flow.
22
+ Turning the cockpit off changes nothing about how work runs — it only changes
23
+ what you can *see*.
24
+
25
+ ## The architecture in one breath
26
+
27
+ The cockpit rides the **agentic channel** — one WebSocket the app serves on its
28
+ *own* port at `/agentic`, alongside its pages and hooks (no sidecar port). Four
29
+ cooperating families sit on that channel, each mounted through a single
30
+ extension **seam** (`app/agentic/registry.ts`) so no family ever touches the boot
31
+ script:
32
+
33
+ | Family | Module | What it owns |
34
+ | --- | --- | --- |
35
+ | **presence** (H1) | `app/agentic/families/presence.family.ts` | The live worker registry over the app's SQLite store — REGISTER / heartbeat / disconnect. |
36
+ | **relay** (H3) | `app/agentic/families/relay.family.ts` | The bounded replay ring + three-lane QoS scheduler + transcript store — the terminal stream. |
37
+ | **blackboard** (H4) | `app/agentic/families/blackboard.family.ts` | The advisory coordination blackboard. |
38
+ | **correlation** (H6) | `app/agentic/families/correlation.family.ts` | The jobKey ⇄ process-instance / plan join. |
39
+
40
+ The supply report the cockpit polls is served by
41
+ `GET /app/api/agentic/supply` (`operations/getAgenticSupply.ts`), which projects
42
+ the presence snapshot — enriched with correlation — into the view.
43
+
44
+ ## Reading a worker row
45
+
46
+ Each row in a leaf-token section is one connected worker:
47
+
48
+ - **worker** — the worker instance id. Click it to drill into its terminal on its
49
+ default stream.
50
+ - **family** — the declared agent family (e.g. `senior`, `junior`), or `—`.
51
+ - **host** — where the worker runs, or `—`.
52
+ - **jobs** — the jobKeys the worker is currently processing. Empty (`—`) when the
53
+ worker is idle *or* when nothing has correlated a job to it yet.
54
+ - **process / plan** — the engine context for each current job: the BPMN process,
55
+ element, process-instance key, and plan/epic key, rendered as
56
+ `plan-fanout · implement-task · inst 4612 · owner/repo#142`. **Click it to open
57
+ that job's live terminal** (`job:<jobKey>`), not just the worker's default
58
+ stream.
59
+ - **liveness** — `live` (heartbeating), `stale` (no refresh past the threshold,
60
+ default 15 s), or `down` (disconnected). Rendered as a coloured dot.
61
+
62
+ ### How jobs and process/plan get populated — the correlation seam (H6)
63
+
64
+ A worker's channel frames don't carry job attribution — the relay only knows a
65
+ *stream id*. So correlation is an explicit, advisory **registry**
66
+ (`app/agentic/correlation.ts`) that the orchestrator populates when it dispatches
67
+ an agentic job:
68
+
69
+ ```ts
70
+ import { currentCorrelation } from "./app/agentic/correlation.ts";
71
+
72
+ // When a worker instance picks up a Camunda-8 job:
73
+ currentCorrelation()?.link("wk-a", jobKey, {
74
+ processInstanceKey,
75
+ bpmnProcessId,
76
+ elementId,
77
+ planKey, // e.g. owner/repo#142
78
+ });
79
+
80
+ // When the job finishes (or the worker disconnects):
81
+ currentCorrelation()?.releaseJob(jobKey); // one job
82
+ currentCorrelation()?.releaseInstance("wk-a"); // every job the worker held
83
+ ```
84
+
85
+ One `link` write is the single canonical join — it projects to **both**
86
+ directions the cockpit needs:
87
+
88
+ - `instance → jobKeys` feeds the presence snapshot's `jobKeysFor` seam, so a
89
+ worker's **jobs** column lights up;
90
+ - `jobKey → context` (with the derived `job:<jobKey>` **stream**) drives the
91
+ **process / plan** cell and the drill-in.
92
+
93
+ A jobKey belongs to at most one worker at a time — re-linking it moves it. The
94
+ relay stream a job's terminal rides is always `job:<jobKey>` (see
95
+ `jobStream` / `jobKeyOfStream` in `app/agentic/correlation.ts`); repointing the
96
+ drill stream there is what lets you open the *live job's* terminal from the
97
+ process/plan cell.
98
+
99
+ If the correlation family is not mounted (or nothing has linked a job), the
100
+ report still serves — jobs stay empty and every worker drills into its default
101
+ instance stream. Correlation is **additive and advisory**; its absence never
102
+ errors.
103
+
104
+ ## Drilling into a worker — resume-from-offset
105
+
106
+ Clicking a worker (or a process/plan) opens a `TerminalSession`
107
+ (`@nanobpm/agentic/cockpit`) subscribed to the relay stream. The session is
108
+ **resume-from-offset**: it tracks the offset just past the last chunk it applied,
109
+ and on every (re)connect it re-subscribes from there. This means:
110
+
111
+ - A **cockpit reconnect** replays only the un-applied tail — no lost output, no
112
+ double-printed lines (within the ring's retained window).
113
+ - A **hub restart** (the ring is in memory and is lost; the app's SQLite store is
114
+ durable) is survived the same way: the worker reconnects and replays its
115
+ transcript on a bumped incarnation, and your terminal resumes from its own
116
+ offset — receiving only what it hadn't already seen. Incarnation fencing stops
117
+ a stale producer from double-attaching. This exact path is pinned by the
118
+ end-to-end wiring test (`test/agentic-e2e.test.ts`).
119
+
120
+ ## Liveness and cleanup
121
+
122
+ Presence rows are kept live by worker heartbeats and removed on disconnect or
123
+ when a worker ages out past the liveness TTL. On (re)mount the presence family
124
+ reconciles the store against live connections, so a worker that vanished while
125
+ the app was down does not linger as a ghost row after a restart.
126
+
127
+ ## What you will NOT find here (and where it lives)
128
+
129
+ - **Demand×supply matrix, missing-agent-type reds, diversity-SLO lights** →
130
+ enrolment epic **#152**. They depend on the vocab / capability→SERVE /
131
+ diversity-SLO machinery this epic deliberately de-scopes. This report carries
132
+ no demand-side fields and the renderer draws none.
133
+ - **Engine / job-protocol changes** → none. The visibility plane is app-tier
134
+ only; the Camunda-8 worker⇄engine job protocol is untouched. The agentic
135
+ channel is the only new conversation.
package/openapi.yaml CHANGED
@@ -127,7 +127,7 @@ components:
127
127
  description: Declared host (where the worker runs), if any.
128
128
  jobKeys:
129
129
  type: array
130
- description: The jobKeys this worker is currently processing (empty until the H6 correlation seam lands).
130
+ description: The jobKeys this worker is currently processing (populated by the H6 correlation registry).
131
131
  items:
132
132
  type: string
133
133
  live:
@@ -149,6 +149,32 @@ components:
149
149
  type: array
150
150
  items:
151
151
  $ref: "#/components/schemas/AgenticSupplyWorker"
152
+ AgenticJobCorrelation:
153
+ type: object
154
+ description: One current job's engine context (H6) — lines a worker's terminal up with its process
155
+ instance / plan. The relay stream a job's terminal is on is the jobKey-scoped stream `job:<jobKey>`.
156
+ required:
157
+ - jobKey
158
+ - stream
159
+ properties:
160
+ jobKey:
161
+ type: string
162
+ description: The Camunda-8 job key the worker activated.
163
+ stream:
164
+ type: string
165
+ description: The relay stream id the job's terminal is relayed on (`job:<jobKey>`).
166
+ processInstanceKey:
167
+ type: string
168
+ description: The owning process instance key, if known.
169
+ bpmnProcessId:
170
+ type: string
171
+ description: The BPMN process id the job belongs to, if known.
172
+ elementId:
173
+ type: string
174
+ description: The BPMN element id (activity/task) the job is for, if known.
175
+ planKey:
176
+ type: string
177
+ description: The plan / epic key this job is part of (e.g. owner/repo#142), if known.
152
178
  AgenticSupplyReport:
153
179
  type: object
154
180
  description: The SUPPLY-ONLY visibility report — the live worker list grouped by leaf. No demand-side
@@ -172,6 +198,12 @@ components:
172
198
  type: array
173
199
  items:
174
200
  $ref: "#/components/schemas/AgenticSupplyLeaf"
201
+ correlations:
202
+ type: array
203
+ description: The engine context (process instance / plan) for every jobKey currently being
204
+ processed, so the cockpit can line each worker's terminal up with its process instance / plan (H6).
205
+ items:
206
+ $ref: "#/components/schemas/AgenticJobCorrelation"
175
207
  VersionInfo:
176
208
  type: object
177
209
  description: The running app's identity (which code is actually live).
@@ -12,6 +12,8 @@ import { encodeFrame, type Frame } from "@nanobpm/agentic/protocol";
12
12
  import type { SqliteDb } from "@nanobpm/agentic/presence";
13
13
  import type { AppApi, DataLayer } from "@nanobpm/urban";
14
14
  import { assert, assertEquals } from "#test-assert";
15
+ import { currentCorrelation } from "../app/agentic/correlation.ts";
16
+ import { family as correlationFamily } from "../app/agentic/families/correlation.family.ts";
15
17
  import { family } from "../app/agentic/families/presence.family.ts";
16
18
  import type { AgenticContext } from "../app/agentic/registry.ts";
17
19
  import { noopLog } from "../test/log.ts";
@@ -151,3 +153,41 @@ test("shared-secret guard rejects a missing secret when configured", async () =>
151
153
  else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
152
154
  }
153
155
  });
156
+
157
+ test("H6: with the correlation family mounted, jobKeys populate, stream repoints, and correlations are reported", async () => {
158
+ const hub = await mountPresence(memSqlite());
159
+ correlationFamily.mount({
160
+ hub,
161
+ registry: hub.registry,
162
+ transport: undefined as never,
163
+ data: undefined,
164
+ log: noopLog(),
165
+ });
166
+ const correlation = currentCorrelation();
167
+ assert(correlation !== undefined, "the correlation family installs the singleton");
168
+ correlation.link("wk-a", "6494", { processInstanceKey: "4612", bpmnProcessId: "plan-fanout", elementId: "implement-task", planKey: "o/r#142" });
169
+ try {
170
+ const res = (await handler(input(), app)) as {
171
+ status: number;
172
+ body: {
173
+ workers: Array<Record<string, unknown>>;
174
+ correlations: Array<Record<string, unknown>>;
175
+ };
176
+ };
177
+ assertEquals(res.status, 200);
178
+ const w = res.body.workers[0];
179
+ assertEquals(w.jobKeys, ["6494"], "the correlation registry feeds the jobKeys seam");
180
+ assertEquals(w.stream, "job:6494", "the drill stream repoints at the live job's stream");
181
+ assertEquals(res.body.correlations.length, 1);
182
+ const c = res.body.correlations[0];
183
+ assertEquals(c.jobKey, "6494");
184
+ assertEquals(c.stream, "job:6494");
185
+ assertEquals(c.processInstanceKey, "4612");
186
+ assertEquals(c.bpmnProcessId, "plan-fanout");
187
+ assertEquals(c.planKey, "o/r#142");
188
+ } finally {
189
+ correlationFamily.teardown?.();
190
+ family.teardown?.();
191
+ await hub.close();
192
+ }
193
+ });
@@ -3,6 +3,12 @@
3
3
  // worker list — family, host, current jobs, liveness — grouped by leaf token, sourced from the H1
4
4
  // presence registry (#144). Read-only projection; it NEVER gates control flow (advisory-only, ADR 0056).
5
5
  //
6
+ // H6 (#149) closes the loop: the correlation registry (`app/agentic/correlation.ts`) supplies the
7
+ // `jobKeysFor` resolver the presence snapshot exposes as a seam, so each worker's current jobKeys light
8
+ // up; each worker's drill `stream` is repointed at its jobKey-scoped relay stream (`job:<jobKey>`); and
9
+ // the report carries the `correlations` — the process-instance / plan context for every current job —
10
+ // so the cockpit lines a worker's terminal up with "that process instance / this plan".
11
+ //
6
12
  // This is the supply half of the visibility plane only. The demand×supply matrix, missing-agent-type
7
13
  // reds, and diversity-SLO lights are DE-SCOPED to the enrolment epic #152 — this report carries no
8
14
  // demand-side fields, and the cockpit renders none.
@@ -10,22 +16,24 @@
10
16
  // The optional shared-secret guard stays HERE (the runtime does not enforce OpenAPI `security`): when
11
17
  // NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret header. Unset → open.
12
18
 
19
+ import { type CorrelationRegistry, currentCorrelation, type JobCorrelation } from "../app/agentic/correlation.ts";
13
20
  import { currentPresenceRegistry, type SupplyWorker } from "../app/agentic/families/presence.family.ts";
14
21
  import { envVar } from "../app/version.ts";
15
- import type { AgenticSupplyReport, AgenticSupplyWorker } from "../nano-generated/api-io.d.ts";
22
+ import type { AgenticJobCorrelation, AgenticSupplyReport, AgenticSupplyWorker } from "../nano-generated/api-io.d.ts";
16
23
  import { defineOperation } from "../nano-generated/operations.ts";
17
24
 
18
25
  // The optional shared-secret guard: when NANO_PR_WEBHOOK_SECRET is set, callers must present it via
19
26
  // the x-hook-secret header. Captured once, at module load.
20
27
  const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
21
28
 
22
- // The relay stream id to subscribe when drilling into a worker's terminal. Presence keys the relay by
23
- // worker instance; the H6 correlation slice (#149) may repoint this at a jobKey-scoped stream.
24
- function toWorker(w: SupplyWorker): AgenticSupplyWorker {
29
+ // Project a presence-registry row to the wire worker. The drill `stream` defaults to the worker
30
+ // instance (H5) but is repointed at the worker's jobKey-scoped relay stream (`job:<jobKey>`) when the
31
+ // correlation registry knows a current job for it (H6) so drilling in opens the LIVE job's terminal.
32
+ function toWorker(w: SupplyWorker, correlation: CorrelationRegistry | undefined): AgenticSupplyWorker {
25
33
  const out: AgenticSupplyWorker = {
26
34
  instance: w.instance,
27
35
  identity: w.identity,
28
- stream: w.instance,
36
+ stream: correlation?.primaryStreamFor(w.instance) ?? w.instance,
29
37
  jobKeys: [...w.jobKeys],
30
38
  live: w.live,
31
39
  staleMs: w.staleMs,
@@ -35,6 +43,17 @@ function toWorker(w: SupplyWorker): AgenticSupplyWorker {
35
43
  return out;
36
44
  }
37
45
 
46
+ // Project a correlation-registry entry to the wire correlation. Optional fields are only set when
47
+ // known (biome bans `undefined`-valued keys crossing the boundary).
48
+ function toCorrelation(c: JobCorrelation): AgenticJobCorrelation {
49
+ const out: AgenticJobCorrelation = { jobKey: c.jobKey, stream: c.stream };
50
+ if (c.processInstanceKey !== undefined) out.processInstanceKey = c.processInstanceKey;
51
+ if (c.bpmnProcessId !== undefined) out.bpmnProcessId = c.bpmnProcessId;
52
+ if (c.elementId !== undefined) out.elementId = c.elementId;
53
+ if (c.planKey !== undefined) out.planKey = c.planKey;
54
+ return out;
55
+ }
56
+
38
57
  export default defineOperation("getAgenticSupply", async ({ req }, app) => {
39
58
  if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
40
59
  app.log.warn("getAgenticSupply rejected: missing/invalid shared secret");
@@ -44,16 +63,20 @@ export default defineOperation("getAgenticSupply", async ({ req }, app) => {
44
63
  const registry = currentPresenceRegistry();
45
64
  if (!registry) {
46
65
  // The presence family has not mounted (or has torn down) — no supply to report, not an error.
47
- const empty: AgenticSupplyReport = { count: 0, generatedAt: new Date().toISOString(), workers: [], leaves: [] };
66
+ const empty: AgenticSupplyReport = { count: 0, generatedAt: new Date().toISOString(), workers: [], leaves: [], correlations: [] };
48
67
  return { status: 200, body: empty };
49
68
  }
50
69
 
51
- const snapshot = registry.snapshot();
70
+ // Thread the H6 correlation registry (if mounted) as the presence snapshot's jobKeysFor resolver so a
71
+ // worker's current jobKeys populate; absent → jobKeys stay empty (advisory, never an error).
72
+ const correlation = currentCorrelation();
73
+ const snapshot = registry.snapshot(correlation ? { jobKeysFor: (instance) => correlation.jobKeysFor(instance) } : {});
52
74
  const report: AgenticSupplyReport = {
53
75
  count: snapshot.count,
54
76
  generatedAt: new Date().toISOString(),
55
- workers: snapshot.workers.map(toWorker),
56
- leaves: snapshot.leaves.map((leaf) => ({ token: leaf.token, workers: leaf.workers.map(toWorker) })),
77
+ workers: snapshot.workers.map((w) => toWorker(w, correlation)),
78
+ leaves: snapshot.leaves.map((leaf) => ({ token: leaf.token, workers: leaf.workers.map((w) => toWorker(w, correlation)) })),
79
+ correlations: correlation ? correlation.snapshot().correlations.map(toCorrelation) : [],
57
80
  };
58
81
  return { status: 200, body: report };
59
82
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.55.0",
3
+ "version": "0.56.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",
@@ -131,6 +131,23 @@
131
131
 
132
132
  .cockpit-worker:hover { color: #58a6ff; }
133
133
 
134
+ .cockpit-supply-process { color: var(--cockpit-muted); }
135
+
136
+ .cockpit-correlation {
137
+ background: none;
138
+ border: none;
139
+ color: var(--cockpit-text);
140
+ cursor: pointer;
141
+ display: block;
142
+ font: inherit;
143
+ padding: 0;
144
+ text-align: left;
145
+ text-decoration: underline;
146
+ text-underline-offset: 2px;
147
+ }
148
+
149
+ .cockpit-correlation:hover { color: #58a6ff; }
150
+
134
151
  .cockpit-supply-liveness { color: var(--cockpit-muted); }
135
152
 
136
153
  .cockpit-supply-empty {
@@ -33,8 +33,21 @@ function liveness(worker, staleAfterMs) {
33
33
  return worker.staleMs >= staleAfterMs ? "stale" : "live";
34
34
  }
35
35
 
36
- function workerView(worker, staleAfterMs) {
36
+ function correlationLabel(c) {
37
+ const parts = [];
38
+ if (c.bpmnProcessId != null) parts.push(c.bpmnProcessId);
39
+ if (c.elementId != null) parts.push(c.elementId);
40
+ if (c.processInstanceKey != null) parts.push(`inst ${c.processInstanceKey}`);
41
+ if (c.planKey != null) parts.push(c.planKey);
42
+ return parts.length > 0 ? parts.join(" \u00b7 ") : `job ${c.jobKey}`;
43
+ }
44
+
45
+ function workerView(worker, staleAfterMs, byJobKey) {
37
46
  const jobKeys = [...(worker.jobKeys ?? [])].sort((a, b) => a.localeCompare(b));
47
+ const correlations = jobKeys
48
+ .map((jobKey) => byJobKey.get(jobKey))
49
+ .filter((c) => c != null)
50
+ .map((c) => ({ jobKey: c.jobKey, stream: c.stream, label: correlationLabel(c) }));
38
51
  return {
39
52
  instance: worker.instance,
40
53
  identity: worker.identity,
@@ -43,6 +56,7 @@ function workerView(worker, staleAfterMs) {
43
56
  host: worker.host ?? "\u2014",
44
57
  jobKeys,
45
58
  jobs: jobKeys.length,
59
+ correlations,
46
60
  liveness: liveness(worker, staleAfterMs),
47
61
  staleMs: worker.staleMs,
48
62
  };
@@ -50,9 +64,11 @@ function workerView(worker, staleAfterMs) {
50
64
 
51
65
  function supplyView(report, staleAfterMs) {
52
66
  const byInstance = (a, b) => a.instance.localeCompare(b.instance);
67
+ const byJobKey = new Map();
68
+ for (const c of report.correlations ?? []) byJobKey.set(c.jobKey, c);
53
69
  const leaves = (report.leaves ?? [])
54
70
  .map((leaf) => {
55
- const workers = leaf.workers.map((w) => workerView(w, staleAfterMs)).sort(byInstance);
71
+ const workers = leaf.workers.map((w) => workerView(w, staleAfterMs, byJobKey)).sort(byInstance);
56
72
  return {
57
73
  token: leaf.token,
58
74
  workers,
@@ -61,7 +77,7 @@ function supplyView(report, staleAfterMs) {
61
77
  };
62
78
  })
63
79
  .sort((a, b) => a.token.localeCompare(b.token));
64
- const workers = (report.workers ?? []).map((w) => workerView(w, staleAfterMs)).sort(byInstance);
80
+ const workers = (report.workers ?? []).map((w) => workerView(w, staleAfterMs, byJobKey)).sort(byInstance);
65
81
  return { leaves, workers, count: workers.length, live: workers.filter((w) => w.liveness === "live").length };
66
82
  }
67
83
 
@@ -100,6 +116,21 @@ function workerRow(doc, worker, onDrill) {
100
116
  const jobsCell = el(doc, "td", "cockpit-td cockpit-supply-jobs", worker.jobs === 0 ? "\u2014" : worker.jobKeys.join(", "));
101
117
  jobsCell.setAttribute("data-jobs", String(worker.jobs));
102
118
  row.appendChild(jobsCell);
119
+ const processCell = el(doc, "td", "cockpit-td cockpit-supply-process");
120
+ processCell.setAttribute("data-correlations", String(worker.correlations.length));
121
+ if (worker.correlations.length === 0) {
122
+ processCell.textContent = "\u2014";
123
+ } else {
124
+ for (const correlation of worker.correlations) {
125
+ const link = el(doc, "button", "cockpit-correlation", correlation.label);
126
+ link.setAttribute("type", "button");
127
+ link.setAttribute("data-job-key", correlation.jobKey);
128
+ link.setAttribute("data-stream", correlation.stream);
129
+ if (onDrill) link.addEventListener("click", () => onDrill(correlation.stream));
130
+ processCell.appendChild(link);
131
+ }
132
+ }
133
+ row.appendChild(processCell);
103
134
  const livenessCell = el(doc, "td", "cockpit-td cockpit-supply-liveness", worker.liveness);
104
135
  livenessCell.setAttribute("data-liveness", worker.liveness);
105
136
  row.appendChild(livenessCell);
@@ -116,7 +147,7 @@ function leafSection(doc, leaf, onDrill) {
116
147
  const table = el(doc, "table", "cockpit-supply-table");
117
148
  const thead = el(doc, "thead", "cockpit-supply-thead");
118
149
  const head = el(doc, "tr", "cockpit-supply-head");
119
- for (const label of ["worker", "family", "host", "jobs", "liveness"]) head.appendChild(el(doc, "th", "cockpit-th", label));
150
+ for (const label of ["worker", "family", "host", "jobs", "process / plan", "liveness"]) head.appendChild(el(doc, "th", "cockpit-th", label));
120
151
  thead.appendChild(head);
121
152
  table.appendChild(thead);
122
153
  const tbody = el(doc, "tbody", "cockpit-supply-tbody");