@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.
- package/CHANGELOG.md +14 -0
- package/README.md +9 -1
- package/SPEC.md +6 -2
- package/app/agentGuide.ts +1 -1
- package/app/agentic/cockpit/supply-render.test.ts +36 -0
- package/app/agentic/cockpit/supply-render.ts +22 -1
- package/app/agentic/cockpit/supply-view.test.ts +40 -0
- package/app/agentic/cockpit/supply-view.ts +81 -3
- package/app/agentic/correlation.test.ts +132 -0
- package/app/agentic/correlation.ts +193 -0
- package/app/agentic/families/correlation.family.test.ts +47 -0
- package/app/agentic/families/correlation.family.ts +39 -0
- package/app/github.test.ts +179 -1
- package/app/github.ts +132 -0
- package/app/plan.test.ts +268 -20
- package/app/plan.ts +147 -15
- package/docs/agentic-cockpit.md +135 -0
- package/nano.app.json +4 -0
- package/openapi.yaml +89 -12
- package/operations/getAgenticSupply.test.ts +40 -0
- package/operations/getAgenticSupply.ts +32 -9
- package/operations/startAndMessage.test.ts +62 -2
- package/operations/startPlanFanout.admission.integration.test.ts +263 -0
- package/operations/startPlanFanout.ts +70 -11
- package/package.json +1 -1
- package/pages/cockpit/cockpit.css +17 -0
- package/pages/cockpit/mount.js +35 -4
- package/pages/epic.page.json +4 -1
- package/resources/agent-guide.md +38 -2
- package/resources/processes/plan-fanout.bpmn +168 -149
- package/test/agentic-e2e.test.ts +258 -0
- package/workers/ensure-base-branch/head-task.integration.test.ts +126 -0
- package/workers/ensure-base-branch/worker.test.ts +104 -0
- 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
|
|
470
|
-
|
|
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
|
-
|
|
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
|
+
}
|