@nanobpm/agentic 0.1.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/README.md +22 -0
- package/dist/blackboard/family.d.ts +40 -0
- package/dist/blackboard/family.js +151 -0
- package/dist/blackboard/index.d.ts +21 -0
- package/dist/blackboard/index.js +19 -0
- package/dist/blackboard/schema.d.ts +30 -0
- package/dist/blackboard/schema.js +42 -0
- package/dist/blackboard/store.d.ts +138 -0
- package/dist/blackboard/store.js +216 -0
- package/dist/blackboard/test-db.d.ts +5 -0
- package/dist/blackboard/test-db.js +42 -0
- package/dist/channel/auth.d.ts +41 -0
- package/dist/channel/auth.js +67 -0
- package/dist/channel/clock.d.ts +11 -0
- package/dist/channel/clock.js +4 -0
- package/dist/channel/connection.d.ts +75 -0
- package/dist/channel/connection.js +14 -0
- package/dist/channel/dispatch.d.ts +46 -0
- package/dist/channel/dispatch.js +86 -0
- package/dist/channel/hub.d.ts +78 -0
- package/dist/channel/hub.js +157 -0
- package/dist/channel/index.d.ts +27 -0
- package/dist/channel/index.js +20 -0
- package/dist/channel/registry.d.ts +68 -0
- package/dist/channel/registry.js +84 -0
- package/dist/channel/ws-transport.d.ts +23 -0
- package/dist/channel/ws-transport.js +178 -0
- package/dist/cockpit/boot.d.ts +68 -0
- package/dist/cockpit/boot.js +202 -0
- package/dist/cockpit/fake-dom.d.ts +37 -0
- package/dist/cockpit/fake-dom.js +73 -0
- package/dist/cockpit/index.d.ts +27 -0
- package/dist/cockpit/index.js +27 -0
- package/dist/cockpit/relay-client.d.ts +52 -0
- package/dist/cockpit/relay-client.js +192 -0
- package/dist/cockpit/render.d.ts +58 -0
- package/dist/cockpit/render.js +122 -0
- package/dist/cockpit/terminal-session.d.ts +95 -0
- package/dist/cockpit/terminal-session.js +123 -0
- package/dist/cockpit/view.d.ts +79 -0
- package/dist/cockpit/view.js +58 -0
- package/dist/demand/c8-rest.d.ts +77 -0
- package/dist/demand/c8-rest.js +123 -0
- package/dist/demand/index.d.ts +24 -0
- package/dist/demand/index.js +24 -0
- package/dist/demand/model.d.ts +68 -0
- package/dist/demand/model.js +118 -0
- package/dist/demand/taskdef.d.ts +40 -0
- package/dist/demand/taskdef.js +67 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +17 -0
- package/dist/presence/family.d.ts +40 -0
- package/dist/presence/family.js +166 -0
- package/dist/presence/index.d.ts +19 -0
- package/dist/presence/index.js +17 -0
- package/dist/presence/schema.d.ts +20 -0
- package/dist/presence/schema.js +32 -0
- package/dist/presence/store.d.ts +130 -0
- package/dist/presence/store.js +191 -0
- package/dist/presence/test-db.d.ts +5 -0
- package/dist/presence/test-db.js +42 -0
- package/dist/protocol/conformance/frames.d.ts +24 -0
- package/dist/protocol/conformance/frames.js +116 -0
- package/dist/protocol/conformance/index.d.ts +13 -0
- package/dist/protocol/conformance/index.js +13 -0
- package/dist/protocol/conformance/malformed.d.ts +14 -0
- package/dist/protocol/conformance/malformed.js +44 -0
- package/dist/protocol/conformance/tokens.d.ts +19 -0
- package/dist/protocol/conformance/tokens.js +49 -0
- package/dist/protocol/conformance/vocab.d.ts +23 -0
- package/dist/protocol/conformance/vocab.js +97 -0
- package/dist/protocol/families.d.ts +32 -0
- package/dist/protocol/families.js +45 -0
- package/dist/protocol/frame.d.ts +45 -0
- package/dist/protocol/frame.js +114 -0
- package/dist/protocol/hex.d.ts +7 -0
- package/dist/protocol/hex.js +26 -0
- package/dist/protocol/index.d.ts +23 -0
- package/dist/protocol/index.js +23 -0
- package/dist/protocol/lanes.d.ts +36 -0
- package/dist/protocol/lanes.js +40 -0
- package/dist/protocol/payloads.d.ts +64 -0
- package/dist/protocol/payloads.js +122 -0
- package/dist/protocol/token.d.ts +34 -0
- package/dist/protocol/token.js +81 -0
- package/dist/protocol/vocab/schema.d.ts +51 -0
- package/dist/protocol/vocab/schema.js +218 -0
- package/dist/relay/incarnation.d.ts +17 -0
- package/dist/relay/incarnation.js +50 -0
- package/dist/relay/index.d.ts +25 -0
- package/dist/relay/index.js +22 -0
- package/dist/relay/relay-family.d.ts +68 -0
- package/dist/relay/relay-family.js +272 -0
- package/dist/relay/ring.d.ts +49 -0
- package/dist/relay/ring.js +105 -0
- package/dist/relay/scheduler.d.ts +72 -0
- package/dist/relay/scheduler.js +180 -0
- package/dist/relay/validate.d.ts +29 -0
- package/dist/relay/validate.js +39 -0
- package/dist/transcript/index.d.ts +18 -0
- package/dist/transcript/index.js +17 -0
- package/dist/transcript/schema.d.ts +32 -0
- package/dist/transcript/schema.js +48 -0
- package/dist/transcript/store.d.ts +192 -0
- package/dist/transcript/store.js +347 -0
- package/dist/transcript/test-db.d.ts +5 -0
- package/dist/transcript/test-db.js +41 -0
- package/dist/vocab/core-vocab.d.ts +26 -0
- package/dist/vocab/core-vocab.js +67 -0
- package/dist/vocab/diversity.d.ts +78 -0
- package/dist/vocab/diversity.js +89 -0
- package/dist/vocab/index.d.ts +22 -0
- package/dist/vocab/index.js +22 -0
- package/dist/vocab/merge.d.ts +9 -0
- package/dist/vocab/merge.js +104 -0
- package/dist/vocab/requires.d.ts +49 -0
- package/dist/vocab/requires.js +107 -0
- package/dist/vocab/resolver.d.ts +62 -0
- package/dist/vocab/resolver.js +149 -0
- package/dist/vocab/serve.d.ts +39 -0
- package/dist/vocab/serve.js +36 -0
- package/package.json +108 -0
- package/page/cockpit.css +114 -0
- package/page/cockpit.page.json +33 -0
- package/page/embed.html +40 -0
- package/page/mount.js +78 -0
- package/page/standalone.html +43 -0
- package/src/blackboard/family.test.ts +280 -0
- package/src/blackboard/family.ts +208 -0
- package/src/blackboard/index.ts +42 -0
- package/src/blackboard/schema.test.ts +60 -0
- package/src/blackboard/schema.ts +44 -0
- package/src/blackboard/store.test.ts +189 -0
- package/src/blackboard/store.ts +331 -0
- package/src/blackboard/test-db.ts +47 -0
- package/src/channel/auth.test.ts +64 -0
- package/src/channel/auth.ts +101 -0
- package/src/channel/clock.ts +14 -0
- package/src/channel/connection.ts +77 -0
- package/src/channel/dispatch.test.ts +83 -0
- package/src/channel/dispatch.ts +102 -0
- package/src/channel/hub.test.ts +335 -0
- package/src/channel/hub.ts +222 -0
- package/src/channel/index.ts +55 -0
- package/src/channel/registry.test.ts +73 -0
- package/src/channel/registry.ts +137 -0
- package/src/channel/ws-transport.test.ts +234 -0
- package/src/channel/ws-transport.ts +212 -0
- package/src/cockpit/boot.test.ts +374 -0
- package/src/cockpit/boot.ts +280 -0
- package/src/cockpit/fake-dom.ts +90 -0
- package/src/cockpit/index.ts +63 -0
- package/src/cockpit/relay-client.test.ts +359 -0
- package/src/cockpit/relay-client.ts +234 -0
- package/src/cockpit/render.test.ts +149 -0
- package/src/cockpit/render.ts +194 -0
- package/src/cockpit/terminal-session.test.ts +252 -0
- package/src/cockpit/terminal-session.ts +194 -0
- package/src/cockpit/view.test.ts +117 -0
- package/src/cockpit/view.ts +140 -0
- package/src/demand/c8-rest.test.ts +140 -0
- package/src/demand/c8-rest.ts +167 -0
- package/src/demand/index.ts +42 -0
- package/src/demand/model.test.ts +197 -0
- package/src/demand/model.ts +183 -0
- package/src/demand/taskdef.test.ts +85 -0
- package/src/demand/taskdef.ts +78 -0
- package/src/index.ts +17 -0
- package/src/presence/family.test.ts +252 -0
- package/src/presence/family.ts +205 -0
- package/src/presence/index.ts +26 -0
- package/src/presence/schema.test.ts +53 -0
- package/src/presence/schema.ts +34 -0
- package/src/presence/store.test.ts +190 -0
- package/src/presence/store.ts +287 -0
- package/src/presence/test-db.test.ts +57 -0
- package/src/presence/test-db.ts +47 -0
- package/src/protocol/conformance/corpus.test.ts +66 -0
- package/src/protocol/conformance/frames.ts +142 -0
- package/src/protocol/conformance/index.ts +29 -0
- package/src/protocol/conformance/malformed.ts +59 -0
- package/src/protocol/conformance/tokens.ts +70 -0
- package/src/protocol/conformance/vocab.ts +122 -0
- package/src/protocol/families.ts +54 -0
- package/src/protocol/frame.test.ts +116 -0
- package/src/protocol/frame.ts +171 -0
- package/src/protocol/hex.ts +28 -0
- package/src/protocol/index.ts +84 -0
- package/src/protocol/lanes.test.ts +82 -0
- package/src/protocol/lanes.ts +54 -0
- package/src/protocol/payloads.test.ts +91 -0
- package/src/protocol/payloads.ts +201 -0
- package/src/protocol/token.test.ts +57 -0
- package/src/protocol/token.ts +123 -0
- package/src/protocol/vocab/schema.test.ts +67 -0
- package/src/protocol/vocab/schema.ts +281 -0
- package/src/relay/incarnation.test.ts +53 -0
- package/src/relay/incarnation.ts +54 -0
- package/src/relay/index.ts +34 -0
- package/src/relay/integration.test.ts +135 -0
- package/src/relay/relay-family.test.ts +236 -0
- package/src/relay/relay-family.ts +336 -0
- package/src/relay/ring.test.ts +138 -0
- package/src/relay/ring.ts +136 -0
- package/src/relay/scheduler.test.ts +233 -0
- package/src/relay/scheduler.ts +208 -0
- package/src/relay/validate.test.ts +43 -0
- package/src/relay/validate.ts +44 -0
- package/src/transcript/index.ts +33 -0
- package/src/transcript/integration.test.ts +108 -0
- package/src/transcript/schema.test.ts +69 -0
- package/src/transcript/schema.ts +51 -0
- package/src/transcript/store.test.ts +285 -0
- package/src/transcript/store.ts +530 -0
- package/src/transcript/test-db.ts +46 -0
- package/src/vocab/core-vocab.test.ts +34 -0
- package/src/vocab/core-vocab.ts +88 -0
- package/src/vocab/diversity.test.ts +153 -0
- package/src/vocab/diversity.ts +169 -0
- package/src/vocab/index.ts +55 -0
- package/src/vocab/merge.test.ts +73 -0
- package/src/vocab/merge.ts +117 -0
- package/src/vocab/requires.test.ts +69 -0
- package/src/vocab/requires.ts +155 -0
- package/src/vocab/resolver.test.ts +118 -0
- package/src/vocab/resolver.ts +187 -0
- package/src/vocab/serve.test.ts +64 -0
- package/src/vocab/serve.ts +66 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { setImmediate as tick } from "node:timers/promises";
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import { decodeFrame, encodeFrame } from "../protocol/index.ts";
|
|
5
|
+
import type { Frame, MessageFamily } from "../protocol/index.ts";
|
|
6
|
+
import { AgenticHub, DuplicateFamilyHandlerError, sharedSecretAuthenticator } from "../channel/index.ts";
|
|
7
|
+
import type { ChannelConnection, ChannelTransport, CloseCode, HandshakeRequest } from "../channel/index.ts";
|
|
8
|
+
import { attachBlackboardFamily, BlackboardPayloadError, BlackboardScopeError } from "./family.ts";
|
|
9
|
+
import { BlackboardStore } from "./store.ts";
|
|
10
|
+
import { openTestDb } from "./test-db.ts";
|
|
11
|
+
|
|
12
|
+
/** In-memory connection the test drives directly, capturing frames sent back. */
|
|
13
|
+
class FakeConnection implements ChannelConnection {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly handshake: HandshakeRequest;
|
|
16
|
+
readonly sent: Frame[] = [];
|
|
17
|
+
#onMessage: ((bytes: Uint8Array) => void) | undefined;
|
|
18
|
+
#onClose: ((code?: CloseCode, reason?: string) => void) | undefined;
|
|
19
|
+
|
|
20
|
+
constructor(id: string, handshake: HandshakeRequest) {
|
|
21
|
+
this.id = id;
|
|
22
|
+
this.handshake = handshake;
|
|
23
|
+
}
|
|
24
|
+
send(bytes: Uint8Array): void {
|
|
25
|
+
this.sent.push(decodeFrame(bytes));
|
|
26
|
+
}
|
|
27
|
+
close(code?: CloseCode, reason?: string): void {
|
|
28
|
+
this.#onClose?.(code, reason);
|
|
29
|
+
}
|
|
30
|
+
onMessage(listener: (bytes: Uint8Array) => void): void {
|
|
31
|
+
this.#onMessage = listener;
|
|
32
|
+
}
|
|
33
|
+
onClose(listener: (code?: CloseCode, reason?: string) => void): void {
|
|
34
|
+
this.#onClose = listener;
|
|
35
|
+
}
|
|
36
|
+
receive(bytes: Uint8Array): void {
|
|
37
|
+
this.#onMessage?.(bytes);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
class FakeTransport implements ChannelTransport {
|
|
42
|
+
readonly address = { port: 0 };
|
|
43
|
+
#onConnection: ((conn: ChannelConnection) => void) | undefined;
|
|
44
|
+
onConnection(listener: (conn: ChannelConnection) => void): void {
|
|
45
|
+
this.#onConnection = listener;
|
|
46
|
+
}
|
|
47
|
+
close(): Promise<void> {
|
|
48
|
+
return Promise.resolve();
|
|
49
|
+
}
|
|
50
|
+
accept(conn: ChannelConnection): void {
|
|
51
|
+
this.#onConnection?.(conn);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function frame(family: MessageFamily, payload: unknown, seq = 1): Uint8Array {
|
|
56
|
+
// Blackboard requests ride the `interactive` lane per the protocol conformance
|
|
57
|
+
// corpus; replies still come back on the `control`/facts lane (see `lastReply`).
|
|
58
|
+
const f: Frame = { lane: "interactive", family, seq, payload };
|
|
59
|
+
return encodeFrame(f);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const auth = sharedSecretAuthenticator({ secret: "s3cret" });
|
|
63
|
+
|
|
64
|
+
/** Connect with a capability credential — the default board scope. */
|
|
65
|
+
function connect(transport: FakeTransport, id: string, remote: string, credential = "board-cap"): FakeConnection {
|
|
66
|
+
const conn = new FakeConnection(id, { token: "s3cret", credential, remote });
|
|
67
|
+
transport.accept(conn);
|
|
68
|
+
return conn;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The single blackboard reply the connection received, decoded cast-free via JSON. */
|
|
72
|
+
function lastReply(conn: FakeConnection) {
|
|
73
|
+
const f = conn.sent.at(-1);
|
|
74
|
+
assert.ok(f, "expected a reply frame");
|
|
75
|
+
assert.equal(f.family, "blackboard");
|
|
76
|
+
assert.equal(f.lane, "control", "replies ride the control/facts lane");
|
|
77
|
+
assert.ok(f.payload && typeof f.payload === "object");
|
|
78
|
+
return JSON.parse(JSON.stringify(f.payload));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
test("append then read round-trips through the S1 seam", async () => {
|
|
82
|
+
const transport = new FakeTransport();
|
|
83
|
+
const hub = new AgenticHub({ transport, authenticator: auth, sweepIntervalMs: 0 });
|
|
84
|
+
const store = new BlackboardStore(openTestDb());
|
|
85
|
+
attachBlackboardFamily(hub, store);
|
|
86
|
+
|
|
87
|
+
const conn = connect(transport, "c1", "peer-a");
|
|
88
|
+
await tick();
|
|
89
|
+
|
|
90
|
+
conn.receive(frame("blackboard", { op: "append", authorTask: "t1", kind: "note", body: "hello" }, 1));
|
|
91
|
+
await tick();
|
|
92
|
+
const appendReply = lastReply(conn);
|
|
93
|
+
assert.equal(appendReply.op, "append");
|
|
94
|
+
assert.equal(appendReply.inserted, true);
|
|
95
|
+
assert.equal(appendReply.scope, undefined, "reply must not echo the (secret) capability scope");
|
|
96
|
+
assert.deepEqual(appendReply.conflicts, []);
|
|
97
|
+
|
|
98
|
+
conn.receive(frame("blackboard", { op: "read" }, 2));
|
|
99
|
+
await tick();
|
|
100
|
+
const readReply = lastReply(conn);
|
|
101
|
+
assert.equal(readReply.op, "read");
|
|
102
|
+
assert.equal(readReply.scope, undefined, "reply must not echo the (secret) capability scope");
|
|
103
|
+
const entries = readReply.entries;
|
|
104
|
+
assert.equal(entries.length, 1);
|
|
105
|
+
assert.equal(entries[0]?.body, "hello");
|
|
106
|
+
assert.equal(entries[0]?.authorTask, "t1");
|
|
107
|
+
assert.equal(readReply.cursor, entries[0]?.id);
|
|
108
|
+
|
|
109
|
+
await hub.close();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("the read reply echoes the request seq for correlation", async () => {
|
|
113
|
+
const transport = new FakeTransport();
|
|
114
|
+
const hub = new AgenticHub({ transport, authenticator: auth, sweepIntervalMs: 0 });
|
|
115
|
+
attachBlackboardFamily(hub, new BlackboardStore(openTestDb()));
|
|
116
|
+
const conn = connect(transport, "c1", "peer-a");
|
|
117
|
+
await tick();
|
|
118
|
+
|
|
119
|
+
conn.receive(frame("blackboard", { op: "read" }, 77));
|
|
120
|
+
await tick();
|
|
121
|
+
assert.equal(conn.sent.at(-1)?.seq, 77);
|
|
122
|
+
|
|
123
|
+
await hub.close();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("a dedupeKey append is idempotent across a retry over the channel", async () => {
|
|
127
|
+
const transport = new FakeTransport();
|
|
128
|
+
const hub = new AgenticHub({ transport, authenticator: auth, sweepIntervalMs: 0 });
|
|
129
|
+
const store = new BlackboardStore(openTestDb());
|
|
130
|
+
attachBlackboardFamily(hub, store);
|
|
131
|
+
const conn = connect(transport, "c1", "peer-a");
|
|
132
|
+
await tick();
|
|
133
|
+
|
|
134
|
+
conn.receive(frame("blackboard", { op: "append", body: "once", dedupeKey: "k1" }, 1));
|
|
135
|
+
await tick();
|
|
136
|
+
assert.equal(lastReply(conn).inserted, true);
|
|
137
|
+
|
|
138
|
+
conn.receive(frame("blackboard", { op: "append", body: "once (retry)", dedupeKey: "k1" }, 2));
|
|
139
|
+
await tick();
|
|
140
|
+
const retry = lastReply(conn);
|
|
141
|
+
assert.equal(retry.inserted, false);
|
|
142
|
+
assert.equal(store.count("board-cap"), 1);
|
|
143
|
+
|
|
144
|
+
await hub.close();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("a file-claim append reports a conflict with a prior sibling claim", async () => {
|
|
148
|
+
const transport = new FakeTransport();
|
|
149
|
+
const hub = new AgenticHub({ transport, authenticator: auth, sweepIntervalMs: 0 });
|
|
150
|
+
attachBlackboardFamily(hub, new BlackboardStore(openTestDb()));
|
|
151
|
+
|
|
152
|
+
// Both peers share the same capability → the same board.
|
|
153
|
+
const a = connect(transport, "c1", "peer-a");
|
|
154
|
+
const b = connect(transport, "c2", "peer-b");
|
|
155
|
+
await tick();
|
|
156
|
+
|
|
157
|
+
a.receive(frame("blackboard", { op: "append", authorTask: "t1", kind: "file-claim", files: ["state.rs"], body: "t1" }, 1));
|
|
158
|
+
await tick();
|
|
159
|
+
assert.deepEqual(lastReply(a).conflicts, [], "first claimer sees no conflict");
|
|
160
|
+
|
|
161
|
+
b.receive(frame("blackboard", { op: "append", authorTask: "t2", kind: "file-claim", files: ["state.rs"], body: "t2" }, 1));
|
|
162
|
+
await tick();
|
|
163
|
+
const conflicts = lastReply(b).conflicts;
|
|
164
|
+
assert.equal(conflicts.length, 1);
|
|
165
|
+
assert.equal(conflicts[0]?.file, "state.rs");
|
|
166
|
+
assert.equal(conflicts[0]?.authorTask, "t1");
|
|
167
|
+
|
|
168
|
+
await hub.close();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("boards are isolated by capability scope", async () => {
|
|
172
|
+
const transport = new FakeTransport();
|
|
173
|
+
const hub = new AgenticHub({ transport, authenticator: auth, sweepIntervalMs: 0 });
|
|
174
|
+
const store = new BlackboardStore(openTestDb());
|
|
175
|
+
attachBlackboardFamily(hub, store);
|
|
176
|
+
|
|
177
|
+
const a = connect(transport, "c1", "peer-a", "cap-A");
|
|
178
|
+
const b = connect(transport, "c2", "peer-b", "cap-B");
|
|
179
|
+
await tick();
|
|
180
|
+
|
|
181
|
+
a.receive(frame("blackboard", { op: "append", body: "a-only" }, 1));
|
|
182
|
+
b.receive(frame("blackboard", { op: "append", body: "b-only" }, 1));
|
|
183
|
+
await tick();
|
|
184
|
+
|
|
185
|
+
a.receive(frame("blackboard", { op: "read" }, 2));
|
|
186
|
+
await tick();
|
|
187
|
+
const aEntries = lastReply(a).entries;
|
|
188
|
+
assert.deepEqual(aEntries.map((e: { body: string }) => e.body), ["a-only"]);
|
|
189
|
+
|
|
190
|
+
b.receive(frame("blackboard", { op: "read" }, 2));
|
|
191
|
+
await tick();
|
|
192
|
+
const bEntries = lastReply(b).entries;
|
|
193
|
+
assert.deepEqual(bEntries.map((e: { body: string }) => e.body), ["b-only"]);
|
|
194
|
+
|
|
195
|
+
await hub.close();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("a connection with no capability scope is rejected and never touches the store", async () => {
|
|
199
|
+
const transport = new FakeTransport();
|
|
200
|
+
const errors: unknown[] = [];
|
|
201
|
+
// Authenticator that grants without requiring a credential, so scope is absent.
|
|
202
|
+
const noCredAuth = sharedSecretAuthenticator({ secret: "s3cret", requireCredential: false });
|
|
203
|
+
const hub = new AgenticHub({ transport, authenticator: noCredAuth, sweepIntervalMs: 0 });
|
|
204
|
+
const store = new BlackboardStore(openTestDb());
|
|
205
|
+
attachBlackboardFamily(hub, store, { onError: (e) => errors.push(e) });
|
|
206
|
+
|
|
207
|
+
const conn = new FakeConnection("c1", { token: "s3cret", remote: "peer-a" });
|
|
208
|
+
transport.accept(conn);
|
|
209
|
+
await tick();
|
|
210
|
+
conn.receive(frame("blackboard", { op: "append", body: "x" }, 1));
|
|
211
|
+
await tick();
|
|
212
|
+
|
|
213
|
+
assert.equal(store.count(""), 0);
|
|
214
|
+
assert.equal(conn.sent.length, 0, "no reply for a scopeless connection");
|
|
215
|
+
assert.equal(errors.length, 1);
|
|
216
|
+
assert.ok(errors[0] instanceof BlackboardScopeError);
|
|
217
|
+
|
|
218
|
+
await hub.close();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("a malformed blackboard payload is rejected and never touches the store", async () => {
|
|
222
|
+
const transport = new FakeTransport();
|
|
223
|
+
const errors: unknown[] = [];
|
|
224
|
+
const hub = new AgenticHub({ transport, authenticator: auth, sweepIntervalMs: 0 });
|
|
225
|
+
const store = new BlackboardStore(openTestDb());
|
|
226
|
+
attachBlackboardFamily(hub, store, { onError: (e) => errors.push(e) });
|
|
227
|
+
const conn = connect(transport, "c1", "peer-a");
|
|
228
|
+
await tick();
|
|
229
|
+
|
|
230
|
+
// Bad op.
|
|
231
|
+
conn.receive(frame("blackboard", { op: "delete" }, 1));
|
|
232
|
+
await tick();
|
|
233
|
+
// append with a blank body.
|
|
234
|
+
conn.receive(frame("blackboard", { op: "append", body: " " }, 2));
|
|
235
|
+
await tick();
|
|
236
|
+
|
|
237
|
+
// file-claim whose files[] carries a non-string: must be rejected outright, not
|
|
238
|
+
// silently coerced to its string subset (which would store an incomplete claim
|
|
239
|
+
// and skew conflict detection).
|
|
240
|
+
conn.receive(
|
|
241
|
+
frame("blackboard", { op: "append", authorTask: "t1", kind: "file-claim", files: ["a.rs", 123], body: "t1" }, 3),
|
|
242
|
+
);
|
|
243
|
+
await tick();
|
|
244
|
+
|
|
245
|
+
assert.equal(store.count("board-cap"), 0);
|
|
246
|
+
assert.equal(errors.length, 3);
|
|
247
|
+
assert.ok(errors.every((e) => e instanceof BlackboardPayloadError));
|
|
248
|
+
|
|
249
|
+
await hub.close();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("the blackboard family composes with a second family through the seam", async () => {
|
|
253
|
+
const transport = new FakeTransport();
|
|
254
|
+
const hub = new AgenticHub({ transport, authenticator: auth, sweepIntervalMs: 0 });
|
|
255
|
+
attachBlackboardFamily(hub, new BlackboardStore(openTestDb()));
|
|
256
|
+
// A second, distinct family attaches through the same seam with no shared edit.
|
|
257
|
+
const heartbeats: string[] = [];
|
|
258
|
+
hub.registerFamilyHandler("heartbeat", (f) => {
|
|
259
|
+
heartbeats.push(JSON.stringify(f.payload));
|
|
260
|
+
});
|
|
261
|
+
assert.deepEqual(hub.router.families().sort(), ["blackboard", "heartbeat"]);
|
|
262
|
+
|
|
263
|
+
const conn = connect(transport, "c1", "peer-a");
|
|
264
|
+
await tick();
|
|
265
|
+
conn.receive(frame("blackboard", { op: "read" }, 1));
|
|
266
|
+
conn.receive(frame("heartbeat", { instance: "w-1" }, 2));
|
|
267
|
+
await tick();
|
|
268
|
+
|
|
269
|
+
assert.equal(conn.sent.at(-1)?.family, "blackboard", "the blackboard frame routed to the blackboard handler");
|
|
270
|
+
assert.deepEqual(heartbeats, ['{"instance":"w-1"}'], "the heartbeat frame routed to its own handler");
|
|
271
|
+
|
|
272
|
+
await hub.close();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("attaching a second blackboard module to the same hub is rejected", () => {
|
|
276
|
+
const transport = new FakeTransport();
|
|
277
|
+
const hub = new AgenticHub({ transport, authenticator: auth, sweepIntervalMs: 0 });
|
|
278
|
+
attachBlackboardFamily(hub, new BlackboardStore(openTestDb()));
|
|
279
|
+
assert.throws(() => attachBlackboardFamily(hub, new BlackboardStore(openTestDb())), DuplicateFamilyHandlerError);
|
|
280
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The blackboard family module — S7's self-contained attachment to the S1 hub.
|
|
3
|
+
*
|
|
4
|
+
* This module owns the `blackboard` message family. It attaches through the hub's
|
|
5
|
+
* canonical {@link AgenticHub.registerFamilyHandler} seam — it does NOT touch a
|
|
6
|
+
* shared frame→family dispatch switch — so it composes with the other family
|
|
7
|
+
* modules (S2 presence, S5 relay) with no shared edit.
|
|
8
|
+
*
|
|
9
|
+
* It promotes nano-workforce's per-plan blackboard HTTP hook to a first-class,
|
|
10
|
+
* capability-scoped channel family. Two operations ride the one `blackboard`
|
|
11
|
+
* family, distinguished by `payload.op`:
|
|
12
|
+
*
|
|
13
|
+
* - `append` — idempotently write an entry (kinds `file-claim`,
|
|
14
|
+
* `constraint-change`, `scope-change`, `learning`, `note`). The reply carries
|
|
15
|
+
* `{ inserted, id, conflicts }`; for a `file-claim`, `conflicts` lists prior
|
|
16
|
+
* claims by OTHER authors on the same files (first-writer-wins, advisory).
|
|
17
|
+
* - `read` — return the board's entries after `since` plus the head `cursor`
|
|
18
|
+
* for incremental polling.
|
|
19
|
+
*
|
|
20
|
+
* The board `scope` is CAPABILITY-DERIVED: by default it is the connection's
|
|
21
|
+
* capability credential (the same credential the S1 handshake gated on — the
|
|
22
|
+
* per-board token nano-workforce's hook already uses as the credential). A
|
|
23
|
+
* connection can therefore only read/write the board its capability authorises,
|
|
24
|
+
* with no board id trusted from the payload. Override {@link BlackboardFamilyOptions.scopeOf}
|
|
25
|
+
* to derive the scope differently (e.g. from an ADR 0028 grant scope).
|
|
26
|
+
*
|
|
27
|
+
* Replies ride the control/facts lane so a blackboard write is never
|
|
28
|
+
* head-of-line-blocked behind a bulk-output storm (invariant 5).
|
|
29
|
+
*/
|
|
30
|
+
import { validatePayload } from "../protocol/index.ts";
|
|
31
|
+
import type { Frame } from "../protocol/index.ts";
|
|
32
|
+
import type { AgenticHub, HubConnection } from "../channel/index.ts";
|
|
33
|
+
import type { BlackboardInput, BlackboardStore, ClaimConflict } from "./store.ts";
|
|
34
|
+
|
|
35
|
+
export interface BlackboardFamilyOptions {
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the board `scope` for a connection. Default: the capability
|
|
38
|
+
* credential presented at the handshake (`handshake.credential` or the
|
|
39
|
+
* `?capability=` query param) — so each distinct capability is its own board,
|
|
40
|
+
* exactly as nano-workforce's per-plan token is. Return `undefined` to reject
|
|
41
|
+
* the frame (the connection is not scoped to any board).
|
|
42
|
+
*/
|
|
43
|
+
scopeOf?: (ctx: HubConnection) => string | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Notified of a fault this module handles while keeping the connection: a
|
|
46
|
+
* malformed blackboard payload ({@link BlackboardPayloadError}), a missing
|
|
47
|
+
* board scope, or a store error. Other exceptions propagate to {@link AgenticHub}.
|
|
48
|
+
*/
|
|
49
|
+
onError?: (err: unknown, connectionId?: string) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Handle to the attached blackboard family. */
|
|
53
|
+
export interface BlackboardFamilyHandle {
|
|
54
|
+
/** Detach is a no-op today; present for symmetry with the other family modules. */
|
|
55
|
+
stop(): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A malformed blackboard payload rejected before it touches the store. */
|
|
59
|
+
export class BlackboardPayloadError extends Error {
|
|
60
|
+
constructor(detail: string) {
|
|
61
|
+
super(`invalid blackboard payload: ${detail}`);
|
|
62
|
+
this.name = "BlackboardPayloadError";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Raised when a connection is not scoped to any board (no capability scope). */
|
|
67
|
+
export class BlackboardScopeError extends Error {
|
|
68
|
+
constructor() {
|
|
69
|
+
super("connection is not scoped to any blackboard");
|
|
70
|
+
this.name = "BlackboardScopeError";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
75
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readString(record: Record<string, unknown>, key: string): string | undefined {
|
|
79
|
+
const value = record[key];
|
|
80
|
+
return typeof value === "string" ? value : undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readStringArray(record: Record<string, unknown>, key: string): string[] | undefined {
|
|
84
|
+
const value = record[key];
|
|
85
|
+
if (!Array.isArray(value)) return undefined;
|
|
86
|
+
return value.filter((v): v is string => typeof v === "string");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The default scope resolver: the connection's capability credential. */
|
|
90
|
+
function defaultScopeOf(ctx: HubConnection): string | undefined {
|
|
91
|
+
const credential = ctx.handshake.credential ?? ctx.handshake.query?.capability;
|
|
92
|
+
const scope = credential?.trim();
|
|
93
|
+
return scope !== undefined && scope !== "" ? scope : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Attach the blackboard family (`blackboard`) to `hub`, backed by `store`.
|
|
98
|
+
* Registers one handler via the S1 seam. Returns a handle for symmetry with the
|
|
99
|
+
* other family modules.
|
|
100
|
+
*
|
|
101
|
+
* @throws DuplicateFamilyHandlerError if the `blackboard` family already has a
|
|
102
|
+
* handler on this hub (one family, one owning module).
|
|
103
|
+
*/
|
|
104
|
+
export function attachBlackboardFamily(
|
|
105
|
+
hub: AgenticHub,
|
|
106
|
+
store: BlackboardStore,
|
|
107
|
+
options: BlackboardFamilyOptions = {},
|
|
108
|
+
): BlackboardFamilyHandle {
|
|
109
|
+
store.ensureSchema();
|
|
110
|
+
const onError = options.onError ?? (() => {});
|
|
111
|
+
const scopeOf = options.scopeOf ?? defaultScopeOf;
|
|
112
|
+
|
|
113
|
+
const reject = (connectionId: string, detail: string): void => {
|
|
114
|
+
onError(new BlackboardPayloadError(detail), connectionId);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
hub.registerFamilyHandler("blackboard", (frame: Frame, ctx: HubConnection) => {
|
|
118
|
+
const payload = frame.payload;
|
|
119
|
+
const result = validatePayload("blackboard", payload);
|
|
120
|
+
if (!result.ok) {
|
|
121
|
+
reject(ctx.id, result.errors.map((e) => e.message).join("; "));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (!isPlainObject(payload)) {
|
|
125
|
+
reject(ctx.id, "not an object");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const scope = scopeOf(ctx);
|
|
130
|
+
if (scope === undefined) {
|
|
131
|
+
onError(new BlackboardScopeError(), ctx.id);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const op = payload.op;
|
|
136
|
+
try {
|
|
137
|
+
if (op === "read") {
|
|
138
|
+
handleRead(store, ctx, frame, scope, payload);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// validatePayload already constrained op ∈ {append, read}.
|
|
142
|
+
handleAppend(store, ctx, frame, scope, payload, reject);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
onError(err, ctx.id);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
stop() {},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function handleRead(
|
|
154
|
+
store: BlackboardStore,
|
|
155
|
+
ctx: HubConnection,
|
|
156
|
+
frame: Frame,
|
|
157
|
+
scope: string,
|
|
158
|
+
payload: Record<string, unknown>,
|
|
159
|
+
): void {
|
|
160
|
+
const rawSince = payload.since;
|
|
161
|
+
const since = typeof rawSince === "number" ? rawSince : undefined;
|
|
162
|
+
const page = store.readPage(scope, { since });
|
|
163
|
+
// `scope` is NOT echoed: under the default resolver it is the connection's
|
|
164
|
+
// capability credential (a secret), and it is server-derived so the client
|
|
165
|
+
// never needs it back for routing. Echoing it would only widen its exposure
|
|
166
|
+
// to logs/recordings.
|
|
167
|
+
ctx.send(reply(frame, { op: "read", cursor: page.cursor, entries: page.entries }));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function handleAppend(
|
|
171
|
+
store: BlackboardStore,
|
|
172
|
+
ctx: HubConnection,
|
|
173
|
+
frame: Frame,
|
|
174
|
+
scope: string,
|
|
175
|
+
payload: Record<string, unknown>,
|
|
176
|
+
reject: (connectionId: string, detail: string) => void,
|
|
177
|
+
): void {
|
|
178
|
+
const body = readString(payload, "body");
|
|
179
|
+
if (body === undefined || body.trim() === "") {
|
|
180
|
+
reject(ctx.id, "append requires a non-empty body");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const files = readStringArray(payload, "files");
|
|
184
|
+
const wave = typeof payload.wave === "number" ? payload.wave : undefined;
|
|
185
|
+
const input: BlackboardInput = {
|
|
186
|
+
authorTask: readString(payload, "authorTask"),
|
|
187
|
+
kind: payload.kind,
|
|
188
|
+
files,
|
|
189
|
+
body,
|
|
190
|
+
wave,
|
|
191
|
+
dedupeKey: readString(payload, "dedupeKey"),
|
|
192
|
+
};
|
|
193
|
+
const { inserted, id } = store.append(scope, input);
|
|
194
|
+
|
|
195
|
+
// Conflict-of-intent is reported only for a file-claim carrying files: prior
|
|
196
|
+
// claims by OTHER authors on the same files, decided by insertion order
|
|
197
|
+
// (beforeId = this row's id), exactly as the HTTP hook did.
|
|
198
|
+
let conflicts: ClaimConflict[] = [];
|
|
199
|
+
if (input.kind === "file-claim" && files && files.length > 0) {
|
|
200
|
+
conflicts = store.detectFileClaimConflicts(scope, { authorTask: input.authorTask, files, beforeId: id });
|
|
201
|
+
}
|
|
202
|
+
ctx.send(reply(frame, { op: "append", inserted, id, conflicts }));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Build a control-lane blackboard reply that echoes the request `seq` for correlation. */
|
|
206
|
+
function reply(request: Frame, payload: unknown): Frame {
|
|
207
|
+
return { lane: "control", family: "blackboard", seq: request.seq, payload };
|
|
208
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nanobpm/agentic-blackboard — the blackboard family for the Nano agentic
|
|
3
|
+
* protocol (ADR 0056, slice S7).
|
|
4
|
+
*
|
|
5
|
+
* Promotes nano-workforce's per-plan blackboard HTTP hook to a first-class,
|
|
6
|
+
* capability-scoped `blackboard` channel-message family: a durable per-scope
|
|
7
|
+
* coordination store ({@link BlackboardStore}) with idempotent `dedupeKey`
|
|
8
|
+
* append, `file-claim` conflict reporting and `since`/cursor incremental reads,
|
|
9
|
+
* plus a self-contained family module ({@link attachBlackboardFamily}) that
|
|
10
|
+
* attaches to the S1 hub through its `registerFamilyHandler(family, handler)`
|
|
11
|
+
* seam — never a shared dispatch switch. Any Urban app gets the blackboard for
|
|
12
|
+
* free by attaching this module.
|
|
13
|
+
*
|
|
14
|
+
* The wire contract lives in `@nanobpm/agentic-protocol`; the hub in
|
|
15
|
+
* `@nanobpm/agentic-channel`. This package builds on both.
|
|
16
|
+
*/
|
|
17
|
+
export {
|
|
18
|
+
BlackboardStore,
|
|
19
|
+
isUniqueViolation,
|
|
20
|
+
normalizeKind,
|
|
21
|
+
systemClock,
|
|
22
|
+
BLACKBOARD_KINDS,
|
|
23
|
+
} from "./store.ts";
|
|
24
|
+
export type {
|
|
25
|
+
BlackboardEntry,
|
|
26
|
+
BlackboardInput,
|
|
27
|
+
BlackboardKind,
|
|
28
|
+
BlackboardPage,
|
|
29
|
+
BlackboardStoreOptions,
|
|
30
|
+
ClaimConflict,
|
|
31
|
+
Clock,
|
|
32
|
+
SqliteDb,
|
|
33
|
+
} from "./store.ts";
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
attachBlackboardFamily,
|
|
37
|
+
BlackboardPayloadError,
|
|
38
|
+
BlackboardScopeError,
|
|
39
|
+
} from "./family.ts";
|
|
40
|
+
export type { BlackboardFamilyOptions, BlackboardFamilyHandle } from "./family.ts";
|
|
41
|
+
|
|
42
|
+
export { BLACKBOARD_TABLE, BLACKBOARD_SCHEMA_SQL } from "./schema.ts";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { BLACKBOARD_SCHEMA_SQL, BLACKBOARD_TABLE } from "./schema.ts";
|
|
6
|
+
import { BlackboardStore } from "./store.ts";
|
|
7
|
+
import { openTestDb } from "./test-db.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Normalise SQL for a drift comparison: drop `-- …` line comments, collapse all
|
|
11
|
+
* runs of whitespace to a single space, and trim. Two DDL scripts that create the
|
|
12
|
+
* same objects with the same columns normalise identically regardless of
|
|
13
|
+
* comments/indentation.
|
|
14
|
+
*/
|
|
15
|
+
function normaliseSql(sql: string): string {
|
|
16
|
+
return sql
|
|
17
|
+
.split("\n")
|
|
18
|
+
.map((line) => line.replace(/--.*$/, ""))
|
|
19
|
+
.join("\n")
|
|
20
|
+
.replace(/\s+/g, " ")
|
|
21
|
+
.trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const migrationPath = fileURLToPath(new URL("../../../../db/migrations/003_agentic_blackboard.sql", import.meta.url));
|
|
25
|
+
|
|
26
|
+
test("the boot migration and BlackboardStore's DDL do not drift", () => {
|
|
27
|
+
const migrationSql = readFileSync(migrationPath, "utf8");
|
|
28
|
+
assert.equal(
|
|
29
|
+
normaliseSql(migrationSql),
|
|
30
|
+
normaliseSql(BLACKBOARD_SCHEMA_SQL),
|
|
31
|
+
"db/migrations/003_agentic_blackboard.sql must match BLACKBOARD_SCHEMA_SQL — update both together",
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("the boot migration is forward-only and additive (IF NOT EXISTS, no drops/alters)", () => {
|
|
36
|
+
const migrationSql = readFileSync(migrationPath, "utf8");
|
|
37
|
+
assert.match(migrationSql, /CREATE TABLE IF NOT EXISTS agentic_blackboard/);
|
|
38
|
+
assert.doesNotMatch(migrationSql, /\bDROP\b/i);
|
|
39
|
+
assert.doesNotMatch(migrationSql, /\bALTER\b/i);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("the migration takes prefix 003, after S2's 001 and S6's 002", () => {
|
|
43
|
+
// Numbering is a shared epic surface (see migration header). Guard the prefix so
|
|
44
|
+
// a rebase that renumbers is caught here rather than at merge time.
|
|
45
|
+
// `fileURLToPath` yields OS-native separators (backslashes on Windows), so match
|
|
46
|
+
// either separator to keep this prefix guard portable.
|
|
47
|
+
assert.match(migrationPath, /[/\\]003_agentic_blackboard\.sql$/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("ensureSchema creates the blackboard table idempotently", () => {
|
|
51
|
+
const db = openTestDb();
|
|
52
|
+
const store = new BlackboardStore(db);
|
|
53
|
+
store.ensureSchema();
|
|
54
|
+
store.ensureSchema();
|
|
55
|
+
const tables = db.all<{ name: string }>("SELECT name FROM sqlite_master WHERE type='table' AND name = ?", [
|
|
56
|
+
BLACKBOARD_TABLE,
|
|
57
|
+
]);
|
|
58
|
+
assert.equal(tables.length, 1);
|
|
59
|
+
db.close();
|
|
60
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical blackboard schema.
|
|
3
|
+
*
|
|
4
|
+
* The DDL here is the single source of truth the {@link BlackboardStore} applies
|
|
5
|
+
* through {@link BlackboardStore.ensureSchema}. The very same statements are
|
|
6
|
+
* mirrored in the app-boot migration `db/migrations/003_agentic_blackboard.sql`
|
|
7
|
+
* (applied by the DataLayer migration runner). To keep those two application
|
|
8
|
+
* paths from drifting, `schema.test.ts` normalises both and asserts they are
|
|
9
|
+
* statement-for-statement identical — divergence is a red test, not a silent
|
|
10
|
+
* production/boot mismatch.
|
|
11
|
+
*
|
|
12
|
+
* The table promotes nano-workforce's per-plan `plan_blackboard` HTTP hook to a
|
|
13
|
+
* first-class channel-family store: the same idempotent-append (`dedupe_key`),
|
|
14
|
+
* conflict-of-intent (`file-claim`) and incremental-read (`id`/cursor) semantics,
|
|
15
|
+
* generalised from a hard-wired `plan_key` to a capability-derived `scope` so any
|
|
16
|
+
* Urban app gets it for free.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The blackboard table name. */
|
|
20
|
+
export const BLACKBOARD_TABLE = "agentic_blackboard";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The canonical blackboard DDL. Forward-only and additive; every column added
|
|
24
|
+
* here must also be added to the boot migration (the drift guard enforces it).
|
|
25
|
+
*
|
|
26
|
+
* Idempotency: the partial UNIQUE index over `(scope, dedupe_key)` (NULLs
|
|
27
|
+
* excluded, so a dedupe-less note always appends) collapses a re-POST carrying a
|
|
28
|
+
* stable `dedupe_key` to one row — the engine may re-activate a job on retry, so
|
|
29
|
+
* a repeated append must be a no-op. `(scope, id)` lists a board's entries in
|
|
30
|
+
* write order and drives the `since`/cursor incremental read.
|
|
31
|
+
*/
|
|
32
|
+
export const BLACKBOARD_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${BLACKBOARD_TABLE} (
|
|
33
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
34
|
+
scope TEXT NOT NULL,
|
|
35
|
+
author_task TEXT NOT NULL DEFAULT 'system',
|
|
36
|
+
kind TEXT NOT NULL DEFAULT 'note',
|
|
37
|
+
files TEXT,
|
|
38
|
+
body TEXT NOT NULL,
|
|
39
|
+
wave INTEGER,
|
|
40
|
+
dedupe_key TEXT,
|
|
41
|
+
created_at TEXT NOT NULL
|
|
42
|
+
);
|
|
43
|
+
CREATE UNIQUE INDEX IF NOT EXISTS ux_${BLACKBOARD_TABLE}_dedupe ON ${BLACKBOARD_TABLE} (scope, dedupe_key) WHERE dedupe_key IS NOT NULL;
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_${BLACKBOARD_TABLE}_scope ON ${BLACKBOARD_TABLE} (scope, id);`;
|