@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.
Files changed (228) hide show
  1. package/README.md +22 -0
  2. package/dist/blackboard/family.d.ts +40 -0
  3. package/dist/blackboard/family.js +151 -0
  4. package/dist/blackboard/index.d.ts +21 -0
  5. package/dist/blackboard/index.js +19 -0
  6. package/dist/blackboard/schema.d.ts +30 -0
  7. package/dist/blackboard/schema.js +42 -0
  8. package/dist/blackboard/store.d.ts +138 -0
  9. package/dist/blackboard/store.js +216 -0
  10. package/dist/blackboard/test-db.d.ts +5 -0
  11. package/dist/blackboard/test-db.js +42 -0
  12. package/dist/channel/auth.d.ts +41 -0
  13. package/dist/channel/auth.js +67 -0
  14. package/dist/channel/clock.d.ts +11 -0
  15. package/dist/channel/clock.js +4 -0
  16. package/dist/channel/connection.d.ts +75 -0
  17. package/dist/channel/connection.js +14 -0
  18. package/dist/channel/dispatch.d.ts +46 -0
  19. package/dist/channel/dispatch.js +86 -0
  20. package/dist/channel/hub.d.ts +78 -0
  21. package/dist/channel/hub.js +157 -0
  22. package/dist/channel/index.d.ts +27 -0
  23. package/dist/channel/index.js +20 -0
  24. package/dist/channel/registry.d.ts +68 -0
  25. package/dist/channel/registry.js +84 -0
  26. package/dist/channel/ws-transport.d.ts +23 -0
  27. package/dist/channel/ws-transport.js +178 -0
  28. package/dist/cockpit/boot.d.ts +68 -0
  29. package/dist/cockpit/boot.js +202 -0
  30. package/dist/cockpit/fake-dom.d.ts +37 -0
  31. package/dist/cockpit/fake-dom.js +73 -0
  32. package/dist/cockpit/index.d.ts +27 -0
  33. package/dist/cockpit/index.js +27 -0
  34. package/dist/cockpit/relay-client.d.ts +52 -0
  35. package/dist/cockpit/relay-client.js +192 -0
  36. package/dist/cockpit/render.d.ts +58 -0
  37. package/dist/cockpit/render.js +122 -0
  38. package/dist/cockpit/terminal-session.d.ts +95 -0
  39. package/dist/cockpit/terminal-session.js +123 -0
  40. package/dist/cockpit/view.d.ts +79 -0
  41. package/dist/cockpit/view.js +58 -0
  42. package/dist/demand/c8-rest.d.ts +77 -0
  43. package/dist/demand/c8-rest.js +123 -0
  44. package/dist/demand/index.d.ts +24 -0
  45. package/dist/demand/index.js +24 -0
  46. package/dist/demand/model.d.ts +68 -0
  47. package/dist/demand/model.js +118 -0
  48. package/dist/demand/taskdef.d.ts +40 -0
  49. package/dist/demand/taskdef.js +67 -0
  50. package/dist/index.d.ts +17 -0
  51. package/dist/index.js +17 -0
  52. package/dist/presence/family.d.ts +40 -0
  53. package/dist/presence/family.js +166 -0
  54. package/dist/presence/index.d.ts +19 -0
  55. package/dist/presence/index.js +17 -0
  56. package/dist/presence/schema.d.ts +20 -0
  57. package/dist/presence/schema.js +32 -0
  58. package/dist/presence/store.d.ts +130 -0
  59. package/dist/presence/store.js +191 -0
  60. package/dist/presence/test-db.d.ts +5 -0
  61. package/dist/presence/test-db.js +42 -0
  62. package/dist/protocol/conformance/frames.d.ts +24 -0
  63. package/dist/protocol/conformance/frames.js +116 -0
  64. package/dist/protocol/conformance/index.d.ts +13 -0
  65. package/dist/protocol/conformance/index.js +13 -0
  66. package/dist/protocol/conformance/malformed.d.ts +14 -0
  67. package/dist/protocol/conformance/malformed.js +44 -0
  68. package/dist/protocol/conformance/tokens.d.ts +19 -0
  69. package/dist/protocol/conformance/tokens.js +49 -0
  70. package/dist/protocol/conformance/vocab.d.ts +23 -0
  71. package/dist/protocol/conformance/vocab.js +97 -0
  72. package/dist/protocol/families.d.ts +32 -0
  73. package/dist/protocol/families.js +45 -0
  74. package/dist/protocol/frame.d.ts +45 -0
  75. package/dist/protocol/frame.js +114 -0
  76. package/dist/protocol/hex.d.ts +7 -0
  77. package/dist/protocol/hex.js +26 -0
  78. package/dist/protocol/index.d.ts +23 -0
  79. package/dist/protocol/index.js +23 -0
  80. package/dist/protocol/lanes.d.ts +36 -0
  81. package/dist/protocol/lanes.js +40 -0
  82. package/dist/protocol/payloads.d.ts +64 -0
  83. package/dist/protocol/payloads.js +122 -0
  84. package/dist/protocol/token.d.ts +34 -0
  85. package/dist/protocol/token.js +81 -0
  86. package/dist/protocol/vocab/schema.d.ts +51 -0
  87. package/dist/protocol/vocab/schema.js +218 -0
  88. package/dist/relay/incarnation.d.ts +17 -0
  89. package/dist/relay/incarnation.js +50 -0
  90. package/dist/relay/index.d.ts +25 -0
  91. package/dist/relay/index.js +22 -0
  92. package/dist/relay/relay-family.d.ts +68 -0
  93. package/dist/relay/relay-family.js +272 -0
  94. package/dist/relay/ring.d.ts +49 -0
  95. package/dist/relay/ring.js +105 -0
  96. package/dist/relay/scheduler.d.ts +72 -0
  97. package/dist/relay/scheduler.js +180 -0
  98. package/dist/relay/validate.d.ts +29 -0
  99. package/dist/relay/validate.js +39 -0
  100. package/dist/transcript/index.d.ts +18 -0
  101. package/dist/transcript/index.js +17 -0
  102. package/dist/transcript/schema.d.ts +32 -0
  103. package/dist/transcript/schema.js +48 -0
  104. package/dist/transcript/store.d.ts +192 -0
  105. package/dist/transcript/store.js +347 -0
  106. package/dist/transcript/test-db.d.ts +5 -0
  107. package/dist/transcript/test-db.js +41 -0
  108. package/dist/vocab/core-vocab.d.ts +26 -0
  109. package/dist/vocab/core-vocab.js +67 -0
  110. package/dist/vocab/diversity.d.ts +78 -0
  111. package/dist/vocab/diversity.js +89 -0
  112. package/dist/vocab/index.d.ts +22 -0
  113. package/dist/vocab/index.js +22 -0
  114. package/dist/vocab/merge.d.ts +9 -0
  115. package/dist/vocab/merge.js +104 -0
  116. package/dist/vocab/requires.d.ts +49 -0
  117. package/dist/vocab/requires.js +107 -0
  118. package/dist/vocab/resolver.d.ts +62 -0
  119. package/dist/vocab/resolver.js +149 -0
  120. package/dist/vocab/serve.d.ts +39 -0
  121. package/dist/vocab/serve.js +36 -0
  122. package/package.json +108 -0
  123. package/page/cockpit.css +114 -0
  124. package/page/cockpit.page.json +33 -0
  125. package/page/embed.html +40 -0
  126. package/page/mount.js +78 -0
  127. package/page/standalone.html +43 -0
  128. package/src/blackboard/family.test.ts +280 -0
  129. package/src/blackboard/family.ts +208 -0
  130. package/src/blackboard/index.ts +42 -0
  131. package/src/blackboard/schema.test.ts +60 -0
  132. package/src/blackboard/schema.ts +44 -0
  133. package/src/blackboard/store.test.ts +189 -0
  134. package/src/blackboard/store.ts +331 -0
  135. package/src/blackboard/test-db.ts +47 -0
  136. package/src/channel/auth.test.ts +64 -0
  137. package/src/channel/auth.ts +101 -0
  138. package/src/channel/clock.ts +14 -0
  139. package/src/channel/connection.ts +77 -0
  140. package/src/channel/dispatch.test.ts +83 -0
  141. package/src/channel/dispatch.ts +102 -0
  142. package/src/channel/hub.test.ts +335 -0
  143. package/src/channel/hub.ts +222 -0
  144. package/src/channel/index.ts +55 -0
  145. package/src/channel/registry.test.ts +73 -0
  146. package/src/channel/registry.ts +137 -0
  147. package/src/channel/ws-transport.test.ts +234 -0
  148. package/src/channel/ws-transport.ts +212 -0
  149. package/src/cockpit/boot.test.ts +374 -0
  150. package/src/cockpit/boot.ts +280 -0
  151. package/src/cockpit/fake-dom.ts +90 -0
  152. package/src/cockpit/index.ts +63 -0
  153. package/src/cockpit/relay-client.test.ts +359 -0
  154. package/src/cockpit/relay-client.ts +234 -0
  155. package/src/cockpit/render.test.ts +149 -0
  156. package/src/cockpit/render.ts +194 -0
  157. package/src/cockpit/terminal-session.test.ts +252 -0
  158. package/src/cockpit/terminal-session.ts +194 -0
  159. package/src/cockpit/view.test.ts +117 -0
  160. package/src/cockpit/view.ts +140 -0
  161. package/src/demand/c8-rest.test.ts +140 -0
  162. package/src/demand/c8-rest.ts +167 -0
  163. package/src/demand/index.ts +42 -0
  164. package/src/demand/model.test.ts +197 -0
  165. package/src/demand/model.ts +183 -0
  166. package/src/demand/taskdef.test.ts +85 -0
  167. package/src/demand/taskdef.ts +78 -0
  168. package/src/index.ts +17 -0
  169. package/src/presence/family.test.ts +252 -0
  170. package/src/presence/family.ts +205 -0
  171. package/src/presence/index.ts +26 -0
  172. package/src/presence/schema.test.ts +53 -0
  173. package/src/presence/schema.ts +34 -0
  174. package/src/presence/store.test.ts +190 -0
  175. package/src/presence/store.ts +287 -0
  176. package/src/presence/test-db.test.ts +57 -0
  177. package/src/presence/test-db.ts +47 -0
  178. package/src/protocol/conformance/corpus.test.ts +66 -0
  179. package/src/protocol/conformance/frames.ts +142 -0
  180. package/src/protocol/conformance/index.ts +29 -0
  181. package/src/protocol/conformance/malformed.ts +59 -0
  182. package/src/protocol/conformance/tokens.ts +70 -0
  183. package/src/protocol/conformance/vocab.ts +122 -0
  184. package/src/protocol/families.ts +54 -0
  185. package/src/protocol/frame.test.ts +116 -0
  186. package/src/protocol/frame.ts +171 -0
  187. package/src/protocol/hex.ts +28 -0
  188. package/src/protocol/index.ts +84 -0
  189. package/src/protocol/lanes.test.ts +82 -0
  190. package/src/protocol/lanes.ts +54 -0
  191. package/src/protocol/payloads.test.ts +91 -0
  192. package/src/protocol/payloads.ts +201 -0
  193. package/src/protocol/token.test.ts +57 -0
  194. package/src/protocol/token.ts +123 -0
  195. package/src/protocol/vocab/schema.test.ts +67 -0
  196. package/src/protocol/vocab/schema.ts +281 -0
  197. package/src/relay/incarnation.test.ts +53 -0
  198. package/src/relay/incarnation.ts +54 -0
  199. package/src/relay/index.ts +34 -0
  200. package/src/relay/integration.test.ts +135 -0
  201. package/src/relay/relay-family.test.ts +236 -0
  202. package/src/relay/relay-family.ts +336 -0
  203. package/src/relay/ring.test.ts +138 -0
  204. package/src/relay/ring.ts +136 -0
  205. package/src/relay/scheduler.test.ts +233 -0
  206. package/src/relay/scheduler.ts +208 -0
  207. package/src/relay/validate.test.ts +43 -0
  208. package/src/relay/validate.ts +44 -0
  209. package/src/transcript/index.ts +33 -0
  210. package/src/transcript/integration.test.ts +108 -0
  211. package/src/transcript/schema.test.ts +69 -0
  212. package/src/transcript/schema.ts +51 -0
  213. package/src/transcript/store.test.ts +285 -0
  214. package/src/transcript/store.ts +530 -0
  215. package/src/transcript/test-db.ts +46 -0
  216. package/src/vocab/core-vocab.test.ts +34 -0
  217. package/src/vocab/core-vocab.ts +88 -0
  218. package/src/vocab/diversity.test.ts +153 -0
  219. package/src/vocab/diversity.ts +169 -0
  220. package/src/vocab/index.ts +55 -0
  221. package/src/vocab/merge.test.ts +73 -0
  222. package/src/vocab/merge.ts +117 -0
  223. package/src/vocab/requires.test.ts +69 -0
  224. package/src/vocab/requires.ts +155 -0
  225. package/src/vocab/resolver.test.ts +118 -0
  226. package/src/vocab/resolver.ts +187 -0
  227. package/src/vocab/serve.test.ts +64 -0
  228. package/src/vocab/serve.ts +66 -0
@@ -0,0 +1,189 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { BlackboardStore, isUniqueViolation, normalizeKind } from "./store.ts";
4
+ import type { Clock } from "./store.ts";
5
+ import { openTestDb } from "./test-db.ts";
6
+
7
+ function fixedClock(start = 1_000): Clock & { set(t: number): void } {
8
+ let t = start;
9
+ return { now: () => t, set: (v: number) => (t = v) };
10
+ }
11
+
12
+ function freshStore(clock?: Clock): BlackboardStore {
13
+ const store = new BlackboardStore(openTestDb(), clock ? { clock } : {});
14
+ store.ensureSchema();
15
+ return store;
16
+ }
17
+
18
+ test("normalizeKind keeps the known kinds and defaults the rest to note", () => {
19
+ for (const k of ["file-claim", "constraint-change", "scope-change", "learning", "note"]) {
20
+ assert.equal(normalizeKind(k), k);
21
+ }
22
+ assert.equal(normalizeKind("bogus"), "note");
23
+ assert.equal(normalizeKind(undefined), "note");
24
+ assert.equal(normalizeKind(42), "note");
25
+ });
26
+
27
+ test("append stores an entry and read returns it in write order", () => {
28
+ const store = freshStore();
29
+ const a = store.append("board-1", { authorTask: "t1", kind: "note", body: "first" });
30
+ const b = store.append("board-1", { authorTask: "t2", kind: "learning", body: "second" });
31
+ assert.deepEqual([a.inserted, b.inserted], [true, true]);
32
+ assert.ok(b.id > a.id);
33
+
34
+ const entries = store.read("board-1");
35
+ assert.deepEqual(
36
+ entries.map((e) => [e.authorTask, e.kind, e.body]),
37
+ [
38
+ ["t1", "note", "first"],
39
+ ["t2", "learning", "second"],
40
+ ],
41
+ );
42
+ });
43
+
44
+ test("append defaults author to 'system', normalises unknown kinds, and trims the body", () => {
45
+ const store = freshStore();
46
+ store.append("b", { body: " padded ", kind: "weird" });
47
+ const [e] = store.read("b");
48
+ assert.equal(e?.authorTask, "system");
49
+ assert.equal(e?.kind, "note");
50
+ assert.equal(e?.body, "padded");
51
+ });
52
+
53
+ test("append rejects a blank body", () => {
54
+ const store = freshStore();
55
+ assert.throws(() => store.append("b", { body: " " }), /non-empty body/);
56
+ assert.throws(() => store.append("b", { body: "" }), /non-empty body/);
57
+ assert.equal(store.count("b"), 0);
58
+ });
59
+
60
+ test("dedupeKey makes a repeat append under the same scope a no-op", () => {
61
+ const store = freshStore();
62
+ const first = store.append("b", { body: "claim", dedupeKey: "k1" });
63
+ const again = store.append("b", { body: "claim (retry)", dedupeKey: "k1" });
64
+ assert.equal(first.inserted, true);
65
+ assert.equal(again.inserted, false);
66
+ assert.equal(again.id, first.id);
67
+ assert.equal(store.count("b"), 1);
68
+ // The original body wins — the retry did not overwrite it.
69
+ assert.equal(store.read("b")[0]?.body, "claim");
70
+ });
71
+
72
+ test("the same dedupeKey under a DIFFERENT scope is a distinct entry", () => {
73
+ const store = freshStore();
74
+ const a = store.append("board-a", { body: "x", dedupeKey: "k1" });
75
+ const b = store.append("board-b", { body: "y", dedupeKey: "k1" });
76
+ assert.equal(a.inserted, true);
77
+ assert.equal(b.inserted, true);
78
+ assert.notEqual(a.id, b.id);
79
+ assert.equal(store.count("board-a"), 1);
80
+ assert.equal(store.count("board-b"), 1);
81
+ });
82
+
83
+ test("a dedupe-less note always appends (partial unique index excludes NULL)", () => {
84
+ const store = freshStore();
85
+ store.append("b", { body: "n1" });
86
+ store.append("b", { body: "n2" });
87
+ store.append("b", { body: "n3" });
88
+ assert.equal(store.count("b"), 3);
89
+ });
90
+
91
+ test("readPage returns a cursor at the head even when since filters everything out", () => {
92
+ const store = freshStore();
93
+ store.append("b", { body: "1" });
94
+ const second = store.append("b", { body: "2" });
95
+
96
+ const caughtUp = store.readPage("b", { since: second.id });
97
+ assert.deepEqual(caughtUp.entries, []);
98
+ assert.equal(caughtUp.cursor, second.id, "cursor is the true head even with nothing new");
99
+
100
+ const empty = store.readPage("missing-board");
101
+ assert.deepEqual(empty.entries, []);
102
+ assert.equal(empty.cursor, 0, "an empty board has cursor 0");
103
+ });
104
+
105
+ test("readPage since returns only entries after the cursor (incremental read)", () => {
106
+ const store = freshStore();
107
+ const a = store.append("b", { body: "1" });
108
+ store.append("b", { body: "2" });
109
+ const page = store.readPage("b", { since: a.id });
110
+ assert.deepEqual(page.entries.map((e) => e.body), ["2"]);
111
+ });
112
+
113
+ test("reads never cross scopes", () => {
114
+ const store = freshStore();
115
+ store.append("board-a", { body: "a-only" });
116
+ store.append("board-b", { body: "b-only" });
117
+ assert.deepEqual(store.read("board-a").map((e) => e.body), ["a-only"]);
118
+ assert.deepEqual(store.read("board-b").map((e) => e.body), ["b-only"]);
119
+ });
120
+
121
+ test("files round-trip as a decoded array, blank/whitespace entries dropped", () => {
122
+ const store = freshStore();
123
+ store.append("b", { kind: "file-claim", files: ["src/a.ts", " ", "src/b.ts", ""], body: "claim" });
124
+ const [e] = store.read("b");
125
+ assert.deepEqual(e?.files, ["src/a.ts", "src/b.ts"]);
126
+ });
127
+
128
+ test("detectFileClaimConflicts surfaces a prior claim by another author on the same file", () => {
129
+ const store = freshStore();
130
+ const prior = store.append("b", { authorTask: "t1", kind: "file-claim", files: ["state.rs"], body: "t1 claims" });
131
+ // t2 is about to claim the same file — compute conflicts as the endpoint does,
132
+ // AFTER inserting t2's own claim, with beforeId = t2's new id.
133
+ const mine = store.append("b", { authorTask: "t2", kind: "file-claim", files: ["state.rs"], body: "t2 claims" });
134
+ const conflicts = store.detectFileClaimConflicts("b", { authorTask: "t2", files: ["state.rs"], beforeId: mine.id });
135
+ assert.equal(conflicts.length, 1);
136
+ assert.equal(conflicts[0]?.file, "state.rs");
137
+ assert.equal(conflicts[0]?.authorTask, "t1");
138
+ assert.equal(conflicts[0]?.id, prior.id);
139
+ });
140
+
141
+ test("detectFileClaimConflicts never reports a writer's own earlier claim", () => {
142
+ const store = freshStore();
143
+ store.append("b", { authorTask: "t1", kind: "file-claim", files: ["state.rs"], body: "t1 earlier" });
144
+ const mine = store.append("b", { authorTask: "t1", kind: "file-claim", files: ["state.rs"], body: "t1 again" });
145
+ const conflicts = store.detectFileClaimConflicts("b", { authorTask: "t1", files: ["state.rs"], beforeId: mine.id });
146
+ assert.deepEqual(conflicts, []);
147
+ });
148
+
149
+ test("detectFileClaimConflicts never reports a host/'system' writer's own earlier claim", () => {
150
+ // A host write leaves authorTask undefined/blank; `append` stores it as "system".
151
+ // Conflict detection must default the querying author to the SAME "system", or a
152
+ // host writer falsely conflicts with its own prior host claims.
153
+ const store = freshStore();
154
+ store.append("b", { kind: "file-claim", files: ["state.rs"], body: "host earlier" });
155
+ const mine = store.append("b", { authorTask: " ", kind: "file-claim", files: ["state.rs"], body: "host again" });
156
+ const conflicts = store.detectFileClaimConflicts("b", { files: ["state.rs"], beforeId: mine.id });
157
+ assert.deepEqual(conflicts, []);
158
+ });
159
+
160
+ test("detectFileClaimConflicts ignores non-file-claim kinds and other scopes", () => {
161
+ const store = freshStore();
162
+ store.append("b", { authorTask: "t1", kind: "note", files: ["state.rs"], body: "just a note" });
163
+ store.append("other", { authorTask: "t1", kind: "file-claim", files: ["state.rs"], body: "other board" });
164
+ const conflicts = store.detectFileClaimConflicts("b", { authorTask: "t2", files: ["state.rs"] });
165
+ assert.deepEqual(conflicts, []);
166
+ });
167
+
168
+ test("detectFileClaimConflicts with no files returns nothing", () => {
169
+ const store = freshStore();
170
+ store.append("b", { authorTask: "t1", kind: "file-claim", files: ["a"], body: "claim" });
171
+ assert.deepEqual(store.detectFileClaimConflicts("b", { authorTask: "t2", files: [] }), []);
172
+ });
173
+
174
+ test("created_at comes from the injected clock", () => {
175
+ const clock = fixedClock(0);
176
+ const store = freshStore(clock);
177
+ clock.set(Date.parse("2026-01-01T00:00:00.000Z"));
178
+ store.append("b", { body: "x" });
179
+ assert.equal(store.read("b")[0]?.createdAt, "2026-01-01T00:00:00.000Z");
180
+ });
181
+
182
+ test("isUniqueViolation matches unique/primary-key collisions but not other constraints", () => {
183
+ assert.equal(isUniqueViolation({ code: "SQLITE_CONSTRAINT_UNIQUE" }), true);
184
+ assert.equal(isUniqueViolation({ code: "SQLITE_CONSTRAINT_PRIMARYKEY" }), true);
185
+ assert.equal(isUniqueViolation({ message: "UNIQUE constraint failed: agentic_blackboard.scope" }), true);
186
+ assert.equal(isUniqueViolation({ message: "FOREIGN KEY constraint failed" }), false);
187
+ assert.equal(isUniqueViolation(new Error("some other error")), false);
188
+ assert.equal(isUniqueViolation(null), false);
189
+ });
@@ -0,0 +1,331 @@
1
+ /**
2
+ * The blackboard store — S7's durable layer over the app DataLayer.
3
+ *
4
+ * A per-scope advisory shared store. Agents READ it on dispatch and WRITE to it
5
+ * during their work ("I now also touch state.rs", "constraint X changed") so
6
+ * parallel siblings coordinate without a human relay. It is a faithful port of
7
+ * nano-workforce's `plan_blackboard` (issues #51 / #49 D4) — the same kinds,
8
+ * idempotent `dedupe_key` append, `file-claim` conflict reporting and
9
+ * `since`/cursor incremental reads — generalised from a hard-wired `plan_key` to
10
+ * a capability-derived `scope`, so any Urban app gets it for free.
11
+ *
12
+ * Design invariants (unchanged from the HTTP hook):
13
+ * - ADVISORY ONLY. It never gates a sequence flow; it is shared *knowledge*,
14
+ * read fresh, and is not part of deterministic replay.
15
+ * - IDEMPOTENT append. A re-POST carrying a stable `dedupeKey` collapses to one
16
+ * row (backed by a partial UNIQUE index; we also short-circuit here).
17
+ * - SCOPE-BOUND. Every row carries the `scope` it was written under; reads and
18
+ * conflict detection never cross scopes.
19
+ *
20
+ * The store speaks only the tiny synchronous SQLite subset the runtime exposes
21
+ * ({@link SqliteDb}), so it works against any app DataLayer source without
22
+ * pulling in the whole runtime (identical to the S2 presence / S6 transcript
23
+ * stores' `SqliteDb`).
24
+ */
25
+ import { BLACKBOARD_SCHEMA_SQL, BLACKBOARD_TABLE } from "./schema.ts";
26
+
27
+ /**
28
+ * The minimal synchronous SQLite handle the store needs — structurally the same
29
+ * surface the Urban runtime's DataLayer exposes (`host.openSqlite`). Kept local
30
+ * so the store depends on a shape, not on the runtime package.
31
+ */
32
+ export interface SqliteDb {
33
+ /** Execute one or more statements with no result (DDL, migrations). */
34
+ exec(sql: string): void;
35
+ /** Run a parameterised statement, returning the changed-row count. */
36
+ run(sql: string, params?: unknown[]): { changes: number; lastInsertRowid: number | bigint };
37
+ /** Run a parameterised query, returning all rows as plain objects. */
38
+ all<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[];
39
+ }
40
+
41
+ /** A monotonic wall clock, injectable for deterministic tests. */
42
+ export interface Clock {
43
+ now(): number;
44
+ }
45
+
46
+ /** The default clock: `Date.now()`. */
47
+ export const systemClock: Clock = { now: () => Date.now() };
48
+
49
+ /** The recognised blackboard entry kinds (verbatim from nano-workforce). */
50
+ export const BLACKBOARD_KINDS = ["file-claim", "constraint-change", "scope-change", "learning", "note"] as const;
51
+ export type BlackboardKind = (typeof BLACKBOARD_KINDS)[number];
52
+
53
+ /** Coerce an arbitrary `kind` to a known value, defaulting to "note". */
54
+ export function normalizeKind(kind: unknown): BlackboardKind {
55
+ return BLACKBOARD_KINDS.find((k) => k === kind) ?? "note";
56
+ }
57
+
58
+ /** What a writer supplies to {@link BlackboardStore.append}. */
59
+ export interface BlackboardInput {
60
+ /** The writing task's slug (or "system" for host writes). */
61
+ readonly authorTask?: string;
62
+ /** The entry kind; unrecognised values normalise to "note". */
63
+ readonly kind?: unknown;
64
+ /** Repo-relative paths this entry concerns (feeds `file-claim` conflicts). */
65
+ readonly files?: readonly string[];
66
+ /** The human/agent-readable note. Required and non-blank. */
67
+ readonly body: string;
68
+ /** Optional wave index the writer was dispatched in. */
69
+ readonly wave?: number | null;
70
+ /** Idempotency key; a repeat append under the same `scope` is a no-op. */
71
+ readonly dedupeKey?: string;
72
+ }
73
+
74
+ /** The parsed, agent-facing view of an entry (files decoded to an array). */
75
+ export interface BlackboardEntry {
76
+ readonly id: number;
77
+ readonly authorTask: string;
78
+ readonly kind: string;
79
+ readonly files: string[];
80
+ readonly body: string;
81
+ readonly wave: number | null;
82
+ readonly createdAt: string;
83
+ }
84
+
85
+ /**
86
+ * An advisory conflict-of-intent: a sibling has already claimed a file this
87
+ * writer is about to claim. Reported per (file, prior claim) so the later
88
+ * claimer can back off, coordinate, or escalate. First-writer-wins is advisory
89
+ * only — the blackboard NEVER locks; merge-time gates are the real safety net.
90
+ */
91
+ export interface ClaimConflict {
92
+ readonly file: string;
93
+ readonly authorTask: string;
94
+ readonly id: number;
95
+ readonly body: string;
96
+ readonly createdAt: string;
97
+ }
98
+
99
+ /** One incremental read: entries after `since` (write order) plus the `cursor`. */
100
+ export interface BlackboardPage {
101
+ readonly entries: BlackboardEntry[];
102
+ /**
103
+ * The board's current head id — the true head even when `since` filters every
104
+ * entry out, so a caller that is fully caught up learns it is caught up
105
+ * (cursor unchanged). `0` for an empty board.
106
+ */
107
+ readonly cursor: number;
108
+ }
109
+
110
+ export interface BlackboardStoreOptions {
111
+ /** Injectable clock for deterministic `created_at` in tests. Default {@link systemClock}. */
112
+ readonly clock?: Clock;
113
+ }
114
+
115
+ /** The raw DB row shape (snake_case columns) as read back from SQLite. */
116
+ interface DbRow {
117
+ id: number;
118
+ scope: string;
119
+ author_task: string;
120
+ kind: string;
121
+ files: string | null;
122
+ body: string;
123
+ wave: number | null;
124
+ dedupe_key: string | null;
125
+ created_at: string;
126
+ }
127
+
128
+ function decodeFiles(raw: string | null): string[] {
129
+ if (!raw) return [];
130
+ try {
131
+ const v: unknown = JSON.parse(raw);
132
+ return Array.isArray(v)
133
+ ? v.map((x) => String(x).trim()).filter((s) => s !== "")
134
+ : [];
135
+ } catch {
136
+ return [];
137
+ }
138
+ }
139
+
140
+ function normaliseFiles(files: readonly string[] | undefined): string[] {
141
+ return (files ?? []).map((f) => String(f).trim()).filter((s) => s !== "");
142
+ }
143
+
144
+ /**
145
+ * Canonical author identity for a write. A blank/omitted `authorTask` is a host
146
+ * write and normalises to "system". This is the single source of truth for the
147
+ * default so `append` (what gets stored) and `detectFileClaimConflicts` (whose
148
+ * claim to self-exclude) can never drift and falsely self-conflict.
149
+ */
150
+ function normaliseAuthor(authorTask: string | undefined): string {
151
+ return authorTask?.trim() || "system";
152
+ }
153
+
154
+ function toEntry(r: DbRow): BlackboardEntry {
155
+ return {
156
+ id: r.id,
157
+ authorTask: r.author_task,
158
+ kind: r.kind,
159
+ files: decodeFiles(r.files),
160
+ body: r.body,
161
+ wave: r.wave,
162
+ createdAt: r.created_at,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * True only for a UNIQUE / PRIMARY-KEY / duplicate violation — never a
168
+ * foreign-key or other constraint failure. We match the *specific* violation
169
+ * (extended SQLite codes, or the specific words) rather than the bare word
170
+ * "constraint", so a `FOREIGN KEY constraint failed` (real corruption, not a
171
+ * benign duplicate) is always rethrown rather than silently swallowed.
172
+ */
173
+ export function isUniqueViolation(err: unknown): boolean {
174
+ if (typeof err !== "object" || err === null) return false;
175
+ const code = "code" in err ? err.code : undefined;
176
+ if (code === "SQLITE_CONSTRAINT_UNIQUE" || code === "SQLITE_CONSTRAINT_PRIMARYKEY") return true;
177
+ const message = "message" in err ? err.message : undefined;
178
+ return typeof message === "string" && /(unique|primary key) constraint failed|duplicate/i.test(message);
179
+ }
180
+
181
+ export class BlackboardStore {
182
+ readonly #db: SqliteDb;
183
+ readonly #clock: Clock;
184
+
185
+ constructor(db: SqliteDb, options: BlackboardStoreOptions = {}) {
186
+ this.#db = db;
187
+ this.#clock = options.clock ?? systemClock;
188
+ }
189
+
190
+ /**
191
+ * Apply the canonical blackboard DDL (idempotent). Callers that let the app
192
+ * DataLayer migration runner apply `db/migrations/003_agentic_blackboard.sql`
193
+ * do not need this — the family module calls it so the store is usable against
194
+ * a bare source too. The DDL is identical to the migration (drift-guarded).
195
+ */
196
+ ensureSchema(): void {
197
+ this.#db.exec(BLACKBOARD_SCHEMA_SQL);
198
+ }
199
+
200
+ /**
201
+ * Append an entry to a board, idempotently. A blank `body` is rejected. When a
202
+ * `dedupeKey` is supplied and an entry already exists for it under this
203
+ * `scope`, the write is a no-op and the existing id is returned
204
+ * (`inserted: false`) — so an engine job retry re-appending the same fact
205
+ * never duplicates.
206
+ */
207
+ append(scope: string, input: BlackboardInput): { inserted: boolean; id: number } {
208
+ const body = typeof input.body === "string" ? input.body.trim() : "";
209
+ if (!body) throw new Error("blackboard entry requires a non-empty body");
210
+ const dedupeKey = input.dedupeKey?.trim() || undefined;
211
+ if (dedupeKey) {
212
+ const existing = this.#findByDedupe(scope, dedupeKey);
213
+ if (existing !== undefined) return { inserted: false, id: existing };
214
+ }
215
+ const files = normaliseFiles(input.files);
216
+ try {
217
+ const { lastInsertRowid } = this.#db.run(
218
+ `INSERT INTO ${BLACKBOARD_TABLE}
219
+ (scope, author_task, kind, files, body, wave, dedupe_key, created_at)
220
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
221
+ [
222
+ scope,
223
+ normaliseAuthor(input.authorTask),
224
+ normalizeKind(input.kind),
225
+ files.length ? JSON.stringify(files) : null,
226
+ body,
227
+ typeof input.wave === "number" ? input.wave : null,
228
+ dedupeKey ?? null,
229
+ new Date(this.#clock.now()).toISOString(),
230
+ ],
231
+ );
232
+ return { inserted: true, id: Number(lastInsertRowid) };
233
+ } catch (err) {
234
+ // Idempotent write-back under concurrency: two appends sharing a dedupeKey
235
+ // can both miss the pre-check above, then one loses the race on the UNIQUE
236
+ // (scope, dedupe_key) index. Convert that collision into a no-op by
237
+ // re-reading the winner's row, so a retry never throws.
238
+ if (dedupeKey && isUniqueViolation(err)) {
239
+ const existing = this.#findByDedupe(scope, dedupeKey);
240
+ if (existing !== undefined) return { inserted: false, id: existing };
241
+ }
242
+ throw err;
243
+ }
244
+ }
245
+
246
+ /**
247
+ * One incremental read of a board: entries with `id > since` in write order,
248
+ * plus the board's current head `cursor`. An agent polling mid-flight passes
249
+ * `cursor` back as the next `since`, so it pulls only what siblings added since
250
+ * its last read.
251
+ */
252
+ readPage(scope: string, opts: { since?: number } = {}): BlackboardPage {
253
+ const since = opts.since ?? 0;
254
+ // Read the head id independently so the cursor is the board's true head even
255
+ // when `since` filters every entry out (a caught-up poller learns it is
256
+ // caught up), and let SQL page with `id > since` against the (scope, id)
257
+ // index instead of loading the whole board to filter client-side.
258
+ const head = this.#db.all<{ cursor: number | bigint | null }>(
259
+ `SELECT MAX(id) AS cursor FROM ${BLACKBOARD_TABLE} WHERE scope = ?`,
260
+ [scope],
261
+ );
262
+ const cursor = Number(head[0]?.cursor ?? 0);
263
+ const rows = this.#db.all<DbRow>(
264
+ `SELECT * FROM ${BLACKBOARD_TABLE} WHERE scope = ? AND id > ? ORDER BY id`,
265
+ [scope, since],
266
+ );
267
+ const entries = rows.map(toEntry);
268
+ return { entries, cursor };
269
+ }
270
+
271
+ /** A board's entries in write order (id asc). `since` returns only `id > since`. */
272
+ read(scope: string, opts: { since?: number } = {}): BlackboardEntry[] {
273
+ return this.readPage(scope, opts).entries;
274
+ }
275
+
276
+ /**
277
+ * Prior `file-claim` entries by OTHER authors under this `scope` that overlap
278
+ * `files`. A writer's own earlier claim is never a conflict with itself. Pass
279
+ * `beforeId` to restrict to strictly prior claims (`id < beforeId`) — the
280
+ * family computes conflicts AFTER inserting its own claim and sets `beforeId`
281
+ * to that new id, so first-writer-wins is decided by insertion order and a
282
+ * sibling claim that raced in concurrently is still surfaced without matching
283
+ * the writer's own just-written row.
284
+ */
285
+ detectFileClaimConflicts(
286
+ scope: string,
287
+ opts: { authorTask?: string; files: readonly string[]; beforeId?: number },
288
+ ): ClaimConflict[] {
289
+ const want = new Set(normaliseFiles(opts.files));
290
+ if (want.size === 0) return [];
291
+ const me = normaliseAuthor(opts.authorTask);
292
+ const beforeId = opts.beforeId;
293
+ // Push `id < beforeId` into SQL (the common path per the docstring) so we
294
+ // scan only strictly-prior claims via the (scope, id) index rather than
295
+ // loading every file-claim for the scope and filtering client-side.
296
+ const clauses = ["scope = ?", "kind = 'file-claim'"];
297
+ const params: unknown[] = [scope];
298
+ if (beforeId != null) {
299
+ clauses.push("id < ?");
300
+ params.push(beforeId);
301
+ }
302
+ const rows = this.#db.all<DbRow>(
303
+ `SELECT * FROM ${BLACKBOARD_TABLE} WHERE ${clauses.join(" AND ")} ORDER BY id`,
304
+ params,
305
+ );
306
+ const out: ClaimConflict[] = [];
307
+ for (const r of rows) {
308
+ if ((r.author_task || "") === me) continue;
309
+ for (const f of new Set(decodeFiles(r.files))) {
310
+ if (want.has(f)) {
311
+ out.push({ file: f, authorTask: r.author_task, id: r.id, body: r.body, createdAt: r.created_at });
312
+ }
313
+ }
314
+ }
315
+ return out;
316
+ }
317
+
318
+ /** Number of entries under a scope. */
319
+ count(scope: string): number {
320
+ const rows = this.#db.all<{ n: number }>(`SELECT COUNT(*) AS n FROM ${BLACKBOARD_TABLE} WHERE scope = ?`, [scope]);
321
+ return rows[0]?.n ?? 0;
322
+ }
323
+
324
+ #findByDedupe(scope: string, dedupeKey: string): number | undefined {
325
+ const rows = this.#db.all<{ id: number }>(
326
+ `SELECT id FROM ${BLACKBOARD_TABLE} WHERE scope = ? AND dedupe_key = ?`,
327
+ [scope, dedupeKey],
328
+ );
329
+ return rows[0]?.id;
330
+ }
331
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Test-only helper: a {@link SqliteDb} backed by an in-memory `node:sqlite`
3
+ * database, mirroring the Node host adapter's `wrapNodeSqlite`. Kept out of the
4
+ * published build (see `tsconfig.build.json` exclude) — it exists solely so the
5
+ * store/family tests exercise the store against a real SQLite engine, not a mock.
6
+ */
7
+ import { DatabaseSync } from "node:sqlite";
8
+ import type { SqliteDb } from "./store.ts";
9
+
10
+ export interface TestDb extends SqliteDb {
11
+ close(): void;
12
+ }
13
+
14
+ function toParams(params: unknown[]): (string | number | bigint | null | Uint8Array)[] {
15
+ return params.map((p) => {
16
+ if (p === null) return null;
17
+ if (typeof p === "string" || typeof p === "number" || typeof p === "bigint" || p instanceof Uint8Array) {
18
+ return p;
19
+ }
20
+ if (typeof p === "boolean") return p ? 1 : 0;
21
+ // Mirror wrapNodeSqlite's `sqliteParams`: unsupported types (including
22
+ // `undefined`) throw rather than coerce, so a test can never pass on a
23
+ // parameter production would reject.
24
+ throw new TypeError(`unsupported SQLite parameter type: ${typeof p}`);
25
+ });
26
+ }
27
+
28
+ export function openTestDb(): TestDb {
29
+ const db = new DatabaseSync(":memory:");
30
+ return {
31
+ exec: (sql) => db.exec(sql),
32
+ run: (sql, params = []) => {
33
+ const stmt = db.prepare(sql);
34
+ const r = stmt.run(...toParams(params));
35
+ return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid };
36
+ },
37
+ all: <T>(sql: string, params: unknown[] = []): T[] => {
38
+ const stmt = db.prepare(sql);
39
+ // Mirror wrapNodeSqlite exactly: return the driver's row objects directly.
40
+ // A JSON round-trip would throw on `bigint` values and mangle `Uint8Array`
41
+ // blobs, diverging from production and breaking test-only reads.
42
+ // biome-ignore lint/plugin: Node sqlite returns untyped row objects; SqliteDb.all<T> is the host adapter boundary.
43
+ return stmt.all(...toParams(params)) as T[];
44
+ },
45
+ close: () => db.close(),
46
+ };
47
+ }
@@ -0,0 +1,64 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import {
4
+ AUTH_FORBIDDEN,
5
+ AUTH_UNAUTHORIZED,
6
+ sharedSecretAuthenticator,
7
+ } from "./auth.ts";
8
+ import type { HandshakeRequest } from "./connection.ts";
9
+
10
+ test("grants a valid identity token plus capability credential", async () => {
11
+ const auth = sharedSecretAuthenticator({ secret: "s3cret" });
12
+ const req: HandshakeRequest = { token: "s3cret", credential: "cap-1", remote: "1.2.3.4" };
13
+
14
+ const result = await auth(req);
15
+ assert.ok(result.ok);
16
+ assert.equal(result.grant.identity, "1.2.3.4");
17
+ assert.equal(result.grant.capability, "cap-1");
18
+ });
19
+
20
+ test("reads the token and credential from query params (blackboard-hook pattern)", async () => {
21
+ const auth = sharedSecretAuthenticator({ secret: "s3cret" });
22
+ const req: HandshakeRequest = { query: { token: "s3cret", capability: "cap-1" } };
23
+
24
+ const result = await auth(req);
25
+ assert.ok(result.ok);
26
+ });
27
+
28
+ test("rejects a wrong identity token with the unauthorized code", async () => {
29
+ const auth = sharedSecretAuthenticator({ secret: "s3cret" });
30
+ const result = await auth({ token: "nope", credential: "cap-1" });
31
+
32
+ assert.ok(!result.ok);
33
+ assert.equal(result.code, AUTH_UNAUTHORIZED);
34
+ });
35
+
36
+ test("rejects a missing capability credential with the forbidden code", async () => {
37
+ const auth = sharedSecretAuthenticator({ secret: "s3cret" });
38
+ const result = await auth({ token: "s3cret" });
39
+
40
+ assert.ok(!result.ok);
41
+ assert.equal(result.code, AUTH_FORBIDDEN);
42
+ });
43
+
44
+ test("honours a custom credential verifier and identity resolver", async () => {
45
+ const auth = sharedSecretAuthenticator({
46
+ secret: "s3cret",
47
+ identityFor: (req) => `worker:${req.query?.instance ?? "?"}`,
48
+ verifyCredential: (credential) => credential === "good",
49
+ });
50
+
51
+ const accepted = await auth({ token: "s3cret", credential: "good", query: { instance: "w7" } });
52
+ assert.ok(accepted.ok);
53
+ assert.equal(accepted.grant.identity, "worker:w7");
54
+
55
+ const rejected = await auth({ token: "s3cret", credential: "bad" });
56
+ assert.ok(!rejected.ok);
57
+ assert.equal(rejected.code, AUTH_FORBIDDEN);
58
+ });
59
+
60
+ test("can be configured to not require a credential", async () => {
61
+ const auth = sharedSecretAuthenticator({ secret: "s3cret", requireCredential: false });
62
+ const result = await auth({ token: "s3cret" });
63
+ assert.ok(result.ok);
64
+ });