@nanobpm/nano-workforce 0.51.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 +14 -0
- package/app/agentic/families/presence.family.test.ts +341 -0
- package/app/agentic/families/presence.family.ts +279 -0
- package/app/agentic/families/relay.family.test.ts +402 -0
- package/app/agentic/families/relay.family.ts +331 -0
- package/db/migrations/023_agentic_presence.sql +30 -0
- package/db/migrations/024_agentic_transcript.sql +43 -0
- package/docs/adr/0002-escalations-are-user-tasks-and-forms.md +167 -0
- package/docs/adr/0003-epic-base-branch-admission.md +123 -0
- package/package.json +1 -1
|
@@ -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
|
+
});
|