@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 +7 -0
- package/app/agentic/families/blackboard.family.test.ts +189 -0
- package/app/agentic/families/blackboard.family.ts +69 -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/025_agentic_blackboard.sql +39 -0
- package/operations/blackboard.test.ts +11 -27
- package/package.json +1 -1
- package/test/blackboardDb.ts +108 -0
- package/workers/retro-gather/worker.test.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,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
|
+
});
|
package/app/blackboard.test.ts
CHANGED
|
@@ -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
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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,
|
|
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
|
-
|
|
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
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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.
|
|
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,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
|
|