@nanobpm/nano-workforce 0.55.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ });