@nanobpm/nano-workforce 0.52.0 → 0.54.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/blackboard.family.test.ts +189 -0
- package/app/agentic/families/blackboard.family.ts +69 -0
- package/app/agentic/families/relay.family.test.ts +402 -0
- package/app/agentic/families/relay.family.ts +331 -0
- package/app/blackboard.schema.test.ts +21 -0
- package/app/blackboard.test.ts +37 -69
- package/app/blackboard.ts +101 -124
- package/app/retro.test.ts +3 -1
- package/db/migrations/024_agentic_transcript.sql +43 -0
- package/db/migrations/025_agentic_blackboard.sql +39 -0
- package/operations/blackboard.test.ts +11 -27
- package/package.json +1 -1
- package/test/blackboardDb.ts +108 -0
- package/workers/retro-gather/worker.test.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.54.0](https://github.com/nanobpm/nano-workforce/compare/v0.53.0...v0.54.0) (2026-08-13)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* generalize the advisory blackboard onto the agentic channel (H4) ([#166](https://github.com/nanobpm/nano-workforce/issues/166)) ([450a4fa](https://github.com/nanobpm/nano-workforce/commit/450a4fa4a75ad1cdc2a78eef6f95aa2f45bebb9f)), closes [#147](https://github.com/nanobpm/nano-workforce/issues/147) [#147](https://github.com/nanobpm/nano-workforce/issues/147) [#143](https://github.com/nanobpm/nano-workforce/issues/143)
|
|
7
|
+
|
|
8
|
+
# [0.53.0](https://github.com/nanobpm/nano-workforce/compare/v0.52.0...v0.53.0) (2026-08-13)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* 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)
|
|
14
|
+
|
|
1
15
|
# [0.52.0](https://github.com/nanobpm/nano-workforce/compare/v0.51.0...v0.52.0) (2026-08-13)
|
|
2
16
|
|
|
3
17
|
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Tests for H4's channel-side `blackboard` family module (#147). These prove the generalized
|
|
2
|
+
// agentic-channel path is a faithful bridge to the SAME per-plan board the HTTP hook serves:
|
|
3
|
+
// - a channel `append` frame writes the very rows `readBlackboard(data, planKey)` (the HTTP path)
|
|
4
|
+
// reads back — one canonical store, no drift surface;
|
|
5
|
+
// - `file-claim` conflict-of-intent is reported on the channel exactly as over HTTP;
|
|
6
|
+
// - board scope is capability-derived (the plan's blackboard token → plan_key), so a connection
|
|
7
|
+
// only ever touches the board its credential authorises;
|
|
8
|
+
// - an unknown/absent credential is rejected (advisory — the frame is dropped, never a hard-lock).
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import { AgenticHub, type HubConnection, sharedSecretAuthenticator } from "@nanobpm/agentic/channel";
|
|
11
|
+
import type { ChannelTransport, HandshakeRequest } from "@nanobpm/agentic/channel";
|
|
12
|
+
import type { Frame } from "@nanobpm/agentic/protocol";
|
|
13
|
+
import { assert, assertEquals } from "#test-assert";
|
|
14
|
+
import { noopLog } from "../../../test/log.ts";
|
|
15
|
+
import { memBlackboardData } from "../../../test/blackboardDb.ts";
|
|
16
|
+
import { readBlackboard } from "../../blackboard.ts";
|
|
17
|
+
import type { AgenticContext } from "../registry.ts";
|
|
18
|
+
import { family } from "./blackboard.family.ts";
|
|
19
|
+
|
|
20
|
+
/** A do-nothing transport just to satisfy the hub constructor; the tests drive the router directly. */
|
|
21
|
+
function fakeTransport(): ChannelTransport {
|
|
22
|
+
return {
|
|
23
|
+
onConnection() {},
|
|
24
|
+
address: null,
|
|
25
|
+
async close() {},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Build a hub + mount the blackboard family over a real in-memory DataLayer. */
|
|
30
|
+
function mountFamily() {
|
|
31
|
+
const { data, db, close } = memBlackboardData();
|
|
32
|
+
const hub = new AgenticHub({
|
|
33
|
+
transport: fakeTransport(),
|
|
34
|
+
authenticator: sharedSecretAuthenticator({ secret: "s3cr3t" }),
|
|
35
|
+
sweepIntervalMs: 0,
|
|
36
|
+
});
|
|
37
|
+
const ctx: AgenticContext = {
|
|
38
|
+
hub,
|
|
39
|
+
registry: hub.registry,
|
|
40
|
+
// The blackboard family never touches the transport; a minimal stand-in is enough.
|
|
41
|
+
transport: undefined as unknown as AgenticContext["transport"],
|
|
42
|
+
data,
|
|
43
|
+
log: noopLog(),
|
|
44
|
+
};
|
|
45
|
+
family.mount(ctx);
|
|
46
|
+
return { hub, data, db, close };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A HubConnection whose sends are captured, presenting `credential` at the handshake. */
|
|
50
|
+
function conn(hub: AgenticHub, credential: string | undefined, sent: Frame[]): HubConnection {
|
|
51
|
+
const handshake: HandshakeRequest = credential === undefined ? {} : { credential };
|
|
52
|
+
return {
|
|
53
|
+
id: `c-${credential ?? "anon"}`,
|
|
54
|
+
identity: "peer",
|
|
55
|
+
handshake,
|
|
56
|
+
registry: hub.registry,
|
|
57
|
+
send: (frame) => sent.push(frame),
|
|
58
|
+
close() {},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function seedToken(db: { run(sql: string, params?: unknown[]): unknown }, planKey: string, token: string): void {
|
|
63
|
+
db.run("INSERT INTO plans (plan_key, blackboard_token) VALUES (?, ?)", [planKey, token]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function appendFrame(seq: number, payload: Record<string, unknown>): Frame {
|
|
67
|
+
return { lane: "control", family: "blackboard", seq, payload: { op: "append", ...payload } };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readFrame(seq: number, since?: number): Frame {
|
|
71
|
+
return { lane: "control", family: "blackboard", seq, payload: since === undefined ? { op: "read" } : { op: "read", since } };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
test("channel append writes the SAME board the HTTP readBlackboard path reads", async () => {
|
|
75
|
+
const { hub, data, db, close } = mountFamily();
|
|
76
|
+
try {
|
|
77
|
+
seedToken(db, "o/r#1", "tok-1");
|
|
78
|
+
const sent: Frame[] = [];
|
|
79
|
+
const c = conn(hub, "tok-1", sent);
|
|
80
|
+
|
|
81
|
+
const ran = await hub.router.route(
|
|
82
|
+
appendFrame(1, { authorTask: "gap-2", kind: "note", body: "hello board" }),
|
|
83
|
+
c,
|
|
84
|
+
);
|
|
85
|
+
assertEquals(ran, true);
|
|
86
|
+
assertEquals(sent.length, 1);
|
|
87
|
+
const reply = sent[0].payload as { op: string; inserted: boolean; id: number };
|
|
88
|
+
assertEquals(reply.op, "append");
|
|
89
|
+
assertEquals(reply.inserted, true);
|
|
90
|
+
assert(reply.id > 0);
|
|
91
|
+
|
|
92
|
+
// Parity: the HTTP-side reader sees exactly what the channel wrote, under the same plan scope.
|
|
93
|
+
const entries = await readBlackboard(data, "o/r#1");
|
|
94
|
+
assertEquals(entries.length, 1);
|
|
95
|
+
assertEquals(entries[0].author_task, "gap-2");
|
|
96
|
+
assertEquals(entries[0].body, "hello board");
|
|
97
|
+
assertEquals(entries[0].kind, "note");
|
|
98
|
+
} finally {
|
|
99
|
+
await hub.close();
|
|
100
|
+
close();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("channel file-claim reports conflicts with a prior claim by another author", async () => {
|
|
105
|
+
const { hub, data, db, close } = mountFamily();
|
|
106
|
+
try {
|
|
107
|
+
seedToken(db, "o/r#1", "tok-1");
|
|
108
|
+
const sent: Frame[] = [];
|
|
109
|
+
const c = conn(hub, "tok-1", sent);
|
|
110
|
+
|
|
111
|
+
await hub.router.route(
|
|
112
|
+
appendFrame(1, { authorTask: "gap-1", kind: "file-claim", files: ["engine/state.rs"], body: "own state.rs" }),
|
|
113
|
+
c,
|
|
114
|
+
);
|
|
115
|
+
await hub.router.route(
|
|
116
|
+
appendFrame(2, { authorTask: "gap-2", kind: "file-claim", files: ["engine/state.rs"], body: "also want state.rs" }),
|
|
117
|
+
c,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const second = sent[1].payload as { conflicts: { authorTask: string; file: string }[] };
|
|
121
|
+
assertEquals(second.conflicts.length, 1);
|
|
122
|
+
assertEquals(second.conflicts[0].authorTask, "gap-1");
|
|
123
|
+
assertEquals(second.conflicts[0].file, "engine/state.rs");
|
|
124
|
+
|
|
125
|
+
// And both rows landed on the shared board.
|
|
126
|
+
const entries = await readBlackboard(data, "o/r#1");
|
|
127
|
+
assertEquals(entries.length, 2);
|
|
128
|
+
} finally {
|
|
129
|
+
await hub.close();
|
|
130
|
+
close();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("channel read returns entries appended over HTTP (bidirectional bridge parity)", async () => {
|
|
135
|
+
const { hub, data, db, close } = mountFamily();
|
|
136
|
+
try {
|
|
137
|
+
seedToken(db, "o/r#1", "tok-1");
|
|
138
|
+
// Write via the HTTP-path adapter…
|
|
139
|
+
const { appendEntry } = await import("../../blackboard.ts");
|
|
140
|
+
await appendEntry(data, "o/r#1", { author_task: "gap-3", kind: "note", body: "via http" });
|
|
141
|
+
|
|
142
|
+
// …and read it back over the channel.
|
|
143
|
+
const sent: Frame[] = [];
|
|
144
|
+
const ran = await hub.router.route(readFrame(9), conn(hub, "tok-1", sent));
|
|
145
|
+
assertEquals(ran, true);
|
|
146
|
+
const reply = sent[0].payload as { op: string; entries: { authorTask: string; body: string }[] };
|
|
147
|
+
assertEquals(reply.op, "read");
|
|
148
|
+
assertEquals(reply.entries.length, 1);
|
|
149
|
+
assertEquals(reply.entries[0].authorTask, "gap-3");
|
|
150
|
+
assertEquals(reply.entries[0].body, "via http");
|
|
151
|
+
} finally {
|
|
152
|
+
await hub.close();
|
|
153
|
+
close();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("an unknown credential is rejected — no board is touched, no reply sent", async () => {
|
|
158
|
+
const { hub, data, db, close } = mountFamily();
|
|
159
|
+
try {
|
|
160
|
+
seedToken(db, "o/r#1", "tok-1");
|
|
161
|
+
const sent: Frame[] = [];
|
|
162
|
+
const ran = await hub.router.route(
|
|
163
|
+
appendFrame(1, { authorTask: "gap-2", kind: "note", body: "should not land" }),
|
|
164
|
+
conn(hub, "bogus-token", sent),
|
|
165
|
+
);
|
|
166
|
+
assertEquals(ran, true); // the handler ran (and rejected) — the family owns the frame
|
|
167
|
+
assertEquals(sent.length, 0); // no reply: scope could not be resolved
|
|
168
|
+
// Nothing was written under any scope.
|
|
169
|
+
const [{ n }] = db.all<{ n: number }>("SELECT COUNT(*) AS n FROM agentic_blackboard");
|
|
170
|
+
assertEquals(n, 0);
|
|
171
|
+
assertEquals((await readBlackboard(data, "o/r#1")).length, 0);
|
|
172
|
+
} finally {
|
|
173
|
+
await hub.close();
|
|
174
|
+
close();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("an absent credential is rejected too", async () => {
|
|
179
|
+
const { hub, db, close } = mountFamily();
|
|
180
|
+
try {
|
|
181
|
+
seedToken(db, "o/r#1", "tok-1");
|
|
182
|
+
const sent: Frame[] = [];
|
|
183
|
+
await hub.router.route(appendFrame(1, { kind: "note", body: "x" }), conn(hub, undefined, sent));
|
|
184
|
+
assertEquals(sent.length, 0);
|
|
185
|
+
} finally {
|
|
186
|
+
await hub.close();
|
|
187
|
+
close();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// nano-workforce — the agentic-channel `blackboard` family (ADR 0056, H4 / #147).
|
|
2
|
+
//
|
|
3
|
+
// This is H4's ONE new file plugged into the H0 (#143) family-registration seam. It mounts
|
|
4
|
+
// `@nanobpm/agentic/blackboard`'s `blackboard` message family on the app-tier hub, backed by the
|
|
5
|
+
// SAME `BlackboardStore` — over the SAME app SQLite DataLayer (`ctx.data.source().db`) — that the
|
|
6
|
+
// legacy `/app/api/hooks/blackboard` HTTP hook now uses (see `app/blackboard.ts`). One canonical
|
|
7
|
+
// store, one table (`agentic_blackboard`), reached two ways: the HTTP side-channel and the agentic
|
|
8
|
+
// channel serve the identical per-plan board with no drift surface.
|
|
9
|
+
//
|
|
10
|
+
// Board scope parity: the family derives each connection's board `scope` from its capability
|
|
11
|
+
// credential — the per-plan blackboard token — resolved back to its `plan_key` via
|
|
12
|
+
// `planKeyForTokenSync`, EXACTLY as the HTTP hook resolves `?token=` to a plan. So a channel client
|
|
13
|
+
// and an HTTP caller holding the same plan token read/write the very same rows. An unknown/absent
|
|
14
|
+
// credential yields no scope and the frame is rejected (advisory — never a hard-lock, never gates a
|
|
15
|
+
// BPMN sequence flow).
|
|
16
|
+
//
|
|
17
|
+
// Adds NO migration of its own: H4's reserved `db/migrations/025_agentic_blackboard.sql` (owned by
|
|
18
|
+
// the app-side adapter) creates `agentic_blackboard`; `store.ensureSchema()` here is the idempotent
|
|
19
|
+
// belt-and-braces the store's own contract expects.
|
|
20
|
+
import { attachBlackboardFamily, BlackboardStore } from "@nanobpm/agentic/blackboard";
|
|
21
|
+
import type { HubConnection } from "@nanobpm/agentic/channel";
|
|
22
|
+
import { planKeyForTokenSync } from "../../blackboard.ts";
|
|
23
|
+
import type { AgenticContext, AgenticFamily } from "../registry.ts";
|
|
24
|
+
|
|
25
|
+
/** The capability-credential a connection presents at the handshake (the blackboard token). */
|
|
26
|
+
function credentialOf(conn: HubConnection): string {
|
|
27
|
+
return (conn.handshake.credential ?? conn.handshake.query?.capability ?? "").trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let handle: { stop(): void } | undefined;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The `blackboard` family module. `mount` attaches the family to the hub when the app has a data
|
|
34
|
+
* layer; without one (data isn't mounted) it is a no-op — the channel simply serves no blackboard,
|
|
35
|
+
* exactly as the HTTP hook would 404. `teardown` detaches it.
|
|
36
|
+
*/
|
|
37
|
+
export const family: AgenticFamily = {
|
|
38
|
+
name: "blackboard",
|
|
39
|
+
mount(ctx: AgenticContext): void {
|
|
40
|
+
// Stop any previously-attached family before (re)mounting, so a repeat mount() (tests or a
|
|
41
|
+
// future remount path) can't leave stale handlers attached and double-handle frames / leak
|
|
42
|
+
// resources. Done unconditionally — before the data check — so even a no-data remount detaches
|
|
43
|
+
// the prior handle instead of silently leaving it live.
|
|
44
|
+
handle?.stop();
|
|
45
|
+
handle = undefined;
|
|
46
|
+
const data = ctx.data;
|
|
47
|
+
if (!data) {
|
|
48
|
+
ctx.log.warn("agentic blackboard family: no data layer; not mounting");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const db = data.source().db;
|
|
52
|
+
const store = new BlackboardStore(db);
|
|
53
|
+
store.ensureSchema();
|
|
54
|
+
handle = attachBlackboardFamily(ctx.hub, store, {
|
|
55
|
+
// Scope every board to the plan the credential's token maps to — the same plan the HTTP hook
|
|
56
|
+
// scopes to — so the two paths share one board. Returning undefined rejects the frame.
|
|
57
|
+
scopeOf: (conn) => planKeyForTokenSync(db, credentialOf(conn)),
|
|
58
|
+
onError: (err, connectionId) =>
|
|
59
|
+
ctx.log.warn("agentic blackboard family error", { connectionId, err: String(err) }),
|
|
60
|
+
});
|
|
61
|
+
ctx.log.info("agentic blackboard family mounted");
|
|
62
|
+
},
|
|
63
|
+
teardown(): void {
|
|
64
|
+
handle?.stop();
|
|
65
|
+
handle = undefined;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export default family;
|
|
@@ -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
|
+
});
|