@nanobpm/nano-workforce 0.52.0 → 0.53.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 CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.53.0](https://github.com/nanobpm/nano-workforce/compare/v0.52.0...v0.53.0) (2026-08-13)
2
+
3
+
4
+ ### Features
5
+
6
+ * relay ring + transcript store agentic family (H3) ([#162](https://github.com/nanobpm/nano-workforce/issues/162)) ([f629526](https://github.com/nanobpm/nano-workforce/commit/f62952698fa3cfb3321e60fdce61fdb1f13ca9d6)), closes [#142](https://github.com/nanobpm/nano-workforce/issues/142) [#143](https://github.com/nanobpm/nano-workforce/issues/143) [#146](https://github.com/nanobpm/nano-workforce/issues/146) [#streams](https://github.com/nanobpm/nano-workforce/issues/streams)
7
+
1
8
  # [0.52.0](https://github.com/nanobpm/nano-workforce/compare/v0.51.0...v0.52.0) (2026-08-13)
2
9
 
3
10
 
@@ -0,0 +1,402 @@
1
+ // Unit tests for the H3 relay ring + transcript store family (ADR 0056, #146).
2
+ //
3
+ // Exercises the acceptance surface of the mounted family through {@link RelayTranscriptService}:
4
+ // - ring resume: a late/reconnecting consumer replays from an offset with no loss or duplication;
5
+ // - lane priority: a bulk-output storm never head-of-line-blocks a control-lane frame;
6
+ // - retention-by-lifecycle: an ephemeral stream's transcript is persisted on completion (and swept
7
+ // after retention); a long-lived stream is checkpointed and stays reattachable, never auto-completed;
8
+ // - disconnect-driven completion: an ephemeral stream flushes when its producer connection drops.
9
+ // Plus a drift guard proving `db/migrations/024_agentic_transcript.sql` mirrors the package's canonical
10
+ // transcript DDL byte-for-byte.
11
+ import { readFile } from "node:fs/promises";
12
+ import { dirname, join } from "node:path";
13
+ import { DatabaseSync } from "node:sqlite";
14
+ import { test } from "node:test";
15
+ import { fileURLToPath } from "node:url";
16
+ import { ConnectionRegistry } from "@nanobpm/agentic/channel";
17
+ import type { Frame } from "@nanobpm/agentic/protocol";
18
+ import { RELAY_FAMILY } from "@nanobpm/agentic/relay";
19
+ import { type SqliteDb, TRANSCRIPT_SCHEMA_SQL } from "@nanobpm/agentic/transcript";
20
+ import { assert, assertEquals } from "#test-assert";
21
+ import { noopLog } from "../../../test/log.ts";
22
+ import {
23
+ createRelayFamily,
24
+ family as relayFamily,
25
+ RELAY_FAMILY_NAME,
26
+ RelayTranscriptService,
27
+ } from "./relay.family.ts";
28
+
29
+ const HERE = dirname(fileURLToPath(import.meta.url));
30
+
31
+ /** An in-memory {@link SqliteDb} over `node:sqlite`, matching the store's exec/run/all surface. */
32
+ function memoryDb(): SqliteDb {
33
+ const raw = new DatabaseSync(":memory:");
34
+ return {
35
+ exec: (sql) => raw.exec(sql),
36
+ run: (sql, params = []) => raw.prepare(sql).run(...params),
37
+ all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []): T[] =>
38
+ raw.prepare(sql).all(...params) as T[],
39
+ };
40
+ }
41
+
42
+ /** An in-memory {@link SqliteDb} whose exec/run/all can be flipped to throw, to exercise advisory resilience. */
43
+ function flakyDb(): { db: SqliteDb; fail: (on: boolean) => void } {
44
+ const raw = new DatabaseSync(":memory:");
45
+ let failing = false;
46
+ const guard = <T>(fn: () => T): T => {
47
+ if (failing) throw new Error("sqlite unavailable");
48
+ return fn();
49
+ };
50
+ return {
51
+ db: {
52
+ exec: (sql) => guard(() => raw.exec(sql)),
53
+ run: (sql, params = []) => guard(() => raw.prepare(sql).run(...params)),
54
+ all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []): T[] =>
55
+ guard(() => raw.prepare(sql).all(...params) as T[]),
56
+ },
57
+ fail: (on: boolean) => {
58
+ failing = on;
59
+ },
60
+ };
61
+ }
62
+
63
+ /** A hub double that just captures the family handler so the test can drive frames directly. */
64
+ interface CapturingHub {
65
+ handler?: (frame: Frame, conn: RelayConn) => void;
66
+ registerFamilyHandler(family: string, handler: (frame: Frame, conn: RelayConn) => void): void;
67
+ }
68
+
69
+ interface RelayConn {
70
+ readonly id: string;
71
+ readonly registry: { has(id: string): boolean };
72
+ send(frame: Frame): void;
73
+ }
74
+
75
+ function capturingHub(): CapturingHub {
76
+ return {
77
+ registerFamilyHandler(_family, handler) {
78
+ this.handler = handler;
79
+ },
80
+ };
81
+ }
82
+
83
+ /** A live fake connection registered in `registry`, collecting frames the hub sends back to it. */
84
+ function connect(id: string, registry: ConnectionRegistry): { conn: RelayConn; sent: Frame[] } {
85
+ registry.add(id, `identity:${id}`);
86
+ const sent: Frame[] = [];
87
+ return { conn: { id, registry, send: (f) => sent.push(f) }, sent };
88
+ }
89
+
90
+ const produce = (stream: string, incarnation: number, chunk: string): Frame => ({
91
+ lane: "bulk",
92
+ family: RELAY_FAMILY,
93
+ seq: 0,
94
+ payload: { op: "produce", stream, incarnation, chunk },
95
+ });
96
+ const subscribe = (stream: string, from: number, credit: number): Frame => ({
97
+ lane: "control",
98
+ family: RELAY_FAMILY,
99
+ seq: 0,
100
+ payload: { op: "subscribe", stream, from, credit },
101
+ });
102
+ const grant = (credit: number): Frame => ({
103
+ lane: "control",
104
+ family: RELAY_FAMILY,
105
+ seq: 0,
106
+ payload: { op: "credit", credit },
107
+ });
108
+
109
+ /** Read the `op` marker off a delivered frame payload without an unsafe cast. */
110
+ function payloadOp(frame: Frame): unknown {
111
+ const p = frame.payload;
112
+ return p && typeof p === "object" && Object.hasOwn(p, "op")
113
+ ? Object.getOwnPropertyDescriptor(p, "op")?.value
114
+ : undefined;
115
+ }
116
+ function payloadField(frame: Frame, key: string): unknown {
117
+ const p = frame.payload;
118
+ return p && typeof p === "object" && Object.hasOwn(p, key)
119
+ ? Object.getOwnPropertyDescriptor(p, key)?.value
120
+ : undefined;
121
+ }
122
+
123
+ function mkService(registry: ConnectionRegistry, db: SqliteDb | undefined): {
124
+ service: RelayTranscriptService;
125
+ hub: CapturingHub;
126
+ } {
127
+ const hub = capturingHub();
128
+ const service = new RelayTranscriptService({ hub, registry, db, log: noopLog() });
129
+ return { service, hub };
130
+ }
131
+
132
+ test("the family exports a valid AgenticFamily named 'relay'", () => {
133
+ assertEquals(relayFamily.name, RELAY_FAMILY_NAME);
134
+ assertEquals(relayFamily.name, "relay");
135
+ assertEquals(typeof relayFamily.mount, "function");
136
+ assertEquals(typeof relayFamily.teardown, "function");
137
+ // createRelayFamily builds an independent instance with the same contract.
138
+ const another = createRelayFamily();
139
+ assertEquals(another.name, "relay");
140
+ });
141
+
142
+ test("ring resume: a late consumer replays from an offset with no loss or duplication", () => {
143
+ const registry = new ConnectionRegistry();
144
+ const { service, hub } = mkService(registry, memoryDb());
145
+ const p = connect("prod", registry);
146
+ for (let i = 0; i < 5; i++) hub.handler?.(produce("s", 1, `c${i}`), p.conn);
147
+
148
+ // A late consumer resumes from offset 2 with ample credit → gets exactly offsets 2,3,4 in order.
149
+ const late = connect("late", registry);
150
+ hub.handler?.(subscribe("s", 2, 100), late.conn);
151
+
152
+ const acks = late.sent.filter((f) => payloadOp(f) === "subscribed");
153
+ assertEquals(acks.length, 1);
154
+ assertEquals(payloadField(acks[0], "gap"), false);
155
+ assertEquals(payloadField(acks[0], "nextOffset"), 5);
156
+
157
+ const data = late.sent.filter((f) => payloadOp(f) === undefined); // data frames carry {stream,offset,chunk}
158
+ assertEquals(
159
+ data.map((f) => payloadField(f, "offset")),
160
+ [2, 3, 4],
161
+ );
162
+ assertEquals(
163
+ data.map((f) => payloadField(f, "chunk")),
164
+ ["c2", "c3", "c4"],
165
+ );
166
+
167
+ // A reconnect from 0 gets the whole retained window — still gap-free, no duplication.
168
+ const full = connect("full", registry);
169
+ hub.handler?.(subscribe("s", 0, 100), full.conn);
170
+ const fullData = full.sent.filter((f) => payloadOp(f) === undefined);
171
+ assertEquals(
172
+ fullData.map((f) => payloadField(f, "offset")),
173
+ [0, 1, 2, 3, 4],
174
+ );
175
+ service.teardown();
176
+ });
177
+
178
+ test("lane priority: a bulk storm never head-of-line-blocks a control frame", () => {
179
+ const registry = new ConnectionRegistry();
180
+ const { service, hub } = mkService(registry, memoryDb());
181
+ const p = connect("prod", registry);
182
+
183
+ // Consumer subscribes to stream A with ZERO bulk credit: it gets the control ack but no bulk.
184
+ const c = connect("cons", registry);
185
+ hub.handler?.(subscribe("A", 0, 0), c.conn);
186
+ assertEquals(c.sent.filter((f) => payloadOp(f) === "subscribed").length, 1);
187
+
188
+ // A bulk-output storm on A: every produce enqueues a bulk data frame, all credit-gated (buffered).
189
+ for (let i = 0; i < 200; i++) hub.handler?.(produce("A", 1, `x${i}`), p.conn);
190
+ const bulkBefore = c.sent.filter((f) => payloadOp(f) === undefined).length;
191
+ assertEquals(bulkBefore, 0, "bulk must stay buffered with zero credit — never force-flushed");
192
+
193
+ // A control-lane heartbeat (a second subscribe) MUST get through despite the buffered bulk backlog.
194
+ hub.handler?.(subscribe("B", 0, 0), c.conn);
195
+ assertEquals(
196
+ c.sent.filter((f) => payloadOp(f) === "subscribed").length,
197
+ 2,
198
+ "control ack delivered ahead of the bulk backlog — control is never starved",
199
+ );
200
+ assertEquals(c.sent.filter((f) => payloadOp(f) === undefined).length, 0);
201
+
202
+ // Granting credit now releases the buffered bulk — nothing was lost, order preserved.
203
+ hub.handler?.(grant(300), c.conn);
204
+ const released = c.sent.filter((f) => payloadOp(f) === undefined);
205
+ assertEquals(released.length, 200);
206
+ assertEquals(payloadField(released[0], "chunk"), "x0");
207
+ assertEquals(payloadField(released[199], "chunk"), "x199");
208
+ service.teardown();
209
+ });
210
+
211
+ test("retention: an ephemeral stream's transcript is persisted on completion, then swept", () => {
212
+ const registry = new ConnectionRegistry();
213
+ const db = memoryDb();
214
+ const clock = { t: 1_000_000 };
215
+ const hub = capturingHub();
216
+ const service = new RelayTranscriptService({
217
+ hub,
218
+ registry,
219
+ db,
220
+ log: noopLog(),
221
+ transcript: { ephemeralRetentionMs: 1000, clock: { now: () => clock.t } },
222
+ });
223
+ const p = connect("prod", registry);
224
+ for (let i = 0; i < 3; i++) hub.handler?.(produce("job-1", 1, `l${i}`), p.conn);
225
+
226
+ const flushed = service.completeStream("job-1");
227
+ assertEquals(flushed, 3);
228
+ const meta = service.transcriptOf("job-1");
229
+ assertEquals(meta?.lifecycle, "ephemeral");
230
+ assertEquals(meta?.status, "completed");
231
+ assertEquals(service.reattach("job-1", 0)?.entries.length, 3);
232
+
233
+ // Before the retention window elapses the sweep keeps it; after, it retires the transcript.
234
+ clock.t += 500;
235
+ assertEquals(service.sweep(), []);
236
+ clock.t += 1000;
237
+ assertEquals(service.sweep(), ["job-1"]);
238
+ assertEquals(service.transcriptOf("job-1"), undefined);
239
+ assert(!service.streams().includes("job-1"), "sweep forgets retired stream state — map stays bounded");
240
+ service.teardown();
241
+ });
242
+
243
+ test("retention: a disconnected producer auto-completes its ephemeral stream on the next frame", () => {
244
+ const registry = new ConnectionRegistry();
245
+ const db = memoryDb();
246
+ const { service, hub } = mkService(registry, db);
247
+ const p = connect("prod", registry);
248
+ for (let i = 0; i < 2; i++) hub.handler?.(produce("job-2", 1, `m${i}`), p.conn);
249
+ assertEquals(service.transcriptOf("job-2"), undefined, "not yet flushed while producer is live");
250
+
251
+ // Producer drops (S1 registry removed it on close/timeout). A subsequent inbound frame from any
252
+ // live connection reconciles the dead producer and flushes+completes its ephemeral stream.
253
+ registry.remove("prod");
254
+ const other = connect("cons", registry);
255
+ hub.handler?.(grant(0), other.conn); // any frame drives #reconcile
256
+
257
+ const meta = service.transcriptOf("job-2");
258
+ assertEquals(meta?.status, "completed");
259
+ assertEquals(service.reattach("job-2", 0)?.entries.length, 2);
260
+ service.teardown();
261
+ });
262
+
263
+ test("retention: a long-lived stream is checkpointed + reattachable and never auto-completed", () => {
264
+ const registry = new ConnectionRegistry();
265
+ const db = memoryDb();
266
+ const { service, hub } = mkService(registry, db);
267
+ service.declareLifecycle("ctrl", "long-lived");
268
+ const p = connect("prod", registry);
269
+ for (let i = 0; i < 4; i++) hub.handler?.(produce("ctrl", 1, `k${i}`), p.conn);
270
+
271
+ const n = service.checkpointStream("ctrl");
272
+ assertEquals(n, 4);
273
+ assertEquals(service.transcriptOf("ctrl")?.status, "open");
274
+ assertEquals(service.reattach("ctrl", 2)?.entries.map((e) => e.chunk), ["k2", "k3"]);
275
+
276
+ // Producer drop must NOT complete a long-lived stream — it stays open for reattach.
277
+ registry.remove("prod");
278
+ const other = connect("cons", registry);
279
+ hub.handler?.(grant(0), other.conn);
280
+ assertEquals(service.transcriptOf("ctrl")?.status, "open");
281
+
282
+ // The retention sweep never time-retires an open long-lived stream.
283
+ assertEquals(service.sweep(2_000_000_000_000), []);
284
+ assertEquals(service.transcriptOf("ctrl")?.status, "open");
285
+ service.teardown();
286
+ });
287
+
288
+ test("teardown flushes still-open ephemeral streams so nothing in-flight is lost", () => {
289
+ const registry = new ConnectionRegistry();
290
+ const db = memoryDb();
291
+ const { service, hub } = mkService(registry, db);
292
+ const p = connect("prod", registry);
293
+ hub.handler?.(produce("open-job", 1, "z0"), p.conn);
294
+ assertEquals(service.transcriptOf("open-job"), undefined);
295
+
296
+ service.teardown();
297
+ assertEquals(service.transcriptOf("open-job")?.status, "completed");
298
+ });
299
+
300
+ test("advisory mode: with no DataLayer the relay still replays; persistence is a no-op", () => {
301
+ const registry = new ConnectionRegistry();
302
+ const { service, hub } = mkService(registry, undefined);
303
+ const p = connect("prod", registry);
304
+ for (let i = 0; i < 3; i++) hub.handler?.(produce("s", 1, `n${i}`), p.conn);
305
+
306
+ const c = connect("cons", registry);
307
+ hub.handler?.(subscribe("s", 0, 100), c.conn);
308
+ const data = c.sent.filter((f) => payloadOp(f) === undefined);
309
+ assertEquals(data.length, 3, "relay replay works without a store — advisory-correct");
310
+
311
+ assertEquals(service.completeStream("s"), 0);
312
+ assertEquals(service.reattach("s", 0), undefined);
313
+ assertEquals(service.sweep(), []);
314
+ service.teardown();
315
+ });
316
+
317
+ test("incarnation fencing: a stale producer cannot overwrite a newer incarnation's stream", () => {
318
+ const registry = new ConnectionRegistry();
319
+ const { service, hub } = mkService(registry, memoryDb());
320
+ const p = connect("prod", registry);
321
+ hub.handler?.(produce("s", 2, "new-a"), p.conn); // incarnation 2 establishes the mark
322
+ hub.handler?.(produce("s", 1, "stale"), p.conn); // incarnation 1 is fenced (dropped)
323
+ hub.handler?.(produce("s", 2, "new-b"), p.conn);
324
+
325
+ const c = connect("cons", registry);
326
+ hub.handler?.(subscribe("s", 0, 100), c.conn);
327
+ const chunks = c.sent.filter((f) => payloadOp(f) === undefined).map((f) => payloadField(f, "chunk"));
328
+ assertEquals(chunks, ["new-a", "new-b"], "the stale incarnation's chunk never entered the ring");
329
+ service.teardown();
330
+ });
331
+
332
+ test("advisory mode: a store that fails to initialize falls back to unpersisted — mount never throws", () => {
333
+ const registry = new ConnectionRegistry();
334
+ const { db, fail } = flakyDb();
335
+ fail(true); // schema application throws during construction
336
+ const hub = capturingHub();
337
+ const service = new RelayTranscriptService({ hub, registry, db, log: noopLog() });
338
+ assertEquals(service.store, undefined, "store setup failure falls back to unpersisted, not a thrown mount");
339
+
340
+ // The relay still replays — advisory-correct even with no store.
341
+ const p = connect("prod", registry);
342
+ for (let i = 0; i < 3; i++) hub.handler?.(produce("s", 1, `n${i}`), p.conn);
343
+ const c = connect("cons", registry);
344
+ hub.handler?.(subscribe("s", 0, 100), c.conn);
345
+ assertEquals(c.sent.filter((f) => payloadOp(f) === undefined).length, 3);
346
+ assertEquals(service.completeStream("s"), 0);
347
+ service.teardown();
348
+ });
349
+
350
+ test("advisory resilience: a flush failure leaves the ephemeral stream uncompleted and never bubbles", () => {
351
+ const registry = new ConnectionRegistry();
352
+ const { db, fail } = flakyDb();
353
+ const hub = capturingHub();
354
+ const service = new RelayTranscriptService({ hub, registry, db, log: noopLog() });
355
+ const p = connect("prod", registry);
356
+ for (let i = 0; i < 2; i++) hub.handler?.(produce("job", 1, `c${i}`), p.conn);
357
+
358
+ fail(true);
359
+ assertEquals(service.completeStream("job"), 0, "flush failure is swallowed and returns 0");
360
+
361
+ // Left uncompleted: once the store recovers, a later completion flushes the whole window.
362
+ fail(false);
363
+ assertEquals(service.completeStream("job"), 2);
364
+ assertEquals(service.transcriptOf("job")?.status, "completed");
365
+ service.teardown();
366
+ });
367
+
368
+ test("advisory resilience: a checkpoint flush failure keeps the long-lived stream open and returns 0", () => {
369
+ const registry = new ConnectionRegistry();
370
+ const { db, fail } = flakyDb();
371
+ const hub = capturingHub();
372
+ const service = new RelayTranscriptService({ hub, registry, db, log: noopLog() });
373
+ service.declareLifecycle("ctrl", "long-lived");
374
+ const p = connect("prod", registry);
375
+ for (let i = 0; i < 3; i++) hub.handler?.(produce("ctrl", 1, `k${i}`), p.conn);
376
+
377
+ fail(true);
378
+ assertEquals(service.checkpointStream("ctrl"), 0, "checkpoint failure is swallowed and returns 0");
379
+
380
+ fail(false);
381
+ assertEquals(service.checkpointStream("ctrl"), 3);
382
+ assertEquals(service.transcriptOf("ctrl")?.status, "open");
383
+ service.teardown();
384
+ });
385
+
386
+ test("drift guard: migration 024 mirrors the canonical transcript DDL byte-for-byte", async () => {
387
+ const migrationPath = join(HERE, "..", "..", "..", "db", "migrations", "024_agentic_transcript.sql");
388
+ const raw = await readFile(migrationPath, "utf8");
389
+ // Strip `-- …` comment lines; the DDL is the remaining statements.
390
+ const ddl = raw
391
+ .split("\n")
392
+ .filter((line) => !line.trimStart().startsWith("--"))
393
+ .join("\n");
394
+ const normalise = (s: string) => s.trim().replace(/\s+/g, " ");
395
+ assertEquals(
396
+ normalise(ddl),
397
+ normalise(TRANSCRIPT_SCHEMA_SQL),
398
+ "024_agentic_transcript.sql drifted from @nanobpm/agentic/transcript TRANSCRIPT_SCHEMA_SQL",
399
+ );
400
+ assert(ddl.includes("agentic_transcript_stream"));
401
+ assert(ddl.includes("agentic_transcript_chunk"));
402
+ });
@@ -0,0 +1,331 @@
1
+ // nano-workforce — the relay ring + transcript store agentic family (ADR 0056, H3 / #146).
2
+ //
3
+ // This is a sibling slice of the agentic-visibility epic (#142). It plugs into the H0 (#143)
4
+ // family-registration SEAM (`../registry.ts`) as ONE NEW FILE and NOTHING ELSE — it never edits
5
+ // `main.ts`, `drainAndExit`, or any shared boot line. The auto-discovery loader (`../loader.ts`)
6
+ // finds it by the `*.family.ts` suffix and the seam mounts + tears it down.
7
+ //
8
+ // It composes two published primitives — it re-implements NEITHER:
9
+ // - `@nanobpm/agentic/relay` — the bounded replay ring, three-lane QoS scheduler
10
+ // (control > interactive > bulk), resume-from-offset, and incarnation/generation fencing,
11
+ // all inside {@link RelayHub}. We mount it on the hub's `registerFamilyHandler` seam.
12
+ // - `@nanobpm/agentic/transcript` — {@link TranscriptStore}, retention-by-lifecycle over the app's
13
+ // SQLite DataLayer. Ephemeral streams flush the ring to a durable transcript on job completion;
14
+ // long-lived streams retain chunks so a reconnecting consumer resumes-from-offset (reattach).
15
+ //
16
+ // Its DB schema ships as the reserved forward-only additive migration
17
+ // `db/migrations/024_agentic_transcript.sql` (H0 pre-allocated prefix 024 for H3), a byte-for-byte
18
+ // mirror of the package's canonical `TRANSCRIPT_SCHEMA_SQL`, drift-guarded by this slice's test.
19
+ //
20
+ // Invariants (ADR 0056): app-tier only, never the engine; the Camunda-8 job protocol (worker⇄engine)
21
+ // is untouched — the agentic channel is the only new conversation; advisory semantics preserved (the
22
+ // relay/transcript never hard-lock or gate a BPMN sequence flow).
23
+ import type { ConnectionRegistry } from "@nanobpm/agentic/channel";
24
+ import type { Frame } from "@nanobpm/agentic/protocol";
25
+ import { RELAY_FAMILY, RelayHub, type RelayHubOptions } from "@nanobpm/agentic/relay";
26
+ import {
27
+ type SqliteDb,
28
+ type TranscriptLifecycle,
29
+ type TranscriptRing,
30
+ type TranscriptSlice,
31
+ TranscriptStore,
32
+ type TranscriptStoreOptions,
33
+ type TranscriptStream,
34
+ } from "@nanobpm/agentic/transcript";
35
+ import type { Logger } from "@nanobpm/urban";
36
+ import type { AgenticContext, AgenticFamily } from "../registry.ts";
37
+
38
+ /** The stable family name this slice registers under the seam (distinct from the wire family key). */
39
+ export const RELAY_FAMILY_NAME = "relay";
40
+
41
+ /** Read a property off an unknown value without an unsafe `as` cast (mirrors the loader's helper). */
42
+ function readProp(value: unknown, key: string): unknown {
43
+ if (!value || typeof value !== "object") return undefined;
44
+ return Object.hasOwn(value, key) ? Object.getOwnPropertyDescriptor(value, key)?.value : undefined;
45
+ }
46
+
47
+ /**
48
+ * A resume-from-offset source with no retained chunks — used to flush/complete a stream that a
49
+ * producer opened logically but never wrote to, so its transcript is still stamped `completed`
50
+ * rather than left dangling `open`.
51
+ */
52
+ const EMPTY_SOURCE: TranscriptRing = { since: () => ({ entries: [] }), nextOffset: 0 };
53
+
54
+ /** Per-stream bookkeeping the service keeps to drive lifecycle-aware persistence. */
55
+ interface StreamState {
56
+ /** Retention lifecycle: `ephemeral` (flush+complete on job end) vs `long-lived` (reattach). */
57
+ lifecycle: TranscriptLifecycle;
58
+ /** The connection id of the most recent producer, for disconnect-driven completion. */
59
+ producer?: string;
60
+ /** Set once an ephemeral stream has been flushed & completed (so it is not re-completed). */
61
+ completed: boolean;
62
+ }
63
+
64
+ export interface RelayTranscriptServiceOptions {
65
+ /** The app-tier hub; the service claims the `relay` family key via its registration seam. */
66
+ readonly hub: {
67
+ registerFamilyHandler(family: string, handler: (frame: Frame, conn: RelayConnectionCtx) => void): void;
68
+ };
69
+ /** The shared connection registry — its `has(id)` is the liveness source of truth. */
70
+ readonly registry: ConnectionRegistry;
71
+ /** The raw SQLite handle (the app DataLayer's `source().db`); absent → transcripts disabled. */
72
+ readonly db: SqliteDb | undefined;
73
+ /** A structured logger for lifecycle lines. */
74
+ readonly log: Logger;
75
+ /** Options forwarded to the underlying {@link RelayHub} (ring/bulk capacity, default credit). */
76
+ readonly relay?: RelayHubOptions;
77
+ /** Options forwarded to the {@link TranscriptStore} (retention window, injectable clock). */
78
+ readonly transcript?: TranscriptStoreOptions;
79
+ /**
80
+ * Apply the transcript DDL from the store on mount (idempotent `CREATE ... IF NOT EXISTS`).
81
+ * Default `true`: the boot migration is the canonical path, but this makes the service usable
82
+ * against a bare source too (and is harmless when the migration already ran).
83
+ */
84
+ readonly ensureSchema?: boolean;
85
+ }
86
+
87
+ /** The minimal per-connection surface the relay handler receives from the hub (a {@link RelayHub} `RelayConnection`). */
88
+ interface RelayConnectionCtx {
89
+ readonly id: string;
90
+ readonly registry: { has(id: string): boolean };
91
+ send(frame: Frame): void;
92
+ }
93
+
94
+ /**
95
+ * Composes the S5 relay ({@link RelayHub}) with the S6 transcript store ({@link TranscriptStore})
96
+ * and wires retention-by-lifecycle. It owns NO ring/scheduler/store logic of its own — it observes
97
+ * `produce` ownership so an ephemeral stream is flushed & completed when its producer disconnects,
98
+ * and exposes the completion/checkpoint/reattach/sweep surface H6 (#149) and the app drive.
99
+ */
100
+ export class RelayTranscriptService {
101
+ /** The mounted relay hub (ring + QoS scheduler + incarnation fence). Exposed for inspection/tests. */
102
+ readonly relay: RelayHub;
103
+ /** The transcript store, or `undefined` when no DataLayer is mounted (relay still works, unpersisted). */
104
+ readonly store: TranscriptStore | undefined;
105
+
106
+ readonly #registry: ConnectionRegistry;
107
+ readonly #log: Logger;
108
+ readonly #streams = new Map<string, StreamState>();
109
+
110
+ constructor(options: RelayTranscriptServiceOptions) {
111
+ this.#registry = options.registry;
112
+ this.#log = options.log;
113
+ // Persistence is advisory: a store that can't be constructed or whose schema can't be applied
114
+ // (locked/permission-denied/unavailable SQLite) must NOT fail the family mount — fall back to
115
+ // running the relay unpersisted rather than tearing down the whole agentic channel.
116
+ let store: TranscriptStore | undefined;
117
+ if (options.db) {
118
+ try {
119
+ store = new TranscriptStore(options.db, options.transcript);
120
+ if (options.ensureSchema !== false) store.ensureSchema();
121
+ } catch (err) {
122
+ this.#log.warn("agentic transcript store unavailable — relay runs unpersisted", {
123
+ err: String(err),
124
+ });
125
+ store = undefined;
126
+ }
127
+ }
128
+ this.store = store;
129
+
130
+ this.relay = new RelayHub({
131
+ ...options.relay,
132
+ onFenced: (stream, incarnation, current) => {
133
+ this.#log.warn("agentic relay fenced a stale producer", { stream, incarnation, current });
134
+ options.relay?.onFenced?.(stream, incarnation, current);
135
+ },
136
+ onError: (err, connectionId) => {
137
+ this.#log.warn("agentic relay message error", { connectionId, err: String(err) });
138
+ options.relay?.onError?.(err, connectionId);
139
+ },
140
+ });
141
+
142
+ // Register the `relay` family ourselves (rather than via `registerRelayFamily`) so we can observe
143
+ // `produce` ownership before delegating to the hub — the hub's own routing still derives purely
144
+ // from this single registration, and a second `relay` registration is rejected by the seam.
145
+ options.hub.registerFamilyHandler(RELAY_FAMILY, (frame, conn) => this.#onFrame(frame, conn));
146
+ }
147
+
148
+ /** The tracked stream names (those a producer opened or that were declared). */
149
+ streams(): string[] {
150
+ return [...this.#streams.keys()];
151
+ }
152
+
153
+ /**
154
+ * Declare a stream's retention lifecycle before (or independently of) its first `produce`. The
155
+ * durable store records lifecycle write-once (first call wins there); in memory the latest call
156
+ * wins until the stream completes, which is how {@link checkpointStream} upgrades an as-yet
157
+ * ephemeral stream to `long-lived`. No-op once the stream has already been completed.
158
+ */
159
+ declareLifecycle(stream: string, lifecycle: TranscriptLifecycle): void {
160
+ const state = this.#stateFor(stream);
161
+ if (!state.completed) state.lifecycle = lifecycle;
162
+ }
163
+
164
+ /**
165
+ * Complete a stream on job end: flush the relay ring (its whole retained window) to the durable
166
+ * transcript under the stream's lifecycle. For an `ephemeral` stream this stamps `completed_at`
167
+ * (so {@link reattach} then serves the durable transcript and {@link sweep} may later retire it);
168
+ * for a `long-lived` stream it is a snapshot checkpoint that leaves the stream `open`. Idempotent:
169
+ * re-completing already-persisted offsets is a no-op. Returns the number of newly-persisted chunks.
170
+ */
171
+ completeStream(stream: string): number {
172
+ if (!this.store) return 0;
173
+ const state = this.#stateFor(stream);
174
+ const source = this.relay.ring(stream) ?? EMPTY_SOURCE;
175
+ let flushed: number;
176
+ try {
177
+ flushed = this.store.flush(stream, source, state.lifecycle);
178
+ } catch (err) {
179
+ // Persistence is advisory: a flush failure must not bubble into the hub's frame handler and
180
+ // take down unrelated streams. Log and leave the stream uncompleted so a later pass retries.
181
+ this.#log.warn("agentic relay stream flush failed — leaving stream uncompleted", {
182
+ stream,
183
+ lifecycle: state.lifecycle,
184
+ err: String(err),
185
+ });
186
+ return 0;
187
+ }
188
+ if (state.lifecycle === "ephemeral") state.completed = true;
189
+ // Drop producer ownership so a later reconcile does not re-flush a completed stream.
190
+ state.producer = undefined;
191
+ this.#log.info("agentic relay stream flushed", {
192
+ stream,
193
+ lifecycle: state.lifecycle,
194
+ flushed,
195
+ completed: state.completed,
196
+ });
197
+ return flushed;
198
+ }
199
+
200
+ /**
201
+ * Snapshot a long-lived stream's ring into the durable transcript without completing it — the
202
+ * checkpoint path a growing stream uses so a reconnecting consumer can {@link reattach} past the
203
+ * ring's resume window. Returns the number of newly-persisted chunks.
204
+ */
205
+ checkpointStream(stream: string): number {
206
+ if (!this.store) return 0;
207
+ this.declareLifecycle(stream, "long-lived");
208
+ const source = this.relay.ring(stream) ?? EMPTY_SOURCE;
209
+ try {
210
+ return this.store.flush(stream, source, "long-lived");
211
+ } catch (err) {
212
+ // Advisory: a checkpoint failure keeps the relay usable — return 0, the stream stays open.
213
+ this.#log.warn("agentic relay checkpoint flush failed", { stream, err: String(err) });
214
+ return 0;
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Reattach a consumer from offset `from` (inclusive) against the durable transcript — mirrors the
220
+ * relay ring's `since` contract exactly, so a late/reconnecting consumer resumes identically
221
+ * whether from the live ring or the persisted transcript. Returns `undefined` with no store.
222
+ */
223
+ reattach(stream: string, from: number): TranscriptSlice | undefined {
224
+ return this.store?.since(stream, from);
225
+ }
226
+
227
+ /** Retention sweep: retire completed-ephemeral transcripts past the retention window. */
228
+ sweep(now?: number): string[] {
229
+ const retired = this.store?.sweep(now) ?? [];
230
+ // Forget the in-memory state of every retired stream so `#streams` stays bounded (and
231
+ // `#reconcile`'s scan stays cheap) even after many ephemeral streams complete and age out.
232
+ for (const stream of retired) this.#streams.delete(stream);
233
+ return retired;
234
+ }
235
+
236
+ /** A stream's persisted transcript metadata, if any (lifecycle/status/offset window). */
237
+ transcriptOf(stream: string): TranscriptStream | undefined {
238
+ return this.store?.get(stream);
239
+ }
240
+
241
+ /**
242
+ * Complete every still-open ephemeral stream (e.g. on shutdown) so no in-flight terminal is lost,
243
+ * then forget all tracking. Long-lived streams are left for reattach and are not force-completed.
244
+ */
245
+ teardown(): void {
246
+ for (const [stream, state] of this.#streams) {
247
+ if (state.lifecycle === "ephemeral" && !state.completed) this.completeStream(stream);
248
+ }
249
+ this.#streams.clear();
250
+ }
251
+
252
+ /** Handle one inbound `relay` frame: reconcile dead producers, observe ownership, then delegate. */
253
+ #onFrame(frame: Frame, conn: RelayConnectionCtx): void {
254
+ this.#reconcile();
255
+ this.#observe(frame, conn);
256
+ this.relay.handle(frame, conn);
257
+ }
258
+
259
+ /** Record `produce` ownership so a producer disconnect can drive ephemeral completion. */
260
+ #observe(frame: Frame, conn: RelayConnectionCtx): void {
261
+ if (readProp(frame.payload, "op") !== "produce") return;
262
+ const stream = readProp(frame.payload, "stream");
263
+ if (typeof stream !== "string" || stream === "") return;
264
+ this.#stateFor(stream).producer = conn.id;
265
+ }
266
+
267
+ /**
268
+ * Flush + complete every ephemeral stream whose producer connection is no longer live (the S1
269
+ * registry dropped it on close or liveness timeout). Lazy, like the relay hub's own dead-subscriber
270
+ * prune: it runs on each inbound frame, and shutdown covers the quiescent tail via {@link teardown}.
271
+ */
272
+ #reconcile(): void {
273
+ for (const [stream, state] of this.#streams) {
274
+ if (state.completed || state.lifecycle !== "ephemeral") continue;
275
+ if (state.producer !== undefined && !this.#registry.has(state.producer)) {
276
+ this.completeStream(stream);
277
+ }
278
+ }
279
+ }
280
+
281
+ #stateFor(stream: string): StreamState {
282
+ let state = this.#streams.get(stream);
283
+ if (state === undefined) {
284
+ state = { lifecycle: "ephemeral", completed: false };
285
+ this.#streams.set(stream, state);
286
+ }
287
+ return state;
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Build the H3 relay family. Copy-of-the-seam pattern: it constructs the {@link RelayTranscriptService}
293
+ * in `mount` (threading the seam's hub/registry/DataLayer/log) and tears it down in `teardown`. The
294
+ * created service is exposed to `onMounted` so a driver (H6 correlation, tests) can reach the
295
+ * completion/reattach surface without re-mounting anything.
296
+ */
297
+ export function createRelayFamily(options: {
298
+ readonly relay?: RelayHubOptions;
299
+ readonly transcript?: TranscriptStoreOptions;
300
+ readonly ensureSchema?: boolean;
301
+ /** Called with the live service once mounted, so a driver can drive completion/reattach. */
302
+ readonly onMounted?: (service: RelayTranscriptService) => void;
303
+ } = {}): AgenticFamily {
304
+ let service: RelayTranscriptService | undefined;
305
+ return {
306
+ name: RELAY_FAMILY_NAME,
307
+ mount(ctx: AgenticContext): void {
308
+ service = new RelayTranscriptService({
309
+ hub: ctx.hub,
310
+ registry: ctx.registry,
311
+ // The app's SQLite handle: the same store the advisory blackboard uses. Absent → relay runs
312
+ // unpersisted (still advisory-correct), rather than failing the whole channel boot.
313
+ db: ctx.data ? ctx.data.source().db : undefined,
314
+ log: ctx.log,
315
+ relay: options.relay,
316
+ transcript: options.transcript,
317
+ ensureSchema: options.ensureSchema,
318
+ });
319
+ options.onMounted?.(service);
320
+ },
321
+ teardown(): void {
322
+ service?.teardown();
323
+ service = undefined;
324
+ },
325
+ };
326
+ }
327
+
328
+ /** The discovered family instance (the loader picks up this `family` export). */
329
+ export const family: AgenticFamily = createRelayFamily();
330
+
331
+ export default family;
@@ -0,0 +1,43 @@
1
+ -- Agentic visibility plane (ADR 0056, epic #142) — H3 relay transcript store (#146).
2
+ --
3
+ -- The relay family (app/agentic/families/relay.family.ts) mounts @nanobpm/agentic/relay (a bounded
4
+ -- replay ring + three-lane QoS scheduler + incarnation fence) on the app-tier agentic channel and
5
+ -- persists terminal transcripts through @nanobpm/agentic/transcript with retention-by-lifecycle:
6
+ --
7
+ -- • ephemeral streams → the relay ring is flushed to a durable transcript on job completion
8
+ -- (and the stream marked `completed`, then retired by a retention sweep);
9
+ -- • long-lived streams → chunks are retained/checkpointed so a reconnecting consumer can resume
10
+ -- from any offset (reattach), bounded by a rolling offset window.
11
+ --
12
+ -- This DDL is the forward-only, additive boot migration the DataLayer runner applies from
13
+ -- nano.app.json (`data.sources.app.migrations`). It is a byte-for-byte mirror of the package's
14
+ -- canonical `TRANSCRIPT_SCHEMA_SQL` (@nanobpm/agentic/transcript `schema.ts`), which the store also
15
+ -- applies via `ensureSchema()`. The two application paths are kept from drifting by the drift-guard
16
+ -- test `app/agentic/families/relay.family.test.ts` — divergence is a red test, not a silent boot vs.
17
+ -- store mismatch. Additive only (CREATE ... IF NOT EXISTS): it adds no column to an existing table
18
+ -- and drops nothing, so it is safe to apply forward over any earlier schema.
19
+ --
20
+ -- H0 (#143) pre-allocated this exact prefix (024) for H3 so no two sibling slices independently grab
21
+ -- "the next" number (H1=023_agentic_presence, H4=025_agentic_blackboard). `chunk_offset` (not
22
+ -- `offset`) is deliberate: OFFSET is a SQLite keyword, so the column avoids quoting in every query.
23
+ --
24
+ -- Invariants (ADR 0056): app-tier only, never the engine; the Camunda-8 job protocol (worker⇄engine)
25
+ -- is untouched — the agentic channel is the only new conversation; advisory semantics preserved (the
26
+ -- transcript never hard-locks or gates a BPMN sequence flow).
27
+ CREATE TABLE IF NOT EXISTS agentic_transcript_stream (
28
+ stream TEXT PRIMARY KEY,
29
+ lifecycle TEXT NOT NULL,
30
+ status TEXT NOT NULL DEFAULT 'open',
31
+ created_at TEXT NOT NULL,
32
+ completed_at TEXT,
33
+ first_offset INTEGER,
34
+ next_offset INTEGER NOT NULL DEFAULT 0
35
+ );
36
+ CREATE TABLE IF NOT EXISTS agentic_transcript_chunk (
37
+ stream TEXT NOT NULL,
38
+ chunk_offset INTEGER NOT NULL,
39
+ chunk TEXT NOT NULL,
40
+ appended_at TEXT NOT NULL,
41
+ PRIMARY KEY (stream, chunk_offset)
42
+ );
43
+ CREATE INDEX IF NOT EXISTS idx_agentic_transcript_stream_retention ON agentic_transcript_stream (lifecycle, status, completed_at);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.52.0",
3
+ "version": "0.53.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",