@nanobpm/nano-workforce 0.55.0 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- 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/docs/agentic-cockpit.md +135 -0
- package/openapi.yaml +33 -1
- package/operations/getAgenticSupply.test.ts +40 -0
- package/operations/getAgenticSupply.ts +32 -9
- package/package.json +1 -1
- package/pages/cockpit/cockpit.css +17 -0
- package/pages/cockpit/mount.js +35 -4
- package/test/agentic-e2e.test.ts +258 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// End-to-end WIRING test for the agentic visibility plane (ADR 0056) — the H6 (#149) closing slice.
|
|
2
|
+
//
|
|
3
|
+
// This is the deterministic integration test the epic asked for: it stands up a REAL AgenticHub over
|
|
4
|
+
// an in-memory transport, mounts the WHOLE family fleet through the H0 (#143) discovery SEAM
|
|
5
|
+
// (`loadAgenticFamilies` — so H1 presence, H3 relay, and H6 correlation all attach exactly as they do
|
|
6
|
+
// in production), and drives a worker + a cockpit consumer end to end:
|
|
7
|
+
//
|
|
8
|
+
// H0 the seam discovers + mounts every `*.family.ts` and tears them down in reverse;
|
|
9
|
+
// H1 a worker REGISTER creates a live presence row with its declared family + host;
|
|
10
|
+
// H6 the orchestrator links the worker's jobKey → process instance / plan;
|
|
11
|
+
// H5 GET /app/api/agentic/supply reports the worker with jobKeys populated, the drill stream
|
|
12
|
+
// repointed at `job:<jobKey>`, and the job's correlation (process instance / plan);
|
|
13
|
+
// H3 the worker relays terminal output on the jobKey-scoped stream and a cockpit TerminalSession
|
|
14
|
+
// drills in and reads it — then, across a HUB RESTART (ring lost, db shared), the same session
|
|
15
|
+
// resumes-from-offset: it re-attaches and receives only the un-applied tail, with no loss and
|
|
16
|
+
// no duplication.
|
|
17
|
+
//
|
|
18
|
+
// It is timer-free and deterministic: connections are in-memory, `flush` yields to the next
|
|
19
|
+
// event-loop turn via `setImmediate` (so all pending microtasks settle before it resolves), and
|
|
20
|
+
// every assertion is on settled state. There are no retries and no sleeps.
|
|
21
|
+
import { DatabaseSync } from "node:sqlite";
|
|
22
|
+
import { test } from "node:test";
|
|
23
|
+
import { AgenticHub } from "@nanobpm/agentic/channel";
|
|
24
|
+
import type { Authenticator, ChannelConnection, ChannelTransport } from "@nanobpm/agentic/channel";
|
|
25
|
+
import { TerminalSession } from "@nanobpm/agentic/cockpit";
|
|
26
|
+
import type { SqliteDb } from "@nanobpm/agentic/presence";
|
|
27
|
+
import { decodeFrame, encodeFrame, type Frame } from "@nanobpm/agentic/protocol";
|
|
28
|
+
import type { AppApi, DataLayer } from "@nanobpm/urban";
|
|
29
|
+
import { assert, assertEquals } from "#test-assert";
|
|
30
|
+
import { currentCorrelation } from "../app/agentic/correlation.ts";
|
|
31
|
+
import { loadAgenticFamilies } from "../app/agentic/loader.ts";
|
|
32
|
+
import { type AgenticContext, AgenticFamilyRegistry } from "../app/agentic/registry.ts";
|
|
33
|
+
import handler from "../operations/getAgenticSupply.ts";
|
|
34
|
+
import { noopLog } from "./log.ts";
|
|
35
|
+
|
|
36
|
+
// ── in-memory substrate ──────────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/** One shared in-memory SQLite db — the durable substrate that survives a hub restart. */
|
|
39
|
+
function memSqlite(): SqliteDb {
|
|
40
|
+
const db = new DatabaseSync(":memory:");
|
|
41
|
+
return {
|
|
42
|
+
exec: (sql) => db.exec(sql),
|
|
43
|
+
run: (sql, params = []) => {
|
|
44
|
+
const r = db.prepare(sql).run(...(params as never[]));
|
|
45
|
+
return { changes: Number(r.changes), lastInsertRowid: Number(r.lastInsertRowid) };
|
|
46
|
+
},
|
|
47
|
+
all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []) => db.prepare(sql).all(...(params as never[])) as T[],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function memData(db: SqliteDb): DataLayer {
|
|
52
|
+
return { source: () => ({ db }) } as unknown as DataLayer;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** An in-memory transport whose `connect` hands the hub a fresh connection. */
|
|
56
|
+
function memTransport(): { transport: ChannelTransport; connect(conn: ChannelConnection): void } {
|
|
57
|
+
let onConnection: ((conn: ChannelConnection) => void) | undefined;
|
|
58
|
+
const transport: ChannelTransport = {
|
|
59
|
+
onConnection: (l) => {
|
|
60
|
+
onConnection = l;
|
|
61
|
+
},
|
|
62
|
+
address: { port: 0 },
|
|
63
|
+
close: async () => {},
|
|
64
|
+
};
|
|
65
|
+
return { transport, connect: (conn) => onConnection?.(conn) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A live in-memory connection: `feed` delivers a frame TO the hub; `sent` collects frames FROM it.
|
|
70
|
+
* `close()` fires the hub's registered close listener, so teardown/presence-cleanup paths run as in
|
|
71
|
+
* production (rather than silently swallowing the disconnect).
|
|
72
|
+
*/
|
|
73
|
+
function conn(id: string, identity: string): { conn: ChannelConnection; feed(frame: Frame): void; sent: Frame[] } {
|
|
74
|
+
let onMessage: ((bytes: Uint8Array) => void) | undefined;
|
|
75
|
+
let onClose: ((code?: number, reason?: string) => void) | undefined;
|
|
76
|
+
const sent: Frame[] = [];
|
|
77
|
+
const channelConn: ChannelConnection = {
|
|
78
|
+
id,
|
|
79
|
+
handshake: { query: { identity }, token: "t", credential: "c" },
|
|
80
|
+
send: (bytes) => sent.push(decodeFrame(bytes)),
|
|
81
|
+
close: (code, reason) => onClose?.(code, reason),
|
|
82
|
+
onMessage: (l) => {
|
|
83
|
+
onMessage = l;
|
|
84
|
+
},
|
|
85
|
+
onClose: (l) => {
|
|
86
|
+
onClose = l;
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
return { conn: channelConn, feed: (frame) => onMessage?.(encodeFrame(frame)), sent };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const authenticator: Authenticator = (req) => ({ ok: true, grant: { identity: req.query?.identity ?? "anon" } });
|
|
93
|
+
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
|
94
|
+
|
|
95
|
+
// ── relay wire helpers (the S5 sub-protocol, mirrored from relay.family.test.ts) ──────────────────
|
|
96
|
+
|
|
97
|
+
const RELAY_FAMILY = "relay";
|
|
98
|
+
const produce = (stream: string, incarnation: number, chunk: string): Frame => ({
|
|
99
|
+
lane: "bulk",
|
|
100
|
+
family: RELAY_FAMILY,
|
|
101
|
+
seq: 0,
|
|
102
|
+
payload: { op: "produce", stream, incarnation, chunk },
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ── the harness: a real hub with the whole family fleet mounted through the H0 seam ───────────────
|
|
106
|
+
|
|
107
|
+
interface MountedHub {
|
|
108
|
+
hub: AgenticHub;
|
|
109
|
+
transport: { transport: ChannelTransport; connect(c: ChannelConnection): void };
|
|
110
|
+
names: string[];
|
|
111
|
+
teardown(): Promise<void>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function mountFleet(db: SqliteDb): Promise<MountedHub> {
|
|
115
|
+
const transport = memTransport();
|
|
116
|
+
const hub = new AgenticHub({ transport: transport.transport, authenticator, sweepIntervalMs: 0 });
|
|
117
|
+
// H0: discover + mount the WHOLE fleet through the SAME registry seam production boot uses
|
|
118
|
+
// (`mountAgenticChannel`) — so idempotent mountAll, partial-mount cleanup, and reverse/isolated
|
|
119
|
+
// teardown are all exercised here exactly as in production rather than reimplemented by hand.
|
|
120
|
+
const registry = new AgenticFamilyRegistry();
|
|
121
|
+
registry.registerAll(await loadAgenticFamilies(undefined, noopLog()));
|
|
122
|
+
const ctx: AgenticContext = {
|
|
123
|
+
hub,
|
|
124
|
+
registry: hub.registry,
|
|
125
|
+
transport: transport.transport as never,
|
|
126
|
+
data: memData(db),
|
|
127
|
+
log: noopLog(),
|
|
128
|
+
};
|
|
129
|
+
await registry.mountAll(ctx);
|
|
130
|
+
return {
|
|
131
|
+
hub,
|
|
132
|
+
transport,
|
|
133
|
+
names: registry.names(),
|
|
134
|
+
teardown: async () => {
|
|
135
|
+
await registry.teardownAll(noopLog());
|
|
136
|
+
await hub.close();
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const app = { log: noopLog() } as unknown as AppApi;
|
|
142
|
+
function supplyInput() {
|
|
143
|
+
return { req: { method: "GET", path: "/app/api/agentic/supply", query: new URLSearchParams(), headers: new Headers(), text: async () => "" } as never, params: {}, query: {}, body: undefined };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
test("E2E: the whole visibility plane wires up — presence, correlation, supply report, relay drill, and resume across a hub restart", async () => {
|
|
147
|
+
const db = memSqlite();
|
|
148
|
+
const JOB = "6494";
|
|
149
|
+
const STREAM = `job:${JOB}`;
|
|
150
|
+
|
|
151
|
+
// Sanity: the fleet the seam discovers really includes presence, relay, and correlation.
|
|
152
|
+
const fleet = await mountFleet(db);
|
|
153
|
+
const names = fleet.names;
|
|
154
|
+
assert(names.includes("presence"), "H1 presence family is discovered by the H0 seam");
|
|
155
|
+
assert(names.includes("relay"), "H3 relay family is discovered by the H0 seam");
|
|
156
|
+
assert(names.includes("correlation"), "H6 correlation family is discovered by the H0 seam");
|
|
157
|
+
|
|
158
|
+
// ── H1: a worker connects and REGISTERs; a live presence row appears with its family/host. ──
|
|
159
|
+
const worker = conn("wk-conn-1", "leafA");
|
|
160
|
+
fleet.transport.connect(worker.conn);
|
|
161
|
+
await flush();
|
|
162
|
+
worker.feed({ lane: "control", family: "register", seq: 1, payload: { instance: "wk-a", capability: { family: "opus", host: "boxA" } } });
|
|
163
|
+
await flush();
|
|
164
|
+
|
|
165
|
+
// ── H6: the orchestrator links the worker's active jobKey to its process instance / plan. ──
|
|
166
|
+
const correlation = currentCorrelation();
|
|
167
|
+
assert(correlation !== undefined, "the correlation family installed the singleton");
|
|
168
|
+
correlation.link("wk-a", JOB, { processInstanceKey: "4612", bpmnProcessId: "plan-fanout", elementId: "implement-task", planKey: "nanobpm/nano-workforce#142" });
|
|
169
|
+
|
|
170
|
+
// ── H5: the supply report shows the worker with jobKeys, the repointed stream, and the correlation. ──
|
|
171
|
+
{
|
|
172
|
+
const res = (await handler(supplyInput(), app)) as {
|
|
173
|
+
status: number;
|
|
174
|
+
body: { count: number; workers: Array<Record<string, unknown>>; correlations: Array<Record<string, unknown>> };
|
|
175
|
+
};
|
|
176
|
+
assertEquals(res.status, 200);
|
|
177
|
+
assertEquals(res.body.count, 1);
|
|
178
|
+
const w = res.body.workers[0];
|
|
179
|
+
assertEquals(w.instance, "wk-a");
|
|
180
|
+
assertEquals(w.family, "opus");
|
|
181
|
+
assertEquals(w.host, "boxA");
|
|
182
|
+
assertEquals(w.jobKeys, [JOB], "H1×H6: the correlation registry feeds the presence jobKeys seam");
|
|
183
|
+
assertEquals(w.stream, STREAM, "H6: the drill stream repoints at the live job's stream");
|
|
184
|
+
assertEquals(res.body.correlations.length, 1);
|
|
185
|
+
const c = res.body.correlations[0];
|
|
186
|
+
assertEquals(c.jobKey, JOB);
|
|
187
|
+
assertEquals(c.stream, STREAM);
|
|
188
|
+
assertEquals(c.processInstanceKey, "4612");
|
|
189
|
+
assertEquals(c.bpmnProcessId, "plan-fanout");
|
|
190
|
+
assertEquals(c.planKey, "nanobpm/nano-workforce#142");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── H3: the worker relays 3 chunks; a cockpit TerminalSession drills in and reads them all. ──
|
|
194
|
+
const written: string[] = [];
|
|
195
|
+
const sink = { write: (chunk: string) => written.push(chunk) };
|
|
196
|
+
|
|
197
|
+
// Wire a cockpit consumer connection through the hub. The TerminalSession speaks the S5 sub-protocol
|
|
198
|
+
// (RelayOutbound/RelayInbound); we wrap outbound as control frames to the hub and unwrap the frames
|
|
199
|
+
// the hub sends back to it into the session — exactly what RelayChannelClient does in the browser.
|
|
200
|
+
let cockpit = conn("cockpit-conn-1", "leafOps");
|
|
201
|
+
fleet.transport.connect(cockpit.conn);
|
|
202
|
+
await flush();
|
|
203
|
+
const session = new TerminalSession({
|
|
204
|
+
stream: STREAM,
|
|
205
|
+
sink,
|
|
206
|
+
send: (message) => cockpit.feed({ lane: "control", family: RELAY_FAMILY, seq: 0, payload: message }),
|
|
207
|
+
credit: 1024,
|
|
208
|
+
});
|
|
209
|
+
const drainToSession = async (c: { sent: Frame[] }) => {
|
|
210
|
+
await flush();
|
|
211
|
+
while (c.sent.length > 0) {
|
|
212
|
+
const frame = c.sent.shift();
|
|
213
|
+
if (frame) session.handle(frame.payload as never);
|
|
214
|
+
}
|
|
215
|
+
await flush();
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
worker.feed(produce(STREAM, 1, "c0"));
|
|
219
|
+
worker.feed(produce(STREAM, 1, "c1"));
|
|
220
|
+
worker.feed(produce(STREAM, 1, "c2"));
|
|
221
|
+
await flush();
|
|
222
|
+
session.attach();
|
|
223
|
+
await drainToSession(cockpit);
|
|
224
|
+
assertEquals(written, ["c0", "c1", "c2"], "the consumer receives every relayed chunk in order");
|
|
225
|
+
assertEquals(session.nextOffset, 3, "the session's resume point advanced past the applied tail");
|
|
226
|
+
|
|
227
|
+
// ── H3 resume-from-offset ACROSS A HUB RESTART: ring is lost, the shared db persists. ──
|
|
228
|
+
await fleet.teardown();
|
|
229
|
+
|
|
230
|
+
const fleet2 = await mountFleet(db);
|
|
231
|
+
// Re-link the correlation on the fresh process (the resumed orchestrator re-establishes it).
|
|
232
|
+
currentCorrelation()?.link("wk-a", JOB, { processInstanceKey: "4612", bpmnProcessId: "plan-fanout" });
|
|
233
|
+
|
|
234
|
+
// The worker reconnects and replays its transcript (c0..c2) plus TWO NEW chunks (c3, c4) on a bumped
|
|
235
|
+
// incarnation — a fresh ring, offsets restart at 0.
|
|
236
|
+
const worker2 = conn("wk-conn-2", "leafA");
|
|
237
|
+
fleet2.transport.connect(worker2.conn);
|
|
238
|
+
await flush();
|
|
239
|
+
worker2.feed({ lane: "control", family: "register", seq: 1, payload: { instance: "wk-a", capability: { family: "opus", host: "boxA" } } });
|
|
240
|
+
for (let i = 0; i < 5; i++) worker2.feed(produce(STREAM, 2, `c${i}`));
|
|
241
|
+
await flush();
|
|
242
|
+
|
|
243
|
+
// The SAME cockpit session reconnects (new hub connection) and re-attaches. Because it resumes from
|
|
244
|
+
// its own nextOffset (3), it receives ONLY the un-applied tail c3,c4 — no loss, no duplicate replay.
|
|
245
|
+
cockpit = conn("cockpit-conn-2", "leafOps");
|
|
246
|
+
fleet2.transport.connect(cockpit.conn);
|
|
247
|
+
await flush();
|
|
248
|
+
session.attach();
|
|
249
|
+
await drainToSession(cockpit);
|
|
250
|
+
|
|
251
|
+
assertEquals(written, ["c0", "c1", "c2", "c3", "c4"], "resume-from-offset delivers only the new tail — no loss, no duplication");
|
|
252
|
+
|
|
253
|
+
// Belt-and-braces: every delivered data frame after resume was at offset ≥ 3 (nothing below the
|
|
254
|
+
// resume point was re-applied).
|
|
255
|
+
assert(session.nextOffset >= 5, "the resume point advanced through the replayed tail");
|
|
256
|
+
|
|
257
|
+
await fleet2.teardown();
|
|
258
|
+
});
|