@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/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,43 @@
1
+ -- Agentic visibility plane (ADR 0056, epic #142) — H3 relay transcript store (#146).
2
+ --
3
+ -- The relay family (app/agentic/families/relay.family.ts) mounts @nanobpm/agentic/relay (a bounded
4
+ -- replay ring + three-lane QoS scheduler + incarnation fence) on the app-tier agentic channel and
5
+ -- persists terminal transcripts through @nanobpm/agentic/transcript with retention-by-lifecycle:
6
+ --
7
+ -- • ephemeral streams → the relay ring is flushed to a durable transcript on job completion
8
+ -- (and the stream marked `completed`, then retired by a retention sweep);
9
+ -- • long-lived streams → chunks are retained/checkpointed so a reconnecting consumer can resume
10
+ -- from any offset (reattach), bounded by a rolling offset window.
11
+ --
12
+ -- This DDL is the forward-only, additive boot migration the DataLayer runner applies from
13
+ -- nano.app.json (`data.sources.app.migrations`). It is a byte-for-byte mirror of the package's
14
+ -- canonical `TRANSCRIPT_SCHEMA_SQL` (@nanobpm/agentic/transcript `schema.ts`), which the store also
15
+ -- applies via `ensureSchema()`. The two application paths are kept from drifting by the drift-guard
16
+ -- test `app/agentic/families/relay.family.test.ts` — divergence is a red test, not a silent boot vs.
17
+ -- store mismatch. Additive only (CREATE ... IF NOT EXISTS): it adds no column to an existing table
18
+ -- and drops nothing, so it is safe to apply forward over any earlier schema.
19
+ --
20
+ -- H0 (#143) pre-allocated this exact prefix (024) for H3 so no two sibling slices independently grab
21
+ -- "the next" number (H1=023_agentic_presence, H4=025_agentic_blackboard). `chunk_offset` (not
22
+ -- `offset`) is deliberate: OFFSET is a SQLite keyword, so the column avoids quoting in every query.
23
+ --
24
+ -- Invariants (ADR 0056): app-tier only, never the engine; the Camunda-8 job protocol (worker⇄engine)
25
+ -- is untouched — the agentic channel is the only new conversation; advisory semantics preserved (the
26
+ -- transcript never hard-locks or gates a BPMN sequence flow).
27
+ CREATE TABLE IF NOT EXISTS agentic_transcript_stream (
28
+ stream TEXT PRIMARY KEY,
29
+ lifecycle TEXT NOT NULL,
30
+ status TEXT NOT NULL DEFAULT 'open',
31
+ created_at TEXT NOT NULL,
32
+ completed_at TEXT,
33
+ first_offset INTEGER,
34
+ next_offset INTEGER NOT NULL DEFAULT 0
35
+ );
36
+ CREATE TABLE IF NOT EXISTS agentic_transcript_chunk (
37
+ stream TEXT NOT NULL,
38
+ chunk_offset INTEGER NOT NULL,
39
+ chunk TEXT NOT NULL,
40
+ appended_at TEXT NOT NULL,
41
+ PRIMARY KEY (stream, chunk_offset)
42
+ );
43
+ CREATE INDEX IF NOT EXISTS idx_agentic_transcript_stream_retention ON agentic_transcript_stream (lifecycle, status, completed_at);
@@ -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.52.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