@nanobpm/nano-workforce 0.55.0 → 0.57.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 (34) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +9 -1
  3. package/SPEC.md +6 -2
  4. package/app/agentGuide.ts +1 -1
  5. package/app/agentic/cockpit/supply-render.test.ts +36 -0
  6. package/app/agentic/cockpit/supply-render.ts +22 -1
  7. package/app/agentic/cockpit/supply-view.test.ts +40 -0
  8. package/app/agentic/cockpit/supply-view.ts +81 -3
  9. package/app/agentic/correlation.test.ts +132 -0
  10. package/app/agentic/correlation.ts +193 -0
  11. package/app/agentic/families/correlation.family.test.ts +47 -0
  12. package/app/agentic/families/correlation.family.ts +39 -0
  13. package/app/github.test.ts +179 -1
  14. package/app/github.ts +132 -0
  15. package/app/plan.test.ts +268 -20
  16. package/app/plan.ts +147 -15
  17. package/docs/agentic-cockpit.md +135 -0
  18. package/nano.app.json +4 -0
  19. package/openapi.yaml +89 -12
  20. package/operations/getAgenticSupply.test.ts +40 -0
  21. package/operations/getAgenticSupply.ts +32 -9
  22. package/operations/startAndMessage.test.ts +62 -2
  23. package/operations/startPlanFanout.admission.integration.test.ts +263 -0
  24. package/operations/startPlanFanout.ts +70 -11
  25. package/package.json +1 -1
  26. package/pages/cockpit/cockpit.css +17 -0
  27. package/pages/cockpit/mount.js +35 -4
  28. package/pages/epic.page.json +4 -1
  29. package/resources/agent-guide.md +38 -2
  30. package/resources/processes/plan-fanout.bpmn +168 -149
  31. package/test/agentic-e2e.test.ts +258 -0
  32. package/workers/ensure-base-branch/head-task.integration.test.ts +126 -0
  33. package/workers/ensure-base-branch/worker.test.ts +104 -0
  34. package/workers/ensure-base-branch/worker.ts +31 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.57.0](https://github.com/nanobpm/nano-workforce/compare/v0.56.0...v0.57.0) (2026-08-13)
2
+
3
+
4
+ ### Features
5
+
6
+ * **app-view:** show epic base branch in epics overview grid ([#159](https://github.com/nanobpm/nano-workforce/issues/159)) ([#164](https://github.com/nanobpm/nano-workforce/issues/164)) ([216cb0f](https://github.com/nanobpm/nano-workforce/commit/216cb0f515561fbaa646725593707ae6a2418246))
7
+
8
+ # [0.56.0](https://github.com/nanobpm/nano-workforce/compare/v0.55.0...v0.56.0) (2026-08-13)
9
+
10
+
11
+ ### Features
12
+
13
+ * correlate jobKeys to process/plan across supply report + cockpit (H6) ([#182](https://github.com/nanobpm/nano-workforce/issues/182)) ([f6e2b0d](https://github.com/nanobpm/nano-workforce/commit/f6e2b0d5f804c4c30863e5f5c66bbdc835025046)), closes [#142](https://github.com/nanobpm/nano-workforce/issues/142) [#152](https://github.com/nanobpm/nano-workforce/issues/152) [#149](https://github.com/nanobpm/nano-workforce/issues/149)
14
+
1
15
  # [0.55.0](https://github.com/nanobpm/nano-workforce/compare/v0.54.0...v0.55.0) (2026-08-13)
2
16
 
3
17
 
package/README.md CHANGED
@@ -231,9 +231,17 @@ fleet"** form, or POST the same operation the form does:
231
231
  ```bash
232
232
  curl -sS -X POST http://localhost:3000/app/api/actions/start/plan-fanout \
233
233
  -H 'content-type: application/json' \
234
- -d '{ "issue": "owner/repo#123" }'
234
+ -d '{ "issue": "owner/repo#123", "baseBranch": "epic/agent-protocol" }'
235
235
  ```
236
236
 
237
+ `baseBranch` is **required** (ADR 0003) — it's the integration branch the whole fleet
238
+ branches off and opens every PR against. A missing `epic/*` base is auto-created off the
239
+ default branch's HEAD; a missing non-`epic/*` base is a `400` (must already exist). Two
240
+ optional flags gate the dangerous cases: `confirmDefaultBase: true` is required to name the
241
+ repository default branch as the base, and `allowSharedBase: true` is required when another
242
+ active epic already targets the same custom base. See
243
+ [ADR 0003](docs/adr/0003-epic-base-branch-admission.md) for the full admission model.
244
+
237
245
  ---
238
246
 
239
247
  ## Configuration
package/SPEC.md CHANGED
@@ -466,8 +466,12 @@ scalar-only and cannot express the `tasks`/`results` lists, so the workers self-
466
466
  `plan_tasks` (one row per slice, tracking its `status`/`pr_key`/`summary`).
467
467
 
468
468
  **Entry points**: the epic page's "Hand an issue to the fleet" form or
469
- `POST /app/api/actions/start/plan-fanout` (`{ issue | url }`) — the same flat
470
- operation the form posts.
469
+ `POST /app/api/actions/start/plan-fanout` (either `{ issue, baseBranch }` or
470
+ `{ url, baseBranch }` — a `oneOf` naming the target by **exactly one** of `issue`
471
+ (`owner/repo#123`) or `url`, plus optional `confirmDefaultBase`/`allowSharedBase`) —
472
+ the same flat operation the form posts. `baseBranch`
473
+ is required and admitted through the ADR 0003 gate (auto-create `epic/*`, confirm-default,
474
+ shared-base guard).
471
475
 
472
476
  **Visibility**: the home page adds a **Plans** grid (Active: planning/dispatched;
473
477
  History: done/failed/abandoned) with a `plan_tasks` child grid showing each task's
package/app/agentGuide.ts CHANGED
@@ -31,7 +31,7 @@ const RAW_GUIDE: string = (() => {
31
31
  "- `GET /status` — every PR in flight, with its engine `processKey` and any open escalation.",
32
32
  "- `GET /version` — which code is live.",
33
33
  "- `POST /actions/start/convergence-loop` — submit a PR (`{ pr, convergeOnly?, maxRounds?, dependsOn? }`).",
34
- "- `POST /actions/start/plan-fanout` — submit an epic (`{ issue }`).",
34
+ "- `POST /actions/start/plan-fanout` — submit an epic (`{ issue, baseBranch }` or `{ url, baseBranch }`; base is required — a missing `epic/*` base is auto-created, and `confirmDefaultBase`/`allowSharedBase` gate the default-branch and shared-base cases — see ADR 0003).",
35
35
  "- `POST /actions/message` — answer an escalation (`escalation-answered`, correlate by PR key).",
36
36
  "",
37
37
  "Engine (Camunda-8 v2 REST) base for debugging: `__ENGINE__`.",
@@ -74,3 +74,39 @@ test("renders an empty state when no workers are connected", () => {
74
74
  assert.equal(host.byData("empty", "true").length, 1);
75
75
  assert.equal(host.byClass("cockpit-supply-worker").length, 0);
76
76
  });
77
+
78
+ test("H6: renders a process/plan cell that drills into the job's stream", () => {
79
+ const host = new FakeElement("body");
80
+ const drilled: string[] = [];
81
+ const correlated: SupplyReport = {
82
+ count: 1,
83
+ workers: [{ instance: "wk-a", identity: "leaf-1", stream: "job:6494", family: "senior", host: "h1", jobKeys: ["6494"], live: true, staleMs: 0 }],
84
+ leaves: [
85
+ {
86
+ token: "leaf-1",
87
+ workers: [{ instance: "wk-a", identity: "leaf-1", stream: "job:6494", family: "senior", host: "h1", jobKeys: ["6494"], live: true, staleMs: 0 }],
88
+ },
89
+ ],
90
+ correlations: [{ jobKey: "6494", stream: "job:6494", bpmnProcessId: "plan-fanout", processInstanceKey: "4612", planKey: "o/r#142" }],
91
+ };
92
+ renderSupply(host, doc, supplyView(correlated), { onDrill: (stream) => drilled.push(stream) });
93
+
94
+ const cell = host.byData("worker", "wk-a")[0]?.byClass("cockpit-supply-process")[0];
95
+ assert.ok(cell);
96
+ assert.equal(cell?.getAttribute("data-correlations"), "1");
97
+ const link = host.byClass("cockpit-correlation")[0];
98
+ assert.ok(link, "the correlation drill button was rendered");
99
+ assert.equal(link?.getAttribute("data-job-key"), "6494");
100
+ assert.equal(link?.getAttribute("data-stream"), "job:6494");
101
+ assert.equal(link?.text(), "plan-fanout · inst 4612 · o/r#142");
102
+ link?.dispatch("click");
103
+ assert.deepEqual(drilled, ["job:6494"], "drilling the process/plan cell opens the live job's stream");
104
+ });
105
+
106
+ test("H6: a worker with no correlation renders an em-dash process cell", () => {
107
+ const host = new FakeElement("body");
108
+ renderSupply(host, doc, supplyView(sample));
109
+ const cell = host.byData("worker", "wk-b")[0]?.byClass("cockpit-supply-process")[0];
110
+ assert.equal(cell?.text(), "—");
111
+ assert.equal(cell?.getAttribute("data-correlations"), "0");
112
+ });
@@ -69,6 +69,27 @@ function workerRow(doc: DocumentLike, worker: SupplyWorkerView, options: RenderS
69
69
  jobsCell.setAttribute("data-jobs", String(worker.jobs));
70
70
  row.appendChild(jobsCell);
71
71
 
72
+ // The process instance / plan each current job belongs to (H6). Each correlation is a drill button
73
+ // onto its jobKey-scoped relay stream, so the operator opens the LIVE job's terminal — not just the
74
+ // worker's default stream. Empty → "—" so the cell always renders something stable.
75
+ const processCell = el(doc, "td", "cockpit-td cockpit-supply-process");
76
+ processCell.setAttribute("data-correlations", String(worker.correlations.length));
77
+ if (worker.correlations.length === 0) {
78
+ processCell.textContent = "—";
79
+ } else {
80
+ for (const correlation of worker.correlations) {
81
+ const link = el(doc, "button", "cockpit-correlation", correlation.label);
82
+ link.setAttribute("type", "button");
83
+ link.setAttribute("data-job-key", correlation.jobKey);
84
+ link.setAttribute("data-stream", correlation.stream);
85
+ if (onDrill !== undefined) {
86
+ link.addEventListener("click", () => onDrill(correlation.stream));
87
+ }
88
+ processCell.appendChild(link);
89
+ }
90
+ }
91
+ row.appendChild(processCell);
92
+
72
93
  const livenessCell = el(doc, "td", "cockpit-td cockpit-supply-liveness", worker.liveness);
73
94
  livenessCell.setAttribute("data-liveness", worker.liveness);
74
95
  row.appendChild(livenessCell);
@@ -88,7 +109,7 @@ function leafSection(doc: DocumentLike, leaf: SupplyLeafView, options: RenderSup
88
109
  const table = el(doc, "table", "cockpit-supply-table");
89
110
  const thead = el(doc, "thead", "cockpit-supply-thead");
90
111
  const head = el(doc, "tr", "cockpit-supply-head");
91
- for (const label of ["worker", "family", "host", "jobs", "liveness"]) {
112
+ for (const label of ["worker", "family", "host", "jobs", "process / plan", "liveness"]) {
92
113
  head.appendChild(el(doc, "th", "cockpit-th", label));
93
114
  }
94
115
  thead.appendChild(head);
@@ -11,6 +11,7 @@ function report(over: Partial<SupplyReport> = {}): SupplyReport {
11
11
  leaves: over.leaves ?? [],
12
12
  count: over.count ?? workers.length,
13
13
  generatedAt: over.generatedAt,
14
+ correlations: over.correlations,
14
15
  };
15
16
  }
16
17
 
@@ -74,3 +75,42 @@ test("sorts leaves by token and workers by instance, with per-leaf live counts",
74
75
  assert.equal(view.leaves[1]?.liveCount, 1);
75
76
  assert.equal(view.leaves[1]?.total, 2);
76
77
  });
78
+
79
+ test("H6: resolves each worker's jobKeys into correlation views with a human label", () => {
80
+ const view = supplyView(
81
+ report({
82
+ workers: [{ instance: "wk-a", identity: "leaf-a", stream: "job:6494", jobKeys: ["6494"], live: true, staleMs: 0 }],
83
+ correlations: [
84
+ { jobKey: "6494", stream: "job:6494", processInstanceKey: "4612", bpmnProcessId: "plan-fanout", elementId: "implement-task", planKey: "o/r#142" },
85
+ ],
86
+ }),
87
+ );
88
+ const w = view.workers[0];
89
+ assert.equal(w?.correlations.length, 1);
90
+ const c = w?.correlations[0];
91
+ assert.equal(c?.jobKey, "6494");
92
+ assert.equal(c?.stream, "job:6494");
93
+ assert.equal(c?.processInstanceKey, "4612");
94
+ assert.equal(c?.planKey, "o/r#142");
95
+ assert.equal(c?.label, "plan-fanout · implement-task · inst 4612 · o/r#142");
96
+ });
97
+
98
+ test("H6: a worker with no matching correlation renders an empty correlation list", () => {
99
+ const view = supplyView(
100
+ report({
101
+ workers: [{ instance: "wk-a", identity: "leaf-a", stream: "wk-a", jobKeys: ["nope"], live: true, staleMs: 0 }],
102
+ correlations: [{ jobKey: "other", stream: "job:other" }],
103
+ }),
104
+ );
105
+ assert.deepEqual(view.workers[0]?.correlations, []);
106
+ });
107
+
108
+ test("H6: a correlation with no engine context falls back to a job-key label", () => {
109
+ const view = supplyView(
110
+ report({
111
+ workers: [{ instance: "wk-a", identity: "leaf-a", stream: "job:6494", jobKeys: ["6494"], live: true, staleMs: 0 }],
112
+ correlations: [{ jobKey: "6494", stream: "job:6494" }],
113
+ }),
114
+ );
115
+ assert.equal(view.workers[0]?.correlations[0]?.label, "job 6494");
116
+ });
@@ -46,6 +46,25 @@ export interface SupplyLeafReport {
46
46
  readonly workers: readonly SupplyWorkerReport[];
47
47
  }
48
48
 
49
+ /**
50
+ * One current job's engine context (H6 #149) — the process instance / plan a worker's terminal is
51
+ * lined up against. Mirrors the supply endpoint's `AgenticJobCorrelation`.
52
+ */
53
+ export interface SupplyCorrelationReport {
54
+ /** The Camunda-8 job key. */
55
+ readonly jobKey: string;
56
+ /** The relay stream the job's terminal is on (`job:<jobKey>`). */
57
+ readonly stream: string;
58
+ /** The owning process instance key, if known. */
59
+ readonly processInstanceKey?: string;
60
+ /** The BPMN process id, if known. */
61
+ readonly bpmnProcessId?: string;
62
+ /** The BPMN element id, if known. */
63
+ readonly elementId?: string;
64
+ /** The plan / epic key, if known. */
65
+ readonly planKey?: string;
66
+ }
67
+
49
68
  /** The supply-only report the cockpit polls (no demand fields — those are enrolment epic #152). */
50
69
  export interface SupplyReport {
51
70
  /** Supply grouped by leaf token. */
@@ -56,6 +75,26 @@ export interface SupplyReport {
56
75
  readonly count: number;
57
76
  /** When the snapshot was taken, ISO-8601 (optional). */
58
77
  readonly generatedAt?: string;
78
+ /** The engine context for every currently-processing job (H6), keyed by jobKey. */
79
+ readonly correlations?: readonly SupplyCorrelationReport[];
80
+ }
81
+
82
+ /** One current job's context as the cockpit renders it in a worker row (H6). */
83
+ export interface JobCorrelationView {
84
+ /** The Camunda-8 job key. */
85
+ readonly jobKey: string;
86
+ /** The relay stream the job's terminal is on. */
87
+ readonly stream: string;
88
+ /** A single human label for the process instance / plan, e.g. `plan-fanout · inst 4612 · owner/repo#142`. */
89
+ readonly label: string;
90
+ /** The owning process instance key, if known. */
91
+ readonly processInstanceKey?: string;
92
+ /** The BPMN process id, if known. */
93
+ readonly bpmnProcessId?: string;
94
+ /** The BPMN element id, if known. */
95
+ readonly elementId?: string;
96
+ /** The plan / epic key, if known. */
97
+ readonly planKey?: string;
59
98
  }
60
99
 
61
100
  /** One worker row in the renderable supply view. */
@@ -72,6 +111,11 @@ export interface SupplyWorkerView {
72
111
  readonly jobKeys: readonly string[];
73
112
  /** The number of current jobs. */
74
113
  readonly jobs: number;
114
+ /**
115
+ * The engine context for each of this worker's current jobs (H6), sorted by jobKey — so the operator
116
+ * sees which process instance / plan the terminal belongs to. Empty when nothing correlates.
117
+ */
118
+ readonly correlations: readonly JobCorrelationView[];
75
119
  /** The coarse liveness grade for the status dot. */
76
120
  readonly liveness: Liveness;
77
121
  /** How long since the last liveness refresh, in ms. */
@@ -116,8 +160,38 @@ function liveness(worker: SupplyWorkerReport, staleAfterMs: number): Liveness {
116
160
  return worker.staleMs >= staleAfterMs ? "stale" : "live";
117
161
  }
118
162
 
119
- function workerView(worker: SupplyWorkerReport, staleAfterMs: number): SupplyWorkerView {
163
+ /** A single stable human label for a job's process instance / plan (empty parts are dropped). */
164
+ function correlationLabel(c: SupplyCorrelationReport): string {
165
+ const parts: string[] = [];
166
+ if (c.bpmnProcessId !== undefined) parts.push(c.bpmnProcessId);
167
+ if (c.elementId !== undefined) parts.push(c.elementId);
168
+ if (c.processInstanceKey !== undefined) parts.push(`inst ${c.processInstanceKey}`);
169
+ if (c.planKey !== undefined) parts.push(c.planKey);
170
+ return parts.length > 0 ? parts.join(" · ") : `job ${c.jobKey}`;
171
+ }
172
+
173
+ function correlationView(c: SupplyCorrelationReport): JobCorrelationView {
174
+ return {
175
+ jobKey: c.jobKey,
176
+ stream: c.stream,
177
+ label: correlationLabel(c),
178
+ ...(c.processInstanceKey !== undefined ? { processInstanceKey: c.processInstanceKey } : {}),
179
+ ...(c.bpmnProcessId !== undefined ? { bpmnProcessId: c.bpmnProcessId } : {}),
180
+ ...(c.elementId !== undefined ? { elementId: c.elementId } : {}),
181
+ ...(c.planKey !== undefined ? { planKey: c.planKey } : {}),
182
+ };
183
+ }
184
+
185
+ function workerView(
186
+ worker: SupplyWorkerReport,
187
+ staleAfterMs: number,
188
+ byJobKey: ReadonlyMap<string, SupplyCorrelationReport>,
189
+ ): SupplyWorkerView {
120
190
  const jobKeys = [...worker.jobKeys].sort((a, b) => a.localeCompare(b));
191
+ const correlations = jobKeys
192
+ .map((jobKey) => byJobKey.get(jobKey))
193
+ .filter((c): c is SupplyCorrelationReport => c !== undefined)
194
+ .map(correlationView);
121
195
  return {
122
196
  instance: worker.instance,
123
197
  identity: worker.identity,
@@ -126,6 +200,7 @@ function workerView(worker: SupplyWorkerReport, staleAfterMs: number): SupplyWor
126
200
  host: worker.host ?? "—",
127
201
  jobKeys,
128
202
  jobs: jobKeys.length,
203
+ correlations,
129
204
  liveness: liveness(worker, staleAfterMs),
130
205
  staleMs: worker.staleMs,
131
206
  };
@@ -142,9 +217,12 @@ const byInstance = (a: SupplyWorkerView, b: SupplyWorkerView) => a.instance.loca
142
217
  export function supplyView(report: SupplyReport, options: SupplyViewOptions = {}): SupplyView {
143
218
  const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
144
219
 
220
+ const byJobKey = new Map<string, SupplyCorrelationReport>();
221
+ for (const c of report.correlations ?? []) byJobKey.set(c.jobKey, c);
222
+
145
223
  const leaves: SupplyLeafView[] = report.leaves
146
224
  .map((leaf) => {
147
- const workers = leaf.workers.map((w) => workerView(w, staleAfterMs)).sort(byInstance);
225
+ const workers = leaf.workers.map((w) => workerView(w, staleAfterMs, byJobKey)).sort(byInstance);
148
226
  return {
149
227
  token: leaf.token,
150
228
  workers,
@@ -154,7 +232,7 @@ export function supplyView(report: SupplyReport, options: SupplyViewOptions = {}
154
232
  })
155
233
  .sort((a, b) => a.token.localeCompare(b.token));
156
234
 
157
- const workers = report.workers.map((w) => workerView(w, staleAfterMs)).sort(byInstance);
235
+ const workers = report.workers.map((w) => workerView(w, staleAfterMs, byJobKey)).sort(byInstance);
158
236
 
159
237
  return {
160
238
  leaves,
@@ -0,0 +1,132 @@
1
+ // Unit tests for the jobKey ⇄ process/plan correlation registry (ADR 0056, H6 / #149).
2
+ //
3
+ // The registry is the single canonical join the cockpit uses to line a worker's terminal up with the
4
+ // process instance / plan it belongs to. These tests pin: the `job:<jobKey>` stream convention; the
5
+ // two derived-from-one-write projections (instance→jobKeys and jobKey→context) staying consistent
6
+ // across link / re-link (move) / releaseJob / releaseInstance; the presence `jobKeysFor` seam; the
7
+ // drill `primaryStreamFor`; and the sorted snapshot.
8
+ import assert from "node:assert/strict";
9
+ import { test } from "node:test";
10
+
11
+ import {
12
+ CorrelationRegistry,
13
+ currentCorrelation,
14
+ JOB_STREAM_PREFIX,
15
+ jobKeyOfStream,
16
+ jobStream,
17
+ setCurrentCorrelation,
18
+ } from "./correlation.ts";
19
+
20
+ test("jobStream / jobKeyOfStream are inverse over the job: convention", () => {
21
+ assert.equal(jobStream("6494"), `${JOB_STREAM_PREFIX}6494`);
22
+ assert.equal(jobKeyOfStream(jobStream("6494")), "6494");
23
+ assert.equal(jobKeyOfStream("wk-a"), undefined);
24
+ // A bare `job:` prefix carries no jobKey, so it maps to undefined (not "") — an empty jobKey is
25
+ // invalid (link() ignores it), so callers never mistake it for a valid key.
26
+ assert.equal(jobKeyOfStream("job:"), undefined);
27
+ });
28
+
29
+ test("link records context and both projections; resolve carries the job: stream", () => {
30
+ const reg = new CorrelationRegistry();
31
+ reg.link("wk-a", "6494", { processInstanceKey: "4612", bpmnProcessId: "plan-fanout", elementId: "implement-task", planKey: "o/r#142" });
32
+
33
+ assert.deepEqual(reg.jobKeysFor("wk-a"), ["6494"]);
34
+ const c = reg.resolve("6494");
35
+ assert.ok(c);
36
+ assert.equal(c.jobKey, "6494");
37
+ assert.equal(c.stream, "job:6494");
38
+ assert.equal(c.processInstanceKey, "4612");
39
+ assert.equal(c.bpmnProcessId, "plan-fanout");
40
+ assert.equal(c.elementId, "implement-task");
41
+ assert.equal(c.planKey, "o/r#142");
42
+ assert.equal(reg.count(), 1);
43
+ });
44
+
45
+ test("link ignores empty instance or jobKey", () => {
46
+ const reg = new CorrelationRegistry();
47
+ reg.link("", "6494");
48
+ reg.link("wk-a", "");
49
+ assert.equal(reg.count(), 0);
50
+ assert.deepEqual(reg.jobKeysFor("wk-a"), []);
51
+ });
52
+
53
+ test("jobKeysFor returns the worker's jobs sorted; unknown instance is empty", () => {
54
+ const reg = new CorrelationRegistry();
55
+ reg.link("wk-a", "20");
56
+ reg.link("wk-a", "3");
57
+ reg.link("wk-a", "100");
58
+ assert.deepEqual(reg.jobKeysFor("wk-a"), ["100", "20", "3"]);
59
+ assert.deepEqual(reg.jobKeysFor("nobody"), []);
60
+ });
61
+
62
+ test("re-linking a jobKey to a new instance MOVES it (drops the stale reverse edge)", () => {
63
+ const reg = new CorrelationRegistry();
64
+ reg.link("wk-a", "6494");
65
+ reg.link("wk-b", "6494", { planKey: "o/r#142" });
66
+
67
+ assert.deepEqual(reg.jobKeysFor("wk-a"), []);
68
+ assert.deepEqual(reg.jobKeysFor("wk-b"), ["6494"]);
69
+ assert.equal(reg.resolve("6494")?.planKey, "o/r#142");
70
+ assert.equal(reg.count(), 1);
71
+ });
72
+
73
+ test("releaseJob removes one job from both projections", () => {
74
+ const reg = new CorrelationRegistry();
75
+ reg.link("wk-a", "6494");
76
+ reg.link("wk-a", "6495");
77
+ reg.releaseJob("6494");
78
+ assert.equal(reg.resolve("6494"), undefined);
79
+ assert.deepEqual(reg.jobKeysFor("wk-a"), ["6495"]);
80
+ reg.releaseJob("nope"); // no-op
81
+ assert.equal(reg.count(), 1);
82
+ });
83
+
84
+ test("releaseInstance drops every job the worker held", () => {
85
+ const reg = new CorrelationRegistry();
86
+ reg.link("wk-a", "1");
87
+ reg.link("wk-a", "2");
88
+ reg.link("wk-b", "3");
89
+ reg.releaseInstance("wk-a");
90
+ assert.deepEqual(reg.jobKeysFor("wk-a"), []);
91
+ assert.equal(reg.resolve("1"), undefined);
92
+ assert.equal(reg.resolve("2"), undefined);
93
+ assert.equal(reg.resolve("3")?.jobKey, "3");
94
+ assert.equal(reg.count(), 1);
95
+ });
96
+
97
+ test("primaryStreamFor picks the lowest-sorted job's stream; undefined when none", () => {
98
+ const reg = new CorrelationRegistry();
99
+ assert.equal(reg.primaryStreamFor("wk-a"), undefined);
100
+ reg.link("wk-a", "50");
101
+ reg.link("wk-a", "10");
102
+ assert.equal(reg.primaryStreamFor("wk-a"), "job:10");
103
+ });
104
+
105
+ test("snapshot returns every job sorted by jobKey", () => {
106
+ const reg = new CorrelationRegistry();
107
+ reg.link("wk-a", "30");
108
+ reg.link("wk-b", "10");
109
+ reg.link("wk-c", "20");
110
+ const snap = reg.snapshot();
111
+ assert.equal(snap.count, 3);
112
+ assert.deepEqual(snap.correlations.map((c) => c.jobKey), ["10", "20", "30"]);
113
+ assert.deepEqual(snap.correlations.map((c) => c.stream), ["job:10", "job:20", "job:30"]);
114
+ });
115
+
116
+ test("link with no context leaves optional fields unset (no undefined holes)", () => {
117
+ const reg = new CorrelationRegistry();
118
+ reg.link("wk-a", "6494");
119
+ const c = reg.resolve("6494");
120
+ assert.ok(c);
121
+ assert.equal("processInstanceKey" in c, false);
122
+ assert.equal("planKey" in c, false);
123
+ });
124
+
125
+ test("currentCorrelation singleton is settable and clearable", () => {
126
+ assert.equal(currentCorrelation(), undefined);
127
+ const reg = new CorrelationRegistry();
128
+ setCurrentCorrelation(reg);
129
+ assert.equal(currentCorrelation(), reg);
130
+ setCurrentCorrelation(undefined);
131
+ assert.equal(currentCorrelation(), undefined);
132
+ });
@@ -0,0 +1,193 @@
1
+ // nano-workforce — the jobKey ⇄ process-instance / plan correlation registry (ADR 0056, H6 / #149).
2
+ //
3
+ // The closing slice of the agentic-visibility epic (#142). It answers ADR 0056's open question —
4
+ // "which process instance / plan is THIS terminal?" — by carrying the one fact neither presence (H1)
5
+ // nor the relay (H3) holds: the association between a worker instance, the jobKeys it is currently
6
+ // processing, and each jobKey's engine context (process instance, BPMN process, plan/epic).
7
+ //
8
+ // Why a separate registry (derivation over duplication): presence rows carry the worker's declared
9
+ // enrolment capability but NO job attribution, and relay streams carry bytes but NO engine context.
10
+ // The correlation is the single canonical join between them:
11
+ // - `jobKeysFor(instance)` is the resolver H1's `PresenceRegistry.snapshot({ jobKeysFor })` seam
12
+ // asks for, so a worker's current jobKeys light up in the supply feed / cockpit.
13
+ // - `resolve(jobKey)` gives the cockpit the process-instance / plan a terminal belongs to, so the
14
+ // drilled bytes line up with "that process instance / this plan".
15
+ // - the relay terminal for a job is the jobKey-scoped stream {@link jobStream} — a stable naming
16
+ // convention (`job:<jobKey>`) so the report can repoint a worker's drill stream at its live job
17
+ // without a second lookup table.
18
+ //
19
+ // Who populates it: the orchestrator that dispatches an agentic job to a worker (it holds the whole
20
+ // job payload — jobKey, processInstanceKey, bpmnProcessId, and the plan/epic it belongs to) calls
21
+ // {@link CorrelationRegistry.link} when the worker picks the job up and {@link CorrelationRegistry.releaseJob}
22
+ // (or {@link CorrelationRegistry.releaseInstance} on disconnect) when it finishes. This is the seam
23
+ // the end-to-end wiring test drives directly.
24
+ //
25
+ // Invariants (ADR 0056): app-tier only, never the engine; the Camunda-8 job protocol (worker⇄engine)
26
+ // is untouched — correlation is an app-side observation, not a new wire type; ADVISORY — it is a
27
+ // read-only join for visibility and NEVER hard-locks or gates a BPMN sequence flow.
28
+
29
+ /** The relay-stream prefix for a jobKey-scoped terminal stream. */
30
+ export const JOB_STREAM_PREFIX = "job:";
31
+
32
+ /** The stable relay stream id a worker relays a job's terminal on: `job:<jobKey>`. */
33
+ export function jobStream(jobKey: string): string {
34
+ return `${JOB_STREAM_PREFIX}${jobKey}`;
35
+ }
36
+
37
+ /**
38
+ * The jobKey encoded in a jobKey-scoped relay stream id, or undefined for any other stream.
39
+ * A bare `job:` prefix with no suffix carries no jobKey, so it maps to undefined too — keeping
40
+ * the "empty jobKey is invalid" invariant (`link()` ignores empty jobKeys) consistent for callers.
41
+ */
42
+ export function jobKeyOfStream(stream: string): string | undefined {
43
+ if (!stream.startsWith(JOB_STREAM_PREFIX)) return undefined;
44
+ const jobKey = stream.slice(JOB_STREAM_PREFIX.length);
45
+ return jobKey === "" ? undefined : jobKey;
46
+ }
47
+
48
+ /** One job's engine context — the correlation a terminal is lined up against. */
49
+ export interface JobCorrelation {
50
+ /** The Camunda-8 job key (the C8 job the worker activated). */
51
+ readonly jobKey: string;
52
+ /** The owning process instance key, if known. */
53
+ readonly processInstanceKey?: string;
54
+ /** The BPMN process id the job belongs to, if known. */
55
+ readonly bpmnProcessId?: string;
56
+ /** The BPMN element id (activity/task) the job is for, if known. */
57
+ readonly elementId?: string;
58
+ /** The plan / epic key this job is part of (e.g. `owner/repo#142`), if known. */
59
+ readonly planKey?: string;
60
+ /** The relay stream id the job's terminal is relayed on (`job:<jobKey>`). */
61
+ readonly stream: string;
62
+ }
63
+
64
+ /** The context an orchestrator supplies when a worker picks up a job (jobKey excluded — it is the key). */
65
+ export type JobContext = Omit<JobCorrelation, "jobKey" | "stream">;
66
+
67
+ /** The read-only correlation snapshot: every currently-linked job, sorted by jobKey. */
68
+ export interface CorrelationSnapshot {
69
+ readonly correlations: readonly JobCorrelation[];
70
+ readonly count: number;
71
+ }
72
+
73
+ /**
74
+ * The advisory in-memory correlation registry. It holds two derived-from-one-write projections of the
75
+ * same `link` call: `instance → jobKeys` (the presence resolver) and `jobKey → context` (the cockpit
76
+ * lookup). A jobKey belongs to at most one instance at a time; re-linking it moves it (and drops the
77
+ * stale reverse edge) so a re-dispatched job never double-counts.
78
+ */
79
+ export class CorrelationRegistry {
80
+ /** jobKey → the worker instance currently processing it. */
81
+ readonly #instanceOf = new Map<string, string>();
82
+ /** worker instance → the set of jobKeys it is currently processing (insertion-ordered). */
83
+ readonly #jobsOf = new Map<string, Set<string>>();
84
+ /** jobKey → its engine context. */
85
+ readonly #context = new Map<string, JobCorrelation>();
86
+
87
+ /**
88
+ * Link a worker instance to a job it is now processing, recording the job's engine context. A
89
+ * re-link of the same jobKey to a different instance moves it (dropping the old reverse edge); a
90
+ * re-link with fresh context overwrites the context (last write wins). Both args must be non-empty.
91
+ */
92
+ link(instance: string, jobKey: string, context: JobContext = {}): void {
93
+ if (instance === "" || jobKey === "") return;
94
+ const previousInstance = this.#instanceOf.get(jobKey);
95
+ if (previousInstance !== undefined && previousInstance !== instance) {
96
+ this.#jobsOf.get(previousInstance)?.delete(jobKey);
97
+ this.#pruneInstance(previousInstance);
98
+ }
99
+ this.#instanceOf.set(jobKey, instance);
100
+ const jobs = this.#jobsOf.get(instance) ?? new Set<string>();
101
+ jobs.add(jobKey);
102
+ this.#jobsOf.set(instance, jobs);
103
+ this.#context.set(jobKey, { jobKey, stream: jobStream(jobKey), ...stripUndefined(context) });
104
+ }
105
+
106
+ /** Release one job (it finished / moved on). No-op if it was never linked. */
107
+ releaseJob(jobKey: string): void {
108
+ const instance = this.#instanceOf.get(jobKey);
109
+ if (instance !== undefined) {
110
+ this.#jobsOf.get(instance)?.delete(jobKey);
111
+ this.#pruneInstance(instance);
112
+ }
113
+ this.#instanceOf.delete(jobKey);
114
+ this.#context.delete(jobKey);
115
+ }
116
+
117
+ /** Release every job a worker instance held (e.g. on disconnect / presence timeout). */
118
+ releaseInstance(instance: string): void {
119
+ const jobs = this.#jobsOf.get(instance);
120
+ if (!jobs) return;
121
+ for (const jobKey of jobs) {
122
+ this.#instanceOf.delete(jobKey);
123
+ this.#context.delete(jobKey);
124
+ }
125
+ this.#jobsOf.delete(instance);
126
+ }
127
+
128
+ /**
129
+ * The jobKeys a worker instance is currently processing, sorted for a stable render. This is the
130
+ * resolver injected into {@link PresenceRegistry.snapshot}'s `jobKeysFor` seam.
131
+ */
132
+ jobKeysFor(instance: string): string[] {
133
+ const jobs = this.#jobsOf.get(instance);
134
+ return jobs ? [...jobs].sort((a, b) => a.localeCompare(b)) : [];
135
+ }
136
+
137
+ /** The engine context for a jobKey, or undefined when it is not (or no longer) linked. */
138
+ resolve(jobKey: string): JobCorrelation | undefined {
139
+ return this.#context.get(jobKey);
140
+ }
141
+
142
+ /**
143
+ * The jobKey-scoped relay stream a worker's terminal should drill into: its lowest-sorted current
144
+ * jobKey's stream (a worker processes one job at a time in this fleet, but sorting keeps it stable
145
+ * if it ever holds several). Undefined when the worker has no linked job — the caller then falls
146
+ * back to the instance-keyed stream.
147
+ */
148
+ primaryStreamFor(instance: string): string | undefined {
149
+ const [first] = this.jobKeysFor(instance);
150
+ return first === undefined ? undefined : jobStream(first);
151
+ }
152
+
153
+ /** The number of currently-linked jobs. */
154
+ count(): number {
155
+ return this.#context.size;
156
+ }
157
+
158
+ /** The read-only correlation snapshot: every linked job, sorted by jobKey. */
159
+ snapshot(): CorrelationSnapshot {
160
+ const correlations = [...this.#context.values()].sort((a, b) => a.jobKey.localeCompare(b.jobKey));
161
+ return { correlations, count: correlations.length };
162
+ }
163
+
164
+ /** Drop a worker's reverse-edge entry once it holds no more jobs, so the map stays bounded. */
165
+ #pruneInstance(instance: string): void {
166
+ const jobs = this.#jobsOf.get(instance);
167
+ if (jobs && jobs.size === 0) this.#jobsOf.delete(instance);
168
+ }
169
+ }
170
+
171
+ /** Drop `undefined`-valued keys so the stored context never materializes an explicit `{ key: undefined }` hole. */
172
+ function stripUndefined(context: JobContext): JobContext {
173
+ const { processInstanceKey, bpmnProcessId, elementId, planKey } = context;
174
+ return {
175
+ ...(processInstanceKey !== undefined ? { processInstanceKey } : {}),
176
+ ...(bpmnProcessId !== undefined ? { bpmnProcessId } : {}),
177
+ ...(elementId !== undefined ? { elementId } : {}),
178
+ ...(planKey !== undefined ? { planKey } : {}),
179
+ };
180
+ }
181
+
182
+ /** The live correlation registry from the most recent mount, so the supply report (H5) can read it. */
183
+ let currentRegistry: CorrelationRegistry | undefined;
184
+
185
+ /** The mounted correlation registry, or undefined before mount / after teardown. */
186
+ export function currentCorrelation(): CorrelationRegistry | undefined {
187
+ return currentRegistry;
188
+ }
189
+
190
+ /** Install the live registry (called by the correlation family's `mount`). */
191
+ export function setCurrentCorrelation(registry: CorrelationRegistry | undefined): void {
192
+ currentRegistry = registry;
193
+ }