@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/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.
|
|
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
|
-
//
|
|
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;
|
|
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
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
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
|
-
|
|
31
|
+
import { BlackboardStore, type SqliteDb } from "@nanobpm/agentic/blackboard";
|
|
32
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
23
33
|
|
|
24
|
-
export
|
|
25
|
-
|
|
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
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
195
|
-
*
|
|
196
|
-
*
|
|
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
|
|
208
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
|