@nanobpm/nano-workforce 0.53.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 CHANGED
@@ -1,3 +1,10 @@
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
+
1
8
  # [0.53.0](https://github.com/nanobpm/nano-workforce/compare/v0.52.0...v0.53.0) (2026-08-13)
2
9
 
3
10
 
@@ -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,21 @@
1
+ // Schema-drift guard (#147). The `db/migrations/025_agentic_blackboard.sql` CREATE statements MUST be
2
+ // the canonical `BLACKBOARD_SCHEMA_SQL` verbatim — the exact DDL `@nanobpm/agentic/blackboard`'s
3
+ // `BlackboardStore.ensureSchema()` (and the agentic-channel family) apply. If the two ever drift, a
4
+ // board created by a migration on one host and by `ensureSchema()` on another would disagree — this
5
+ // test fails the build before that can ship.
6
+ import { readFileSync } from "node:fs";
7
+ import { fileURLToPath } from "node:url";
8
+ import test from "node:test";
9
+ import { BLACKBOARD_SCHEMA_SQL } from "@nanobpm/agentic/blackboard";
10
+ import { assert, assertEquals } from "#test-assert";
11
+
12
+ test("migration 025 CREATE statements equal BLACKBOARD_SCHEMA_SQL verbatim", () => {
13
+ const path = fileURLToPath(new URL("../db/migrations/025_agentic_blackboard.sql", import.meta.url));
14
+ const sql = readFileSync(path, "utf8");
15
+ const start = sql.indexOf("CREATE TABLE IF NOT EXISTS agentic_blackboard");
16
+ const end = sql.indexOf("(scope, id);");
17
+ assert(start !== -1, "migration 025 is missing the `CREATE TABLE IF NOT EXISTS agentic_blackboard` marker");
18
+ assert(end !== -1, "migration 025 is missing the `(scope, id);` index marker");
19
+ const createBlock = sql.slice(start, end + "(scope, id);".length).trim();
20
+ assertEquals(createBlock, BLACKBOARD_SCHEMA_SQL.trim());
21
+ });
@@ -1,7 +1,12 @@
1
1
  // Unit tests for the epic coordination blackboard (Tier 1, issues #51 / #49 D4).
2
+ //
3
+ // H4 (#147) migrated the storage onto `@nanobpm/agentic/blackboard`'s shared `BlackboardStore`
4
+ // (table `agentic_blackboard`), reached over the app DataLayer's raw SQLite handle. These tests run
5
+ // the adapter against a REAL in-memory SQLite engine (see `test/blackboardDb.ts`), so the
6
+ // idempotency, conflict, and incremental-read behaviour is verified end-to-end, not against a mock.
2
7
  import { test } from "node:test";
3
8
  import { assert, assertEquals, assertStringIncludes } from "#test-assert";
4
- import type { DataLayer } from "@nanobpm/urban";
9
+ import { memBlackboardData } from "../test/blackboardDb.ts";
5
10
  import {
6
11
  appendEntry,
7
12
  blackboardUrl,
@@ -10,41 +15,13 @@ import {
10
15
  mintBlackboardToken,
11
16
  normalizeKind,
12
17
  planKeyForToken,
18
+ planKeyForTokenSync,
13
19
  publicBaseUrl,
14
20
  readBlackboard,
15
21
  readBlackboardPage,
16
22
  renderCoordinationBrief,
17
23
  } from "./blackboard.ts";
18
24
 
19
- // A tiny in-memory stand-in for the record gateway, matching the subset of the Table<T> API the
20
- // blackboard uses (insert/find/findOne). Mirrors the fake-app style used across the app tests.
21
- function memData(): { data: DataLayer; stores: Record<string, any[]> } {
22
- const stores: Record<string, any[]> = {};
23
- const seq: Record<string, number> = {};
24
- function tbl(name: string, pk = "id") {
25
- const rows = (stores[name] ??= [] as any[]);
26
- return {
27
- async insert(row: any) {
28
- if (pk === "id") {
29
- const id = (seq[name] = (seq[name] ?? 0) + 1);
30
- rows.push({ id, ...row });
31
- return id;
32
- }
33
- rows.push({ ...row });
34
- return row[pk];
35
- },
36
- async find(where: any = {}) {
37
- return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
38
- },
39
- async findOne(where: any = {}) {
40
- return rows.find((r) => Object.entries(where).every(([k, v]) => r[k] === v));
41
- },
42
- };
43
- }
44
- const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
45
- return { data, stores };
46
- }
47
-
48
25
  test("mintBlackboardToken: URL-safe, unguessable, unique", () => {
49
26
  const a = mintBlackboardToken();
50
27
  const b = mintBlackboardToken();
@@ -106,16 +83,21 @@ test("renderCoordinationBrief: leads with a separator and teaches the protocol +
106
83
  assertStringIncludes(brief, "Share what you learn");
107
84
  });
108
85
 
109
- test("planKeyForToken: resolves a token to its plan, undefined otherwise", async () => {
110
- const { data } = memData();
86
+ test("planKeyForToken: resolves a token to its plan, undefined otherwise (async + sync agree)", async () => {
87
+ const { data, db } = memBlackboardData();
111
88
  await data.table("plans", "plan_key").insert({ plan_key: "o/r#7", blackboard_token: "tok7" });
112
89
  assertEquals(await planKeyForToken(data, "tok7"), "o/r#7");
113
90
  assertEquals(await planKeyForToken(data, "nope"), undefined);
114
91
  assertEquals(await planKeyForToken(data, ""), undefined);
92
+ // The sync resolver (used by the agentic channel's scopeOf) resolves the identical mapping, so the
93
+ // HTTP hook and the channel scope a plan's board to the same plan_key.
94
+ assertEquals(planKeyForTokenSync(db, "tok7"), "o/r#7");
95
+ assertEquals(planKeyForTokenSync(db, "nope"), undefined);
96
+ assertEquals(planKeyForTokenSync(db, ""), undefined);
115
97
  });
116
98
 
117
99
  test("appendEntry + readBlackboard: append, encode files, read back in write order", async () => {
118
- const { data } = memData();
100
+ const { data } = memBlackboardData();
119
101
  await appendEntry(data, "o/r#1", { author_task: "gap-2", kind: "file-claim", files: ["a.rs"], body: "touches a.rs" });
120
102
  await appendEntry(data, "o/r#1", { author_task: "gap-8", kind: "note", body: "heads up" });
121
103
  await appendEntry(data, "o/r#2", { body: "other plan" }); // must not leak across plans
@@ -128,14 +110,14 @@ test("appendEntry + readBlackboard: append, encode files, read back in write ord
128
110
  });
129
111
 
130
112
  test("appendEntry: trims whitespace-padded file paths so stored/read values are clean", async () => {
131
- const { data } = memData();
113
+ const { data } = memBlackboardData();
132
114
  await appendEntry(data, "p", { kind: "file-claim", files: [" engine/state.rs ", "\tengine/mine.rs\n"], body: "claims" });
133
115
  const [e] = await readBlackboard(data, "p");
134
116
  assertEquals(e.files, ["engine/state.rs", "engine/mine.rs"], "paths stored trimmed, not whitespace-padded");
135
117
  });
136
118
 
137
119
  test("appendEntry: a missing author defaults to 'system' and kind is normalised", async () => {
138
- const { data } = memData();
120
+ const { data } = memBlackboardData();
139
121
  await appendEntry(data, "p", { body: "x", kind: "weird" as unknown });
140
122
  const [e] = await readBlackboard(data, "p");
141
123
  assertEquals(e.author_task, "system");
@@ -143,44 +125,30 @@ test("appendEntry: a missing author defaults to 'system' and kind is normalised"
143
125
  });
144
126
 
145
127
  test("appendEntry: idempotent on dedupe_key (a job retry re-POST is a no-op)", async () => {
146
- const { data, stores } = memData();
128
+ const { data, db } = memBlackboardData();
147
129
  const first = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
148
130
  const again = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
149
131
  assertEquals(first.inserted, true);
150
132
  assertEquals(again.inserted, false, "second write with same dedupe_key is a no-op");
151
133
  assertEquals(again.id, first.id, "returns the existing id");
152
- assertEquals(stores["plan_blackboard"].length, 1, "exactly one row persisted");
134
+ const [{ n }] = db.all<{ n: number }>("SELECT COUNT(*) AS n FROM agentic_blackboard WHERE scope = ?", ["p"]);
135
+ assertEquals(n, 1, "exactly one row persisted");
153
136
  });
154
137
 
155
- test("appendEntry: a lost UNIQUE race collapses to a no-op instead of a 500", async () => {
156
- // Simulate the concurrency window: two POSTs share a dedupe_key, both miss the findOne
157
- // pre-check, then insert loses the race on the UNIQUE (plan_key, dedupe_key) index. The
158
- // catch branch must re-read the winner's row and return it rather than propagate the throw.
159
- const winner = { id: 42, plan_key: "p", dedupe_key: "t:claim:1", author_task: "t", body: "claim" };
160
- let preCheckDone = false;
161
- const table: any = {
162
- async findOne() {
163
- // Pre-check misses (row not yet visible); the recovery read after the collision hits.
164
- if (!preCheckDone) {
165
- preCheckDone = true;
166
- return undefined;
167
- }
168
- return winner;
169
- },
170
- async insert() {
171
- throw Object.assign(new Error("UNIQUE constraint failed: plan_blackboard.dedupe_key"), {
172
- code: "SQLITE_CONSTRAINT_UNIQUE",
173
- });
174
- },
175
- };
176
- const data = { table: () => table } as any as DataLayer;
177
- const res = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
178
- assertEquals(res.inserted, false, "a lost race is not a fresh insert");
179
- assertEquals(res.id, 42, "returns the winning row's id");
138
+ test("appendEntry: a repeat dedupe_key collapses to the existing row instead of a fresh insert", async () => {
139
+ // The store's idempotent short-circuit (and its lost-UNIQUE-race recovery) means re-appending a
140
+ // fact under a stable dedupe_key returns the winning row as inserted:false rather than throwing
141
+ // an engine job retry never duplicates or 500s.
142
+ const { data } = memBlackboardData();
143
+ const winner = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
144
+ assertEquals(winner.inserted, true);
145
+ const retry = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
146
+ assertEquals(retry.inserted, false, "a repeat is not a fresh insert");
147
+ assertEquals(retry.id, winner.id, "returns the winning row's id");
180
148
  });
181
149
 
182
150
  test("appendEntry: a blank body is rejected", async () => {
183
- const { data } = memData();
151
+ const { data } = memBlackboardData();
184
152
  let threw = false;
185
153
  try {
186
154
  await appendEntry(data, "p", { body: " " });
@@ -191,7 +159,7 @@ test("appendEntry: a blank body is rejected", async () => {
191
159
  });
192
160
 
193
161
  test("readBlackboard: since returns only newer entries (incremental poll)", async () => {
194
- const { data } = memData();
162
+ const { data } = memBlackboardData();
195
163
  await appendEntry(data, "p", { body: "one" });
196
164
  await appendEntry(data, "p", { body: "two" });
197
165
  await appendEntry(data, "p", { body: "three" });
@@ -201,7 +169,7 @@ test("readBlackboard: since returns only newer entries (incremental poll)", asyn
201
169
  });
202
170
 
203
171
  test("readBlackboardPage: cursor is the plan head and lets an agent poll to caught-up (Tier 2)", async () => {
204
- const { data } = memData();
172
+ const { data } = memBlackboardData();
205
173
  await appendEntry(data, "p", { body: "one" });
206
174
  await appendEntry(data, "p", { body: "two" });
207
175
 
@@ -222,14 +190,14 @@ test("readBlackboardPage: cursor is the plan head and lets an agent poll to caug
222
190
  });
223
191
 
224
192
  test("readBlackboardPage: an empty plan yields no entries and a zero cursor", async () => {
225
- const { data } = memData();
193
+ const { data } = memBlackboardData();
226
194
  const page = await readBlackboardPage(data, "empty");
227
195
  assertEquals(page.entries, []);
228
196
  assertEquals(page.cursor, 0);
229
197
  });
230
198
 
231
199
  test("detectFileClaimConflicts: a sibling's prior claim on the same file is surfaced", async () => {
232
- const { data } = memData();
200
+ const { data } = memBlackboardData();
233
201
  await appendEntry(data, "p", { author_task: "gap-2", kind: "file-claim", files: ["engine/state.rs"], body: "owns state.rs" });
234
202
 
235
203
  const conflicts = await detectFileClaimConflicts(data, "p", {
@@ -242,7 +210,7 @@ test("detectFileClaimConflicts: a sibling's prior claim on the same file is surf
242
210
  });
243
211
 
244
212
  test("detectFileClaimConflicts: your own prior claim and non-file-claim entries are not conflicts", async () => {
245
- const { data } = memData();
213
+ const { data } = memBlackboardData();
246
214
  await appendEntry(data, "p", { author_task: "gap-2", kind: "file-claim", files: ["a.rs"], body: "my earlier claim" });
247
215
  await appendEntry(data, "p", { author_task: "gap-8", kind: "note", files: ["a.rs"], body: "just a note about a.rs" });
248
216
 
@@ -256,7 +224,7 @@ test("detectFileClaimConflicts: your own prior claim and non-file-claim entries
256
224
  });
257
225
 
258
226
  test("detectFileClaimConflicts: beforeId restricts to strictly prior claims (insertion order wins)", async () => {
259
- const { data } = memData();
227
+ const { data } = memBlackboardData();
260
228
  const prior = await appendEntry(data, "p", {
261
229
  author_task: "gap-2",
262
230
  kind: "file-claim",
package/app/blackboard.ts CHANGED
@@ -2,42 +2,43 @@
2
2
  //
3
3
  // A per-plan advisory shared store. Implementer agents (`senior:feature`) READ it on dispatch and
4
4
  // WRITE to it during/after their work — "I now also touch state.rs", "constraint X changed
5
- // direction Y" — so parallel siblings in a wave can coordinate without a human relay. It is the
6
- // machine-actionable substrate the #614 retro's "structured coordination channel" asked for.
5
+ // direction Y" — so parallel siblings in a wave can coordinate without a human relay.
7
6
  //
8
- // Design invariants:
7
+ // H4 (#147, ADR 0056) GENERALISED the storage onto `@nanobpm/agentic/blackboard`'s first-class,
8
+ // capability-scoped `BlackboardStore` (table `agentic_blackboard`) — the SAME store the new
9
+ // agentic-channel `blackboard` family serves, over the SAME app SQLite DataLayer. This module is now
10
+ // the app-side ADAPTER: it keeps the exact HTTP-hook surface (snake_case entries, `plan_key` scope,
11
+ // token→plan resolution) callers already depend on — `operations/{appendBlackboard,readBlackboard}`,
12
+ // `app/retro.ts`, `app/plan.ts`, `workers/record-wave` — while the append/read/dedupe/conflict
13
+ // SEMANTICS live once in the shared store (no drift surface). The idempotency, `file-claim`
14
+ // conflict reporting, and `since`/cursor incremental-read behaviour are identical to before because
15
+ // the store is a faithful port of the original `plan_blackboard` logic.
16
+ //
17
+ // Design invariants (unchanged):
9
18
  // - ADVISORY ONLY. Never gate a sequence flow on a blackboard read; the BPMN stays the
10
19
  // control-flow source of truth. This store is shared *knowledge*, read fresh, and is not part
11
20
  // of deterministic replay.
12
21
  // - IDEMPOTENT write-back. The engine may re-activate a job on retry, so a re-POST carrying a
13
- // stable `dedupe_key` is a no-op (backed by a unique index; we also short-circuit here).
22
+ // stable `dedupe_key` is a no-op (backed by a unique index; the store also short-circuits).
14
23
  // - CAPABILITY URL. The per-plan token IS the credential; the agent curls the exact URL it was
15
24
  // handed (delivered in `appendPrompt`). Delivery is in-band (rides the prompt the harness
16
25
  // already forwards); use is out-of-band (a direct side-channel to `/app/api/hooks/blackboard`).
17
26
  //
18
- // Data access goes through the record gateway (`data.table`), never hand-written SQL matching
19
- // app/service.ts and app/plan.ts.
20
- import type { DataLayer } from "@nanobpm/urban";
27
+ // Storage goes through the shared `BlackboardStore` over the app DataLayer's raw synchronous SQLite
28
+ // handle (`data.source().db`) the same physical database the record gateway (`data.table`) uses,
29
+ // so the HTTP hook and the agentic channel share one connection and one table.
21
30
 
22
- const now = () => new Date().toISOString();
31
+ import { BlackboardStore, type SqliteDb } from "@nanobpm/agentic/blackboard";
32
+ import type { DataLayer } from "@nanobpm/urban";
23
33
 
24
- export const BLACKBOARD_KINDS = ["file-claim", "constraint-change", "scope-change", "learning", "note"] as const;
25
- export type BlackboardKind = (typeof BLACKBOARD_KINDS)[number];
34
+ // Re-export the storage vocabulary from the shared package so there is ONE canonical definition of
35
+ // the kinds, the kind-normaliser, and the unique-violation predicate — the app never keeps a
36
+ // parallel copy that could drift from the store's own semantics.
37
+ export type { BlackboardKind } from "@nanobpm/agentic/blackboard";
38
+ export { BLACKBOARD_KINDS, isUniqueViolation, normalizeKind } from "@nanobpm/agentic/blackboard";
26
39
 
27
- /** The stored row shape (files is a JSON-encoded string of paths, or NULL). */
28
- export interface BlackboardRow {
29
- id: number;
30
- plan_key: string;
31
- author_task: string;
32
- kind: string;
33
- files: string | null;
34
- body: string;
35
- wave: number | null;
36
- dedupe_key: string | null;
37
- created_at: string;
38
- }
39
-
40
- /** The parsed, agent-facing view of an entry (files decoded to an array). */
40
+ /** The parsed, agent-facing view of an entry (files decoded to an array). Snake_case is the
41
+ * app/HTTP-hook boundary contract every existing caller and agent already consumes. */
41
42
  export interface BlackboardEntry {
42
43
  id: number;
43
44
  author_task: string;
@@ -58,11 +59,6 @@ export interface BlackboardInput {
58
59
  dedupe_key?: string;
59
60
  }
60
61
 
61
- /** Coerce an arbitrary `kind` to a known value, defaulting to "note" for anything unrecognised. */
62
- export function normalizeKind(kind: unknown): BlackboardKind {
63
- return BLACKBOARD_KINDS.find((k) => k === kind) ?? "note";
64
- }
65
-
66
62
  /** A URL-safe, unguessable capability token (192 bits of randomness, base64url, no padding). */
67
63
  export function mintBlackboardToken(): string {
68
64
  const bytes = new Uint8Array(24);
@@ -157,9 +153,45 @@ coordinate, or if it genuinely blocks you, escalate a \`question\` per your norm
157
153
  here is a hard lock; the merge step is the real safety net.`;
158
154
  }
159
155
 
160
- const blackboardTable = (data: DataLayer) => data.table<BlackboardRow>("plan_blackboard", "id");
156
+ // The store is a thin wrapper over the DataLayer's raw synchronous SQLite handle; construct it per
157
+ // call (cheap — it just holds the handle). The schema is applied by boot migration
158
+ // `025_agentic_blackboard.sql`; we also `ensureSchema()` once per handle (idempotent
159
+ // `CREATE TABLE IF NOT EXISTS`) so the adapter works against a bare source too (e.g. unit tests over
160
+ // an in-memory DataLayer that hasn't run migrations).
161
+ const schemaReady = new WeakSet<object>();
162
+ function storeFor(data: DataLayer): BlackboardStore {
163
+ const db = data.source().db;
164
+ const store = new BlackboardStore(db);
165
+ if (!schemaReady.has(db)) {
166
+ store.ensureSchema();
167
+ schemaReady.add(db);
168
+ }
169
+ return store;
170
+ }
171
+
172
+ /** Map the store's camelCase entry to the app/HTTP snake_case boundary shape. */
173
+ function toEntry(e: {
174
+ id: number;
175
+ authorTask: string;
176
+ kind: string;
177
+ files: string[];
178
+ body: string;
179
+ wave: number | null;
180
+ createdAt: string;
181
+ }): BlackboardEntry {
182
+ return {
183
+ id: e.id,
184
+ author_task: e.authorTask,
185
+ kind: e.kind,
186
+ files: e.files,
187
+ body: e.body,
188
+ wave: e.wave,
189
+ created_at: e.createdAt,
190
+ };
191
+ }
161
192
 
162
- /** Resolve a capability token back to its plan, or undefined when the token is unknown. */
193
+ /** Resolve a capability token back to its plan, or undefined when the token is unknown. Async
194
+ * variant over the record gateway, used by the HTTP-hook operations. */
163
195
  export async function planKeyForToken(data: DataLayer, token: string): Promise<string | undefined> {
164
196
  if (!token) return undefined;
165
197
  const row = await data
@@ -168,32 +200,24 @@ export async function planKeyForToken(data: DataLayer, token: string): Promise<s
168
200
  return row?.plan_key;
169
201
  }
170
202
 
171
- function decodeFiles(raw: string | null): string[] {
172
- if (!raw) return [];
173
- try {
174
- const v = JSON.parse(raw);
175
- return Array.isArray(v) ? v.map(String).map((s) => s.trim()).filter((s) => s !== "") : [];
176
- } catch {
177
- return [];
178
- }
179
- }
180
-
181
- function toEntry(r: BlackboardRow): BlackboardEntry {
182
- return {
183
- id: r.id,
184
- author_task: r.author_task,
185
- kind: r.kind,
186
- files: decodeFiles(r.files).map((x) => x.trim()).filter((x) => x !== ""),
187
- body: r.body,
188
- wave: r.wave,
189
- created_at: r.created_at,
190
- };
203
+ /** Resolve a capability token back to its plan over a raw synchronous SQLite handle. The agentic
204
+ * channel's `blackboard` family derives its board `scope` synchronously from the connection's
205
+ * capability credential, so it needs this sync path (the async {@link planKeyForToken} can't be
206
+ * awaited in a synchronous `scopeOf`). Both resolve the SAME `plans.blackboard_token` mapping, so
207
+ * the channel and the HTTP hook scope every plan's board to the identical `plan_key`. */
208
+ export function planKeyForTokenSync(db: SqliteDb, token: string): string | undefined {
209
+ if (!token) return undefined;
210
+ const rows = db.all<{ plan_key: string }>(
211
+ "SELECT plan_key FROM plans WHERE blackboard_token = ? LIMIT 1",
212
+ [token],
213
+ );
214
+ return rows[0]?.plan_key;
191
215
  }
192
216
 
193
217
  /** One incremental read: the entries after `since` (write order) plus `cursor` — the plan's current
194
- * head id. An agent polling midflight (Tier 2) passes `cursor` back as the next `since`, so it pulls
195
- * only what siblings added since its last read. `cursor` is the true head even when `since` filters
196
- * every entry out, so a caller that is fully caught up learns it is caught up (cursor unchanged). */
218
+ * head id. An agent polling midflight passes `cursor` back as the next `since`, so it pulls only
219
+ * what siblings added since its last read. `cursor` is the true head even when `since` filters every
220
+ * entry out, so a caller that is fully caught up learns it is caught up (cursor unchanged). */
197
221
  export interface BlackboardPage {
198
222
  entries: BlackboardEntry[];
199
223
  cursor: number;
@@ -204,14 +228,8 @@ export async function readBlackboardPage(
204
228
  planKey: string,
205
229
  opts: { since?: number } = {},
206
230
  ): Promise<BlackboardPage> {
207
- const rows = await blackboardTable(data).find({ plan_key: planKey });
208
- const cursor = rows.reduce((max, r) => (r.id > max ? r.id : max), 0);
209
- const since = opts.since ?? 0;
210
- const entries = rows
211
- .filter((r) => r.id > since)
212
- .sort((a, b) => a.id - b.id)
213
- .map(toEntry);
214
- return { entries, cursor };
231
+ const page = storeFor(data).readPage(planKey, { since: opts.since });
232
+ return { entries: page.entries.map(toEntry), cursor: page.cursor };
215
233
  }
216
234
 
217
235
  /** A plan's entries in write order (id asc). `since` returns only entries with `id > since`. */
@@ -247,22 +265,19 @@ export async function detectFileClaimConflicts(
247
265
  planKey: string,
248
266
  opts: { author_task?: string; files: string[]; beforeId?: number },
249
267
  ): Promise<ClaimConflict[]> {
250
- const want = new Set((opts.files ?? []).map((f) => String(f).trim()).filter((s) => s !== ""));
251
- if (want.size === 0) return [];
252
- const me = opts.author_task?.trim() || "";
253
- const beforeId = opts.beforeId;
254
- const rows = await blackboardTable(data).find({ plan_key: planKey, kind: "file-claim" });
255
- const out: ClaimConflict[] = [];
256
- for (const r of rows.slice().sort((a, b) => a.id - b.id)) {
257
- if (beforeId != null && r.id >= beforeId) continue;
258
- if ((r.author_task || "") === me) continue;
259
- for (const f of new Set(decodeFiles(r.files).map((x) => x.trim()).filter((x) => x !== ""))) {
260
- if (want.has(f)) {
261
- out.push({ file: f, author_task: r.author_task, id: r.id, body: r.body, created_at: r.created_at });
262
- }
263
- }
264
- }
265
- return out;
268
+ return storeFor(data)
269
+ .detectFileClaimConflicts(planKey, {
270
+ authorTask: opts.author_task,
271
+ files: opts.files,
272
+ beforeId: opts.beforeId,
273
+ })
274
+ .map((c) => ({
275
+ file: c.file,
276
+ author_task: c.authorTask,
277
+ id: c.id,
278
+ body: c.body,
279
+ created_at: c.createdAt,
280
+ }));
266
281
  }
267
282
 
268
283
  /** Append an entry, idempotently. A blank `body` is rejected. When a `dedupe_key` is supplied and
@@ -273,50 +288,12 @@ export async function appendEntry(
273
288
  planKey: string,
274
289
  input: BlackboardInput,
275
290
  ): Promise<{ inserted: boolean; id: number | bigint }> {
276
- const body = typeof input.body === "string" ? input.body.trim() : "";
277
- if (!body) throw new Error("blackboard entry requires a non-empty body");
278
- const table = blackboardTable(data);
279
- const dedupe_key = input.dedupe_key?.trim() || undefined;
280
- if (dedupe_key) {
281
- const existing = await table.findOne({ plan_key: planKey, dedupe_key });
282
- if (existing) return { inserted: false, id: existing.id };
283
- }
284
- const files = (input.files ?? []).map(String).map((s) => s.trim()).filter((s) => s !== "");
285
- try {
286
- const id = await table.insert({
287
- plan_key: planKey,
288
- author_task: input.author_task?.trim() || "system",
289
- kind: normalizeKind(input.kind),
290
- files: files.length ? JSON.stringify(files) : null,
291
- body,
292
- wave: typeof input.wave === "number" ? input.wave : null,
293
- dedupe_key: dedupe_key ?? null,
294
- created_at: now(),
295
- });
296
- return { inserted: true, id };
297
- } catch (err) {
298
- // Idempotent write-back under concurrency: two POSTs sharing a dedupe_key can both miss the
299
- // findOne pre-check above, then one loses the race on the UNIQUE (plan_key, dedupe_key) index.
300
- // Convert that collision into a no-op by re-reading the winner's row, so a retry never 500s.
301
- if (dedupe_key && isUniqueViolation(err)) {
302
- const existing = await table.findOne({ plan_key: planKey, dedupe_key });
303
- if (existing) return { inserted: false, id: existing.id };
304
- }
305
- throw err;
306
- }
291
+ return storeFor(data).append(planKey, {
292
+ authorTask: input.author_task,
293
+ kind: input.kind,
294
+ files: input.files,
295
+ body: input.body,
296
+ wave: input.wave,
297
+ dedupeKey: input.dedupe_key,
298
+ });
307
299
  }
308
-
309
- /** True only for a UNIQUE / PRIMARY-KEY / duplicate violation — never a foreign-key or other
310
- * constraint failure. We match the *specific* violation (extended SQLite codes, or the specific
311
- * words) rather than the bare word "constraint", so a `FOREIGN KEY constraint failed` (real data
312
- * corruption, not a benign duplicate) is always rethrown rather than silently swallowed. */
313
- export function isUniqueViolation(err: unknown): boolean {
314
- if (!err || typeof err !== "object") return false;
315
- // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
316
- const code = (err as { code?: unknown }).code;
317
- if (code === "SQLITE_CONSTRAINT_UNIQUE" || code === "SQLITE_CONSTRAINT_PRIMARYKEY") return true;
318
- // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
319
- const message = (err as { message?: unknown }).message;
320
- return typeof message === "string" &&
321
- /(unique|primary key) constraint failed|duplicate/i.test(message);
322
- }
package/app/retro.test.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import { test } from "node:test";
3
3
  import { assert, assertEquals, assertStringIncludes } from "#test-assert";
4
4
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
5
+ import { memBlackboardSource } from "../test/blackboardDb.ts";
5
6
  import { appendEntry } from "./blackboard.ts";
6
7
  import { recordTaskDelta } from "./taskDelta.ts";
7
8
  import {
@@ -46,7 +47,7 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
46
47
  },
47
48
  };
48
49
  }
49
- const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
50
+ const data = { table: (n: string, pk?: string) => tbl(n, pk), source: memBlackboardSource().source } as any as DataLayer;
50
51
  return { data, stores };
51
52
  }
52
53
 
@@ -335,6 +336,7 @@ test("maybeStartRetro: a secondary blocked-retro persistence failure still retur
335
336
  // recordRetro rethrows non-unique DB errors; simulate the blocked-retro insert hitting a
336
337
  // FOREIGN KEY failure so the persistence in the createInstance-failure handler throws.
337
338
  const failingData = {
339
+ ...data,
338
340
  table: (name: string, pk?: string) => {
339
341
  const t = (data as any).table(name, pk);
340
342
  if (name !== "plan_retros") return t;
@@ -0,0 +1,39 @@
1
+ -- Generalize the advisory blackboard onto the agentic channel's blackboard family (ADR 0056, H4 /
2
+ -- #147). Reserved prefix `025` was pre-allocated by H0 (#143) so no two sibling slices collide on
3
+ -- "the next" number.
4
+ --
5
+ -- The per-plan advisory blackboard (issues #51 / #49 D4, migration 009's `plan_blackboard`) is
6
+ -- promoted to `@nanobpm/agentic/blackboard`'s first-class, capability-scoped `agentic_blackboard`
7
+ -- store — the SAME store the new agentic-channel `blackboard` family serves, over the SAME app
8
+ -- SQLite DataLayer. The HTTP hook (`/app/api/hooks/blackboard`) and the channel now read/write one
9
+ -- canonical table (no drift surface), scoped by the plan key exactly as before.
10
+ --
11
+ -- Forward-only and additive (expand phase): a new table + indexes, then a one-shot backfill of the
12
+ -- existing `plan_blackboard` rows (`plan_key` → `scope`) so in-flight plans keep their coordination
13
+ -- history unaffected. The old `plan_blackboard` table is intentionally NOT dropped here — dropping a
14
+ -- table a release just stopped reading is a separate, later contract phase.
15
+ --
16
+ -- The CREATE statements below are the canonical `BLACKBOARD_SCHEMA_SQL` verbatim (the same DDL the
17
+ -- store's `ensureSchema()` and the agentic-channel family apply), so the migration path and the
18
+ -- `CREATE TABLE IF NOT EXISTS` path can never drift. `app/blackboard.schema.test.ts` guards this.
19
+ CREATE TABLE IF NOT EXISTS agentic_blackboard (
20
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
21
+ scope TEXT NOT NULL,
22
+ author_task TEXT NOT NULL DEFAULT 'system',
23
+ kind TEXT NOT NULL DEFAULT 'note',
24
+ files TEXT,
25
+ body TEXT NOT NULL,
26
+ wave INTEGER,
27
+ dedupe_key TEXT,
28
+ created_at TEXT NOT NULL
29
+ );
30
+ CREATE UNIQUE INDEX IF NOT EXISTS ux_agentic_blackboard_dedupe ON agentic_blackboard (scope, dedupe_key) WHERE dedupe_key IS NOT NULL;
31
+ CREATE INDEX IF NOT EXISTS idx_agentic_blackboard_scope ON agentic_blackboard (scope, id);
32
+
33
+ -- Backfill: carry every existing per-plan entry over under scope = plan_key, in write order (id asc)
34
+ -- so the new autoincrement ids stay monotonic in the original write order. The old table's UNIQUE
35
+ -- (plan_key, dedupe_key) invariant maps 1:1 onto the new (scope, dedupe_key) index, so no collision.
36
+ INSERT INTO agentic_blackboard (scope, author_task, kind, files, body, wave, dedupe_key, created_at)
37
+ SELECT plan_key, author_task, kind, files, body, wave, dedupe_key, created_at
38
+ FROM plan_blackboard
39
+ ORDER BY id;
@@ -3,35 +3,18 @@
3
3
  import { test } from "node:test";
4
4
  import { assertEquals } from "#test-assert";
5
5
  import type { AppApi } from "@nanobpm/urban";
6
+ import { memBlackboardData } from "../test/blackboardDb.ts";
6
7
  import { noopLog } from "../test/log.ts";
7
8
  import readBlackboard from "./readBlackboard.ts";
8
9
  import appendBlackboard from "./appendBlackboard.ts";
9
10
 
10
- function memApp(): { app: AppApi; stores: Record<string, any[]> } {
11
- const stores: Record<string, any[]> = {};
12
- const seq: Record<string, number> = {};
13
- function tbl(name: string, pk = "id") {
14
- const rows = (stores[name] ??= [] as any[]);
15
- return {
16
- async insert(row: any) {
17
- if (pk === "id") {
18
- const id = (seq[name] = (seq[name] ?? 0) + 1);
19
- rows.push({ id, ...row });
20
- return id;
21
- }
22
- rows.push({ ...row });
23
- return row[pk];
24
- },
25
- async find(where: any = {}) {
26
- return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
27
- },
28
- async findOne(where: any = {}) {
29
- return rows.find((r) => Object.entries(where).every(([k, v]) => r[k] === v));
30
- },
31
- };
32
- }
33
- const app = { data: { table: (n: string, pk?: string) => tbl(n, pk) }, log: noopLog() } as any as AppApi;
34
- return { app, stores };
11
+ // The operations bind to `app.data`; back it with a real in-memory SQLite DataLayer (the same
12
+ // harness `app/blackboard.test.ts` uses) so the hook path exercises the shared `BlackboardStore` /
13
+ // `agentic_blackboard` table end-to-end. `db` is exposed for row-count assertions.
14
+ function memApp(): { app: AppApi; db: { all<T>(sql: string, params?: unknown[]): T[] } } {
15
+ const { data, db } = memBlackboardData();
16
+ const app = { data, log: noopLog() } as unknown as AppApi;
17
+ return { app, db };
35
18
  }
36
19
 
37
20
  function req(method: string, query: Record<string, string>) {
@@ -106,14 +89,15 @@ test("POST with a blank body → 400", async () => {
106
89
  });
107
90
 
108
91
  test("POST is idempotent on dedupe_key (retry → 200, not a duplicate)", async () => {
109
- const { app, stores } = memApp();
92
+ const { app, db } = memApp();
110
93
  await seedPlan(app, "o/r#1", "tok");
111
94
  const body = { author_task: "t", body: "claim", dedupe_key: "t:claim:1" };
112
95
  assertEquals((await call(app, "POST", { token: "tok" }, body)).status, 201);
113
96
  const retry = await call(app, "POST", { token: "tok" }, body);
114
97
  assertEquals(retry.status, 200);
115
98
  assertEquals(retry.body.inserted, false);
116
- assertEquals(stores["plan_blackboard"].length, 1);
99
+ const [{ n }] = db.all<{ n: number }>("SELECT COUNT(*) AS n FROM agentic_blackboard WHERE scope = ?", ["o/r#1"]);
100
+ assertEquals(n, 1);
117
101
  });
118
102
 
119
103
  test("GET ?since returns only newer entries", async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.53.0",
3
+ "version": "0.54.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",
@@ -0,0 +1,108 @@
1
+ // A test-only DataLayer stub backed by a real in-memory `node:sqlite` database, for exercising the
2
+ // blackboard adapter (`app/blackboard.ts`) and the agentic `blackboard` family against a real SQLite
3
+ // engine rather than a mock. It mirrors the two surfaces the adapter uses:
4
+ // - `data.source().db` — the raw synchronous `SqliteDb` the shared `BlackboardStore` writes to,
5
+ // - `data.table(name)` — the async record gateway (only the `plans` table is needed here, for
6
+ // token→plan resolution), backed by the SAME db so the sync (`planKeyForTokenSync`) and async
7
+ // (`planKeyForToken`) paths see identical rows.
8
+ import { DatabaseSync, type SQLInputValue } from "node:sqlite";
9
+ import { afterEach } from "node:test";
10
+ import type { DataLayer } from "@nanobpm/urban";
11
+
12
+ /** The tiny synchronous SQLite handle shape the runtime + the agentic store share. */
13
+ interface SqliteDb {
14
+ exec(sql: string): void;
15
+ run(sql: string, params?: unknown[]): { changes: number; lastInsertRowid: number | bigint };
16
+ all<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[];
17
+ close(): void;
18
+ }
19
+
20
+ function coerce(p: unknown): SQLInputValue {
21
+ if (p === null) return null;
22
+ if (typeof p === "boolean") return p ? 1 : 0;
23
+ return p as SQLInputValue;
24
+ }
25
+
26
+ function wrap(db: DatabaseSync): SqliteDb {
27
+ return {
28
+ exec: (sql) => db.exec(sql),
29
+ run: (sql, params = []) => {
30
+ const r = db.prepare(sql).run(...params.map(coerce));
31
+ return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid };
32
+ },
33
+ all: <T>(sql: string, params: unknown[] = []) => db.prepare(sql).all(...params.map(coerce)) as T[],
34
+ close: () => db.close(),
35
+ };
36
+ }
37
+
38
+ // Every raw handle these helpers open is tracked here and released after each test, so call sites
39
+ // that drop the returned `close()` (most of them) don't leak native SQLite handles across the run.
40
+ const openDbs = new Set<DatabaseSync>();
41
+
42
+ afterEach(() => {
43
+ for (const raw of openDbs) closeTracked(raw);
44
+ });
45
+
46
+ /** Open a tracked in-memory db and return it with an idempotent `close()` safe to call twice. */
47
+ function openTracked(): { raw: DatabaseSync; close(): void } {
48
+ const raw = new DatabaseSync(":memory:");
49
+ openDbs.add(raw);
50
+ return { raw, close: () => closeTracked(raw) };
51
+ }
52
+
53
+ function closeTracked(raw: DatabaseSync): void {
54
+ if (openDbs.delete(raw)) raw.close();
55
+ }
56
+
57
+ /** A minimal async record gateway over the real db — just the insert/find/findOne subset the
58
+ * blackboard tests exercise on the `plans` table. */
59
+ function gateway(db: SqliteDb, name: string, pk: string) {
60
+ const quote = (id: string) => `"${id.replace(/"/g, '""')}"`;
61
+ return {
62
+ // biome-ignore lint/suspicious/noExplicitAny: test-only gateway over dynamic row shapes.
63
+ async insert(row: any): Promise<number | bigint | unknown> {
64
+ const keys = Object.keys(row).filter((k) => row[k] !== undefined);
65
+ const cols = keys.map(quote).join(", ");
66
+ const placeholders = keys.map(() => "?").join(", ");
67
+ const r = db.run(
68
+ `INSERT INTO ${quote(name)} (${cols}) VALUES (${placeholders})`,
69
+ keys.map((k) => row[k]),
70
+ );
71
+ return pk === "id" ? r.lastInsertRowid : row[pk];
72
+ },
73
+ // biome-ignore lint/suspicious/noExplicitAny: test-only gateway over dynamic row shapes.
74
+ async find(where: any = {}): Promise<any[]> {
75
+ const keys = Object.keys(where);
76
+ const clause = keys.length ? `WHERE ${keys.map((k) => `${quote(k)} = ?`).join(" AND ")}` : "";
77
+ return db.all(`SELECT * FROM ${quote(name)} ${clause}`, keys.map((k) => where[k]));
78
+ },
79
+ // biome-ignore lint/suspicious/noExplicitAny: test-only gateway over dynamic row shapes.
80
+ async findOne(where: any = {}): Promise<any> {
81
+ return (await this.find(where))[0];
82
+ },
83
+ };
84
+ }
85
+
86
+ /** A DataLayer stub over a fresh in-memory SQLite db, plus a `plans` table for token resolution. */
87
+ export function memBlackboardData(): { data: DataLayer; db: SqliteDb; close(): void } {
88
+ const { raw, close } = openTracked();
89
+ const db = wrap(raw);
90
+ db.exec("CREATE TABLE IF NOT EXISTS plans (plan_key TEXT PRIMARY KEY, blackboard_token TEXT);");
91
+ const data = {
92
+ source: () => ({ db }),
93
+ table: (name: string, pk = "id") => gateway(db, name, pk),
94
+ } as unknown as DataLayer;
95
+ return { data, db, close };
96
+ }
97
+
98
+ /**
99
+ * A bare real-sqlite handle (no tables) for tests whose fake DataLayer keeps its OTHER tables as
100
+ * in-memory arrays but still needs the blackboard's `data.source().db` seam to resolve to a real
101
+ * SQLite engine (the store applies its own schema via `ensureSchema()`). Spread its `.source` into
102
+ * the fake `data`: `{ ...fake, source: bb.source }`.
103
+ */
104
+ export function memBlackboardSource(): { source: () => { db: SqliteDb }; db: SqliteDb; close(): void } {
105
+ const { raw, close } = openTracked();
106
+ const db = wrap(raw);
107
+ return { source: () => ({ db }), db, close };
108
+ }
@@ -2,6 +2,7 @@ import { test } from "node:test";
2
2
  import { assert, assertEquals, assertStringIncludes } from "#test-assert";
3
3
  import type { DataLayer } from "@nanobpm/urban";
4
4
  import { noopLog } from "../../test/log.ts";
5
+ import { memBlackboardSource } from "../../test/blackboardDb.ts";
5
6
  import { appendEntry } from "../../app/blackboard.ts";
6
7
  import handler from "./worker.ts";
7
8
 
@@ -29,7 +30,7 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
29
30
  async update() {},
30
31
  };
31
32
  }
32
- const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
33
+ const data = { table: (n: string, pk?: string) => tbl(n, pk), source: memBlackboardSource().source } as any as DataLayer;
33
34
  return { data, stores };
34
35
  }
35
36