@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,287 @@
1
+ /**
2
+ * The presence & registry store — S2's durable layer over the app DataLayer.
3
+ *
4
+ * S1 owns *connection* liveness in-memory (touch-on-frame + a TTL sweep that
5
+ * closes silent sockets). S2 layers a durable *presence* registry on top: one
6
+ * row per registered worker instance, carrying its declared capability
7
+ * (cognition/weight/family/host — an enrolment attribute, never a routing
8
+ * token), the connection it registered on, and its own `last_seen` liveness
9
+ * refreshed by heartbeats. Rows age out on the presence TTL via {@link sweep}.
10
+ *
11
+ * The store speaks only the tiny synchronous SQLite subset the runtime exposes
12
+ * ({@link SqliteDb}), so it works against any app DataLayer source without
13
+ * pulling in the whole runtime.
14
+ */
15
+ import type { Capability } from "../protocol/index.ts";
16
+ import { systemClock } from "../channel/index.ts";
17
+ import type { Clock } from "../channel/index.ts";
18
+ import { PRESENCE_SCHEMA_SQL, PRESENCE_TABLE } from "./schema.ts";
19
+
20
+ /**
21
+ * The minimal synchronous SQLite handle the store needs — structurally the same
22
+ * surface the Urban runtime's DataLayer exposes (`host.openSqlite`). Kept local
23
+ * so the store depends on a shape, not on the runtime package.
24
+ */
25
+ export interface SqliteDb {
26
+ /** Execute one or more statements with no result (DDL, migrations). */
27
+ exec(sql: string): void;
28
+ /** Run a parameterised statement, returning the changed-row count. */
29
+ run(sql: string, params?: unknown[]): { changes: number; lastInsertRowid: number | bigint };
30
+ /** Run a parameterised query, returning all rows as plain objects. */
31
+ all<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[];
32
+ }
33
+
34
+ /** A durable presence row for one registered worker instance. */
35
+ export interface PresenceRow {
36
+ /** The worker instance id (`register.instance`) — the primary key. */
37
+ readonly instance: string;
38
+ /** The channel connection the instance last registered on. */
39
+ readonly connectionId: string;
40
+ /** The authenticated principal (ADR 0028 identity) of that connection. */
41
+ readonly identity: string;
42
+ /** The declared enrolment capability (never a routing token). */
43
+ readonly capability: Capability;
44
+ /** When the instance first registered, ISO-8601. */
45
+ readonly registeredAt: string;
46
+ /** Last liveness refresh (register/heartbeat), epoch ms. */
47
+ readonly lastSeen: number;
48
+ }
49
+
50
+ /** Input to {@link PresenceStore.register}. */
51
+ export interface RegisterInput {
52
+ readonly instance: string;
53
+ readonly connectionId: string;
54
+ readonly identity: string;
55
+ readonly capability: Capability;
56
+ }
57
+
58
+ export interface PresenceStoreOptions {
59
+ /** Presence liveness TTL in ms; a row unseen for longer ages out. Default 30000. */
60
+ ttlMs?: number;
61
+ /** Injectable clock for deterministic tests. Default {@link systemClock}. */
62
+ clock?: Clock;
63
+ }
64
+
65
+ const DEFAULT_TTL_MS = 30_000;
66
+
67
+ /**
68
+ * Raised when a `register` would overwrite an instance already owned by a
69
+ * different authenticated identity. Presence rows are bound to the identity
70
+ * that first registered them, so one authenticated peer can never take over
71
+ * (or change the identity of) another peer's instance.
72
+ */
73
+ export class PresenceOwnershipError extends Error {
74
+ readonly instance: string;
75
+ constructor(instance: string) {
76
+ super(`instance "${instance}" is registered to a different identity`);
77
+ this.name = "PresenceOwnershipError";
78
+ this.instance = instance;
79
+ }
80
+ }
81
+
82
+ /** The raw DB row shape (snake_case columns) as read back from SQLite. */
83
+ interface DbRow {
84
+ instance: string;
85
+ connection_id: string;
86
+ identity: string;
87
+ cognition: string | null;
88
+ weight: number | null;
89
+ family: string | null;
90
+ host: string | null;
91
+ registered_at: string;
92
+ last_seen: number;
93
+ }
94
+
95
+ function buildCapability(row: DbRow): Capability {
96
+ const cap: {
97
+ cognition?: string;
98
+ weight?: number;
99
+ family?: string;
100
+ host?: string;
101
+ } = {};
102
+ if (row.cognition !== null) cap.cognition = row.cognition;
103
+ if (row.weight !== null) cap.weight = row.weight;
104
+ if (row.family !== null) cap.family = row.family;
105
+ if (row.host !== null) cap.host = row.host;
106
+ return cap;
107
+ }
108
+
109
+ function toRow(row: DbRow): PresenceRow {
110
+ return {
111
+ instance: row.instance,
112
+ connectionId: row.connection_id,
113
+ identity: row.identity,
114
+ capability: buildCapability(row),
115
+ registeredAt: row.registered_at,
116
+ lastSeen: row.last_seen,
117
+ };
118
+ }
119
+
120
+ export class PresenceStore {
121
+ readonly #db: SqliteDb;
122
+ readonly #ttlMs: number;
123
+ readonly #clock: Clock;
124
+
125
+ constructor(db: SqliteDb, options: PresenceStoreOptions = {}) {
126
+ this.#db = db;
127
+ this.#ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
128
+ this.#clock = options.clock ?? systemClock;
129
+ }
130
+
131
+ /** The presence liveness TTL in ms. */
132
+ get ttlMs(): number {
133
+ return this.#ttlMs;
134
+ }
135
+
136
+ /**
137
+ * Apply the canonical presence DDL (idempotent). Callers that let the app
138
+ * DataLayer migration runner apply `db/migrations/001_agentic_presence.sql`
139
+ * do not need this — but the family module calls it so the store is usable
140
+ * against a bare source too. The DDL is identical to the migration (guarded).
141
+ */
142
+ ensureSchema(): void {
143
+ this.#db.exec(PRESENCE_SCHEMA_SQL);
144
+ }
145
+
146
+ /**
147
+ * Register (or re-register) an instance. A first registration stamps
148
+ * `registered_at`; a re-registration (e.g. after a reconnect on a new
149
+ * connection) keeps the original `registered_at` and refreshes everything
150
+ * else, including `last_seen`. Returns the stored row.
151
+ *
152
+ * Ownership is bound to the authenticated `identity` that first registered the
153
+ * instance: a re-register from the same identity (the reconnect case) is
154
+ * allowed, but one from a *different* identity is rejected with a
155
+ * {@link PresenceOwnershipError} and leaves the existing row untouched — no
156
+ * peer can take over another peer's instance or rewrite its identity.
157
+ */
158
+ register(input: RegisterInput): PresenceRow {
159
+ const now = this.#clock.now();
160
+ const cap = input.capability;
161
+ this.#db.run(
162
+ `INSERT INTO ${PRESENCE_TABLE}
163
+ (instance, connection_id, identity, cognition, weight, family, host, registered_at, last_seen)
164
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
165
+ ON CONFLICT(instance) DO UPDATE SET
166
+ connection_id = excluded.connection_id,
167
+ identity = excluded.identity,
168
+ cognition = excluded.cognition,
169
+ weight = excluded.weight,
170
+ family = excluded.family,
171
+ host = excluded.host,
172
+ last_seen = excluded.last_seen
173
+ WHERE ${PRESENCE_TABLE}.identity = excluded.identity`,
174
+ [
175
+ input.instance,
176
+ input.connectionId,
177
+ input.identity,
178
+ cap.cognition ?? null,
179
+ cap.weight ?? null,
180
+ cap.family ?? null,
181
+ cap.host ?? null,
182
+ new Date(now).toISOString(),
183
+ now,
184
+ ],
185
+ );
186
+ // Enforce ownership on the *persisted* row rather than the driver's
187
+ // changed-row count. The identity guard in the UPSERT's `WHERE` leaves a
188
+ // foreign-owned row untouched, so after the statement the stored identity
189
+ // is authoritative: if it differs from the caller's, this register was a
190
+ // rejected takeover. Reading it back keeps the invariant independent of how
191
+ // SQLite counts UPSERT changes (which counts WHERE-matched rows, not just
192
+ // value-changing ones), so a same-identity no-op re-register never trips it.
193
+ const row = this.get(input.instance);
194
+ if (row === undefined) {
195
+ throw new Error(`presence row vanished immediately after register: ${input.instance}`);
196
+ }
197
+ if (row.identity !== input.identity) {
198
+ throw new PresenceOwnershipError(input.instance);
199
+ }
200
+ return row;
201
+ }
202
+
203
+ /**
204
+ * Refresh an instance's liveness to now. Returns `true` if the instance was
205
+ * registered, `false` if there is no such row (a heartbeat before register).
206
+ * When `identity` is given, the refresh is scoped to the owning identity, so a
207
+ * heartbeat from a foreign identity is a silent no-op (and cannot probe for
208
+ * the existence of another peer's instance).
209
+ */
210
+ heartbeat(instance: string, identity?: string): boolean {
211
+ const { changes } =
212
+ identity === undefined
213
+ ? this.#db.run(`UPDATE ${PRESENCE_TABLE} SET last_seen = ? WHERE instance = ?`, [this.#clock.now(), instance])
214
+ : this.#db.run(`UPDATE ${PRESENCE_TABLE} SET last_seen = ? WHERE instance = ? AND identity = ?`, [
215
+ this.#clock.now(),
216
+ instance,
217
+ identity,
218
+ ]);
219
+ return changes > 0;
220
+ }
221
+
222
+ /**
223
+ * Remove an instance's presence row. Returns `true` if a row was removed. When
224
+ * `identity` is given, the removal is scoped to the owning identity, so a
225
+ * deregister from a foreign identity is a silent no-op.
226
+ */
227
+ deregister(instance: string, identity?: string): boolean {
228
+ const { changes } =
229
+ identity === undefined
230
+ ? this.#db.run(`DELETE FROM ${PRESENCE_TABLE} WHERE instance = ?`, [instance])
231
+ : this.#db.run(`DELETE FROM ${PRESENCE_TABLE} WHERE instance = ? AND identity = ?`, [instance, identity]);
232
+ return changes > 0;
233
+ }
234
+
235
+ /**
236
+ * Remove every presence row registered on a now-dead connection (e.g. one S1
237
+ * closed on its own liveness sweep). Returns the removed instance ids so the
238
+ * caller can react. Presence also ages out via {@link sweep}; this is the
239
+ * eager path when a disconnect is observed.
240
+ */
241
+ removeByConnection(connectionId: string): string[] {
242
+ const removed = this.#db
243
+ .all<{ instance: string }>(`SELECT instance FROM ${PRESENCE_TABLE} WHERE connection_id = ?`, [connectionId])
244
+ .map((r) => r.instance);
245
+ if (removed.length > 0) {
246
+ this.#db.run(`DELETE FROM ${PRESENCE_TABLE} WHERE connection_id = ?`, [connectionId]);
247
+ }
248
+ return removed;
249
+ }
250
+
251
+ /**
252
+ * Age out every presence row whose `last_seen` is older than the TTL and
253
+ * return the removed rows. `now` defaults to the clock; pass an explicit value
254
+ * for deterministic tests. Matches S1's liveness predicate (`now - lastSeen >
255
+ * ttl`).
256
+ */
257
+ sweep(now: number = this.#clock.now()): PresenceRow[] {
258
+ const cutoff = now - this.#ttlMs;
259
+ const stale = this.#db
260
+ .all<DbRow>(`SELECT * FROM ${PRESENCE_TABLE} WHERE last_seen < ?`, [cutoff])
261
+ .map(toRow);
262
+ if (stale.length > 0) {
263
+ this.#db.run(`DELETE FROM ${PRESENCE_TABLE} WHERE last_seen < ?`, [cutoff]);
264
+ }
265
+ return stale;
266
+ }
267
+
268
+ /** Look up a single instance's presence row. */
269
+ get(instance: string): PresenceRow | undefined {
270
+ const rows = this.#db.all<DbRow>(`SELECT * FROM ${PRESENCE_TABLE} WHERE instance = ?`, [instance]);
271
+ const row = rows[0];
272
+ return row === undefined ? undefined : toRow(row);
273
+ }
274
+
275
+ /** Every presence row, ordered by first registration then instance id. */
276
+ list(): PresenceRow[] {
277
+ return this.#db
278
+ .all<DbRow>(`SELECT * FROM ${PRESENCE_TABLE} ORDER BY registered_at, instance`)
279
+ .map(toRow);
280
+ }
281
+
282
+ /** Number of registered instances. */
283
+ count(): number {
284
+ const rows = this.#db.all<{ n: number }>(`SELECT COUNT(*) AS n FROM ${PRESENCE_TABLE}`);
285
+ return rows[0]?.n ?? 0;
286
+ }
287
+ }
@@ -0,0 +1,57 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { openTestDb } from "./test-db.ts";
4
+
5
+ /**
6
+ * The in-memory test adapter must mirror the Node host adapter's `wrapNodeSqlite`
7
+ * exactly: it rejects unsupported SQLite parameter types (including `undefined`)
8
+ * instead of silently coercing them, so a test can never pass on a parameter
9
+ * production would throw on.
10
+ */
11
+ test("openTestDb rejects unsupported SQLite parameter types like the host adapter", () => {
12
+ const db = openTestDb();
13
+ try {
14
+ db.exec("CREATE TABLE t (v)");
15
+ assert.throws(() => db.run("INSERT INTO t (v) VALUES (?)", [{}]), TypeError);
16
+ assert.throws(() => db.run("INSERT INTO t (v) VALUES (?)", [undefined]), TypeError);
17
+ assert.throws(() => db.run("INSERT INTO t (v) VALUES (?)", [Symbol("x")]), TypeError);
18
+ } finally {
19
+ db.close();
20
+ }
21
+ });
22
+
23
+ test("openTestDb accepts the SQLite-native parameter types", () => {
24
+ const db = openTestDb();
25
+ try {
26
+ db.exec("CREATE TABLE t (v)");
27
+ db.run("INSERT INTO t (v) VALUES (?)", ["s"]);
28
+ db.run("INSERT INTO t (v) VALUES (?)", [1]);
29
+ db.run("INSERT INTO t (v) VALUES (?)", [10n]);
30
+ db.run("INSERT INTO t (v) VALUES (?)", [null]);
31
+ db.run("INSERT INTO t (v) VALUES (?)", [true]);
32
+ db.run("INSERT INTO t (v) VALUES (?)", [new Uint8Array([1, 2])]);
33
+ const rows = db.all<{ n: number }>("SELECT COUNT(*) AS n FROM t");
34
+ assert.equal(rows[0]?.n, 6);
35
+ } finally {
36
+ db.close();
37
+ }
38
+ });
39
+
40
+ /**
41
+ * `all()` must return the driver's row objects verbatim, exactly like the host
42
+ * `wrapNodeSqlite`. A JSON clone would mangle `Uint8Array` blobs into plain
43
+ * objects (and throw outright on `bigint`), so guard that blobs round-trip as
44
+ * real `Uint8Array` instances.
45
+ */
46
+ test("openTestDb.all returns Uint8Array blobs intact, like the host adapter", () => {
47
+ const db = openTestDb();
48
+ try {
49
+ db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, blob BLOB)");
50
+ db.run("INSERT INTO t (id, blob) VALUES (?, ?)", [1, new Uint8Array([1, 2, 3])]);
51
+ const rows = db.all<{ blob: Uint8Array }>("SELECT blob FROM t WHERE id = ?", [1]);
52
+ assert.ok(rows[0]?.blob instanceof Uint8Array);
53
+ assert.deepEqual([...(rows[0]?.blob ?? [])], [1, 2, 3]);
54
+ } finally {
55
+ db.close();
56
+ }
57
+ });
@@ -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,66 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { MESSAGE_FAMILIES } from "../families.ts";
4
+ import { QOS_LANES } from "../lanes.ts";
5
+ import { GOLDEN_FRAMES } from "./frames.ts";
6
+ import type { FrameDirection } from "./frames.ts";
7
+ import { MALFORMED_FRAMES } from "./malformed.ts";
8
+ import type { FrameDecodeErrorCode } from "../frame.ts";
9
+
10
+ // The corpus is only a defence against drift if it is exhaustive. These tests
11
+ // fail if a new family, lane, or decode-error code is added without a covering
12
+ // vector — forcing the corpus to grow with the contract.
13
+
14
+ test("golden frames cover every message family", () => {
15
+ const covered = new Set(GOLDEN_FRAMES.map((g) => g.frame.family));
16
+ for (const family of MESSAGE_FAMILIES) {
17
+ assert.ok(covered.has(family), `no golden frame covers family: ${family}`);
18
+ }
19
+ });
20
+
21
+ test("golden frames cover every QoS lane", () => {
22
+ const covered = new Set(GOLDEN_FRAMES.map((g) => g.frame.lane));
23
+ for (const lane of QOS_LANES) {
24
+ assert.ok(covered.has(lane), `no golden frame covers lane: ${lane}`);
25
+ }
26
+ });
27
+
28
+ test("golden frames cover every channel direction", () => {
29
+ // Derived from the FrameDirection union via an exhaustive Record so tsc fails
30
+ // when the union grows without a covering golden — no hand-maintained list.
31
+ const ALL_DIRECTIONS: Record<FrameDirection, true> = {
32
+ "worker->hub": true,
33
+ "hub->worker": true,
34
+ "hub->observers": true,
35
+ };
36
+ const covered = new Set<string>(GOLDEN_FRAMES.map((g) => g.direction));
37
+ for (const direction of Object.keys(ALL_DIRECTIONS)) {
38
+ assert.ok(covered.has(direction), `no golden frame covers direction: ${direction}`);
39
+ }
40
+ });
41
+
42
+ test("malformed corpus covers every decode-error code", () => {
43
+ // Derived from the FrameDecodeErrorCode union via an exhaustive Record so tsc
44
+ // fails when the union grows without a covering key — no hand-maintained list.
45
+ const ALL_CODES: Record<FrameDecodeErrorCode, true> = {
46
+ empty: true,
47
+ "short-header": true,
48
+ "bad-magic": true,
49
+ "unsupported-version": true,
50
+ "unknown-lane": true,
51
+ "unknown-family": true,
52
+ "truncated-payload": true,
53
+ "trailing-bytes": true,
54
+ "invalid-payload-json": true,
55
+ };
56
+ const allCodes = Object.keys(ALL_CODES);
57
+ const covered = new Set<string>(MALFORMED_FRAMES.map((m) => m.expected));
58
+ for (const code of allCodes) {
59
+ assert.ok(covered.has(code), `no malformed vector covers code: ${code}`);
60
+ }
61
+ });
62
+
63
+ test("golden frame names are unique", () => {
64
+ const names = GOLDEN_FRAMES.map((g) => g.name);
65
+ assert.equal(new Set(names).size, names.length);
66
+ });
@@ -0,0 +1,142 @@
1
+ import type { Frame } from "../frame.ts";
2
+
3
+ /** The channels a golden frame may travel on. Single source of truth for the
4
+ * direction contract — the corpus completeness test derives its coverage from
5
+ * this union so a new variant cannot be added without a covering vector. */
6
+ export type FrameDirection = "worker->hub" | "hub->worker" | "hub->observers";
7
+
8
+ /**
9
+ * Golden frame vectors: canonical (frame ↔ bytes) pairs the codec must satisfy
10
+ * in BOTH directions. `hex` is the exact wire encoding of `frame`. These are
11
+ * committed goldens — a codec change that alters the wire bytes fails the
12
+ * round-trip test, catching drift between this repo and the c8ctl client.
13
+ *
14
+ * Coverage spans every message family, every QoS lane, both directions, and
15
+ * boundary values (seq = 0, seq = uint32 max, null payload, empty string,
16
+ * multi-byte UTF-8).
17
+ */
18
+ export interface GoldenFrame {
19
+ readonly name: string;
20
+ /** Informational: which way the frame travels on the channel. */
21
+ readonly direction: FrameDirection;
22
+ readonly frame: Frame;
23
+ /** Exact wire encoding of `frame`, as a lowercase hex string. */
24
+ readonly hex: string;
25
+ }
26
+
27
+ export const GOLDEN_FRAMES: readonly GoldenFrame[] = [
28
+ {
29
+ name: "register-worker-to-hub",
30
+ direction: "worker->hub",
31
+ frame: {
32
+ lane: "control",
33
+ family: "register",
34
+ seq: 1,
35
+ payload: {
36
+ instance: "w-abc123",
37
+ capability: { cognition: "opus", weight: 3, family: "anthropic", host: "mac-01" },
38
+ },
39
+ },
40
+ hex: "4e4101000100000001000000697b22696e7374616e6365223a22772d616263313233222c226361706162696c697479223a7b22636f676e6974696f6e223a226f707573222c22776569676874223a332c2266616d696c79223a22616e7468726f706963222c22686f7374223a226d61632d3031227d7d",
41
+ },
42
+ {
43
+ name: "heartbeat-seq-zero",
44
+ direction: "worker->hub",
45
+ frame: { lane: "control", family: "heartbeat", seq: 0, payload: { instance: "w-abc123" } },
46
+ hex: "4e4101000200000000000000177b22696e7374616e6365223a22772d616263313233227d",
47
+ },
48
+ {
49
+ name: "deregister-with-reason",
50
+ direction: "worker->hub",
51
+ frame: {
52
+ lane: "control",
53
+ family: "deregister",
54
+ seq: 42,
55
+ payload: { instance: "w-abc123", reason: "shutdown" },
56
+ },
57
+ hex: "4e410100030000002a0000002b7b22696e7374616e6365223a22772d616263313233222c22726561736f6e223a2273687574646f776e227d",
58
+ },
59
+ {
60
+ name: "serve-hub-to-worker",
61
+ direction: "hub->worker",
62
+ frame: {
63
+ lane: "control",
64
+ family: "serve",
65
+ seq: 2,
66
+ payload: { instance: "w-abc123", tokens: ["implementation.qa.red#1", "planning.decide"] },
67
+ },
68
+ hex: "4e41010004000000020000004e7b22696e7374616e6365223a22772d616263313233222c22746f6b656e73223a5b22696d706c656d656e746174696f6e2e71612e7265642331222c22706c616e6e696e672e646563696465225d7d",
69
+ },
70
+ {
71
+ name: "demand-hub-to-observers",
72
+ direction: "hub->observers",
73
+ frame: {
74
+ lane: "control",
75
+ family: "demand",
76
+ seq: 7,
77
+ payload: { network: "implementation", missing: ["implementation.ci"] },
78
+ },
79
+ hex: "4e41010005000000070000003c7b226e6574776f726b223a22696d706c656d656e746174696f6e222c226d697373696e67223a5b22696d706c656d656e746174696f6e2e6369225d7d",
80
+ },
81
+ {
82
+ name: "blackboard-append-interactive",
83
+ direction: "worker->hub",
84
+ frame: {
85
+ lane: "interactive",
86
+ family: "blackboard",
87
+ seq: 100,
88
+ payload: { op: "append", dedupeKey: "regen-before-build" },
89
+ },
90
+ hex: "4e4101010600000064000000307b226f70223a22617070656e64222c226465647570654b6579223a22726567656e2d6265666f72652d6275696c64227d",
91
+ },
92
+ {
93
+ name: "blackboard-read-interactive",
94
+ direction: "worker->hub",
95
+ frame: {
96
+ lane: "interactive",
97
+ family: "blackboard",
98
+ seq: 101,
99
+ payload: { op: "read", since: 12 },
100
+ },
101
+ hex: "4e4101010600000065000000187b226f70223a2272656164222c2273696e6365223a31327d",
102
+ },
103
+ {
104
+ name: "relay-chunk-bulk",
105
+ direction: "worker->hub",
106
+ frame: {
107
+ lane: "bulk",
108
+ family: "relay",
109
+ seq: 5,
110
+ payload: { stream: "job-1223", offset: 2048, chunk: "hello world\n" },
111
+ },
112
+ hex: "4e41010207000000050000003b7b2273747265616d223a226a6f622d31323233222c226f6666736574223a323034382c226368756e6b223a2268656c6c6f20776f726c645c6e227d",
113
+ },
114
+ {
115
+ name: "relay-seq-max-bulk",
116
+ direction: "hub->worker",
117
+ frame: {
118
+ lane: "bulk",
119
+ family: "relay",
120
+ seq: 4294967295,
121
+ payload: { stream: "job-1223", offset: 0, chunk: "" },
122
+ },
123
+ hex: "4e41010207ffffffff0000002b7b2273747265616d223a226a6f622d31323233222c226f6666736574223a302c226368756e6b223a22227d",
124
+ },
125
+ {
126
+ name: "null-payload-envelope",
127
+ direction: "hub->worker",
128
+ frame: { lane: "control", family: "heartbeat", seq: 3, payload: null },
129
+ hex: "4e4101000200000003000000046e756c6c",
130
+ },
131
+ {
132
+ name: "unicode-payload-relay",
133
+ direction: "worker->hub",
134
+ frame: {
135
+ lane: "bulk",
136
+ family: "relay",
137
+ seq: 9,
138
+ payload: { stream: "job-1223", offset: 4, chunk: "✓ café — 日本語" },
139
+ },
140
+ hex: "4e4101020700000009000000427b2273747265616d223a226a6f622d31323233222c226f6666736574223a342c226368756e6b223a22e29c9320636166c3a920e2809420e697a5e69cace8aa9e227d",
141
+ },
142
+ ];
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Shared conformance corpus for the Nano agentic protocol.
3
+ *
4
+ * Exported so cross-repo tests (this repo AND jwulf/c8ctl-plugin-nano) consume
5
+ * the SAME golden frames, malformed vectors, vocab documents and token vectors.
6
+ * Import it and hold your codec/client to it:
7
+ *
8
+ * import { GOLDEN_FRAMES, MALFORMED_FRAMES } from "./index.ts";
9
+ */
10
+ export {
11
+ GOLDEN_FRAMES,
12
+ type GoldenFrame,
13
+ } from "./frames.ts";
14
+ export {
15
+ MALFORMED_FRAMES,
16
+ type MalformedFrame,
17
+ } from "./malformed.ts";
18
+ export {
19
+ VALID_VOCABS,
20
+ INVALID_VOCABS,
21
+ type ValidVocab,
22
+ type InvalidVocab,
23
+ } from "./vocab.ts";
24
+ export {
25
+ VALID_TOKENS,
26
+ INVALID_TOKENS,
27
+ type ValidToken,
28
+ type InvalidToken,
29
+ } from "./tokens.ts";
@@ -0,0 +1,59 @@
1
+ import type { FrameDecodeErrorCode } from "../frame.ts";
2
+
3
+ /**
4
+ * Malformed frame vectors: adversarial byte sequences that MUST be rejected,
5
+ * each paired with the exact {@link FrameDecodeErrorCode} the decoder must
6
+ * raise. A shared prose spec does not stop divergence — shared adversarial
7
+ * vectors do. Both this decoder and the c8ctl client are held to these.
8
+ */
9
+ export interface MalformedFrame {
10
+ readonly name: string;
11
+ /** The raw bytes to feed the decoder, as a lowercase hex string (may be ""). */
12
+ readonly hex: string;
13
+ readonly expected: FrameDecodeErrorCode;
14
+ }
15
+
16
+ export const MALFORMED_FRAMES: readonly MalformedFrame[] = [
17
+ { name: "empty-buffer", hex: "", expected: "empty" },
18
+ { name: "short-header", hex: "4e410100", expected: "short-header" },
19
+ {
20
+ name: "bad-magic",
21
+ hex: "ffff01000200000000000000177b22696e7374616e6365223a22772d616263313233227d",
22
+ expected: "bad-magic",
23
+ },
24
+ {
25
+ name: "unsupported-version",
26
+ hex: "4e4102000200000000000000177b22696e7374616e6365223a22772d616263313233227d",
27
+ expected: "unsupported-version",
28
+ },
29
+ {
30
+ name: "unknown-lane",
31
+ hex: "4e4101090200000000000000177b22696e7374616e6365223a22772d616263313233227d",
32
+ expected: "unknown-lane",
33
+ },
34
+ {
35
+ name: "unknown-family",
36
+ hex: "4e4101006300000000000000177b22696e7374616e6365223a22772d616263313233227d",
37
+ expected: "unknown-family",
38
+ },
39
+ {
40
+ name: "truncated-payload",
41
+ hex: "4e4101000200000000000000177b",
42
+ expected: "truncated-payload",
43
+ },
44
+ {
45
+ name: "trailing-bytes",
46
+ hex: "4e4101000200000000000000177b22696e7374616e6365223a22772d616263313233227d00",
47
+ expected: "trailing-bytes",
48
+ },
49
+ {
50
+ name: "invalid-payload-json-nonjson",
51
+ hex: "4e4101000200000000000000027a7a",
52
+ expected: "invalid-payload-json",
53
+ },
54
+ {
55
+ name: "invalid-payload-json-bad-utf8",
56
+ hex: "4e410100020000000000000001ff",
57
+ expected: "invalid-payload-json",
58
+ },
59
+ ];