@nanobpm/nano-workforce 0.54.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.
- package/CHANGELOG.md +14 -0
- package/app/agentic/cockpit/index.ts +30 -0
- package/app/agentic/cockpit/supply-boot.test.ts +279 -0
- package/app/agentic/cockpit/supply-boot.ts +282 -0
- package/app/agentic/cockpit/supply-render.test.ts +112 -0
- package/app/agentic/cockpit/supply-render.ts +165 -0
- package/app/agentic/cockpit/supply-view.test.ts +116 -0
- package/app/agentic/cockpit/supply-view.ts +243 -0
- 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/docs/agentic-cockpit.md +135 -0
- package/openapi.yaml +126 -0
- package/operations/getAgenticSupply.test.ts +193 -0
- package/operations/getAgenticSupply.ts +82 -0
- package/package.json +1 -1
- package/pages/cockpit/cockpit.css +162 -0
- package/pages/cockpit/embed.html +42 -0
- package/pages/cockpit/mount.js +362 -0
- package/pages/cockpit/standalone.html +46 -0
- package/pages/cockpit.page.json +46 -0
- package/pages/epic-detail.page.json +2 -1
- package/pages/epic.page.json +2 -1
- package/pages/home.page.json +4 -0
- package/test/agentic-cockpit-doubles.ts +136 -0
- package/test/agentic-e2e.test.ts +258 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Unit tests for the H6 correlation family module (ADR 0056, #149).
|
|
2
|
+
//
|
|
3
|
+
// The family installs the correlation-registry singleton on mount and clears it on teardown — the seam
|
|
4
|
+
// through which the supply report (H5) and cockpit read a worker's process instance / plan. Unlike
|
|
5
|
+
// presence/relay it owns no channel message family, so these tests exercise the mount/teardown
|
|
6
|
+
// lifecycle and the singleton it manages, driven with a minimal AgenticContext.
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { assert, assertEquals } from "#test-assert";
|
|
9
|
+
import { currentCorrelation } from "../correlation.ts";
|
|
10
|
+
import type { AgenticContext } from "../registry.ts";
|
|
11
|
+
import { noopLog } from "../../../test/log.ts";
|
|
12
|
+
import { CORRELATION_FAMILY, family } from "./correlation.family.ts";
|
|
13
|
+
|
|
14
|
+
function ctx(): AgenticContext {
|
|
15
|
+
return {
|
|
16
|
+
hub: undefined as never,
|
|
17
|
+
registry: undefined as never,
|
|
18
|
+
transport: undefined as never,
|
|
19
|
+
data: undefined,
|
|
20
|
+
log: noopLog(),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("the correlation family declares its stable name", () => {
|
|
25
|
+
assertEquals(family.name, CORRELATION_FAMILY);
|
|
26
|
+
assertEquals(family.name, "correlation");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("mount installs a fresh correlation registry singleton; teardown clears it", () => {
|
|
30
|
+
assertEquals(currentCorrelation(), undefined);
|
|
31
|
+
family.mount(ctx());
|
|
32
|
+
const reg = currentCorrelation();
|
|
33
|
+
assert(reg !== undefined, "mount installs the singleton");
|
|
34
|
+
reg.link("wk-a", "6494", { planKey: "o/r#142" });
|
|
35
|
+
assertEquals(reg.count(), 1);
|
|
36
|
+
family.teardown?.();
|
|
37
|
+
assertEquals(currentCorrelation(), undefined);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("re-mount installs a fresh (empty) registry, not the torn-down one", () => {
|
|
41
|
+
family.mount(ctx());
|
|
42
|
+
currentCorrelation()?.link("wk-a", "1");
|
|
43
|
+
family.teardown?.();
|
|
44
|
+
family.mount(ctx());
|
|
45
|
+
assertEquals(currentCorrelation()?.count(), 0);
|
|
46
|
+
family.teardown?.();
|
|
47
|
+
});
|
|
@@ -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.
|