@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,40 @@
1
+ import type { AgenticHub } from "../channel/index.ts";
2
+ import type { PresenceRow, PresenceStore } from "./store.ts";
3
+ export interface PresenceFamilyOptions {
4
+ /**
5
+ * How often to age out stale presence rows, in ms. Default: a third of the
6
+ * store's TTL. Pass 0 to disable the internal timer (tests call
7
+ * {@link PresenceFamilyHandle.sweepNow} explicitly). The sweep reads "now"
8
+ * from the store's own clock, so liveness time has a single source of truth.
9
+ */
10
+ sweepIntervalMs?: number;
11
+ /**
12
+ * Notified of a fault this module handles while keeping the connection: a
13
+ * malformed presence payload ({@link PresencePayloadError}), a rejected
14
+ * ownership takeover ({@link PresenceOwnershipError}), or a presence-sweep
15
+ * error. Other handler exceptions are not routed here — they propagate to
16
+ * {@link AgenticHub} and surface via the hub's own error handling.
17
+ */
18
+ onError?: (err: unknown, connectionId?: string) => void;
19
+ }
20
+ /** Handle to the attached presence family — drives/stops the presence sweep. */
21
+ export interface PresenceFamilyHandle {
22
+ /** Age out stale presence rows now and return the removed rows. */
23
+ sweepNow(): PresenceRow[];
24
+ /** Stop the presence sweep timer. */
25
+ stop(): void;
26
+ }
27
+ /** A malformed presence payload rejected before it touches the store. */
28
+ export declare class PresencePayloadError extends Error {
29
+ readonly family: string;
30
+ constructor(family: string, detail: string);
31
+ }
32
+ /**
33
+ * Attach the presence family (`register`/`heartbeat`/`deregister`) to `hub`,
34
+ * backed by `store`. Registers three handlers via the S1 seam and schedules the
35
+ * presence-TTL sweep. Returns a handle to drive/stop the sweep.
36
+ *
37
+ * @throws DuplicateFamilyHandlerError if one of the three families already has
38
+ * a handler on this hub (one family, one owning module).
39
+ */
40
+ export declare function attachPresenceFamily(hub: AgenticHub, store: PresenceStore, options?: PresenceFamilyOptions): PresenceFamilyHandle;
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The presence family module — S2's self-contained attachment to the S1 hub.
3
+ *
4
+ * This module owns the `register`, `heartbeat` and `deregister` message
5
+ * families. It attaches through the hub's canonical
6
+ * {@link AgenticHub.registerFamilyHandler} seam — it does NOT touch a shared
7
+ * frame→family dispatch switch — so it composes with the other wave-2 family
8
+ * modules (S5 relay, S7 blackboard) with no shared edit.
9
+ *
10
+ * On `register` it writes a durable presence row (via {@link PresenceStore}) and
11
+ * mirrors instance+capability onto S1's in-memory connection registry
12
+ * (`ctx.registry.setPresence`). `heartbeat` refreshes the row's liveness;
13
+ * `deregister` removes it. Rows the fleet stops heartbeating age out on the
14
+ * presence TTL via the sweep this module schedules.
15
+ */
16
+ import { validatePayload } from "../protocol/index.js";
17
+ import { PresenceOwnershipError } from "./store.js";
18
+ /** A malformed presence payload rejected before it touches the store. */
19
+ export class PresencePayloadError extends Error {
20
+ family;
21
+ constructor(family, detail) {
22
+ super(`invalid ${family} payload: ${detail}`);
23
+ this.name = "PresencePayloadError";
24
+ this.family = family;
25
+ }
26
+ }
27
+ function isPlainObject(value) {
28
+ return typeof value === "object" && value !== null && !Array.isArray(value);
29
+ }
30
+ function readString(record, key) {
31
+ const value = record[key];
32
+ return typeof value === "string" ? value : undefined;
33
+ }
34
+ function readNumber(record, key) {
35
+ const value = record[key];
36
+ return typeof value === "number" ? value : undefined;
37
+ }
38
+ /** Extract the enrolment capability from a validated `register` payload. */
39
+ function readCapability(record) {
40
+ const raw = record.capability;
41
+ if (!isPlainObject(raw))
42
+ return {};
43
+ const cap = {};
44
+ const cognition = readString(raw, "cognition");
45
+ if (cognition !== undefined)
46
+ cap.cognition = cognition;
47
+ const weight = readNumber(raw, "weight");
48
+ if (weight !== undefined)
49
+ cap.weight = weight;
50
+ const family = readString(raw, "family");
51
+ if (family !== undefined)
52
+ cap.family = family;
53
+ const host = readString(raw, "host");
54
+ if (host !== undefined)
55
+ cap.host = host;
56
+ return cap;
57
+ }
58
+ /**
59
+ * Attach the presence family (`register`/`heartbeat`/`deregister`) to `hub`,
60
+ * backed by `store`. Registers three handlers via the S1 seam and schedules the
61
+ * presence-TTL sweep. Returns a handle to drive/stop the sweep.
62
+ *
63
+ * @throws DuplicateFamilyHandlerError if one of the three families already has
64
+ * a handler on this hub (one family, one owning module).
65
+ */
66
+ export function attachPresenceFamily(hub, store, options = {}) {
67
+ store.ensureSchema();
68
+ const onError = options.onError ?? (() => { });
69
+ const reject = (family, connectionId, detail) => {
70
+ onError(new PresencePayloadError(family, detail), connectionId);
71
+ };
72
+ hub.registerFamilyHandler("register", (frame, ctx) => {
73
+ const payload = frame.payload;
74
+ const result = validatePayload("register", payload);
75
+ if (!result.ok) {
76
+ reject("register", ctx.id, result.errors.map((e) => e.message).join("; "));
77
+ return;
78
+ }
79
+ if (!isPlainObject(payload)) {
80
+ reject("register", ctx.id, "not an object");
81
+ return;
82
+ }
83
+ const instance = readString(payload, "instance");
84
+ if (instance === undefined) {
85
+ reject("register", ctx.id, "missing instance");
86
+ return;
87
+ }
88
+ const capability = readCapability(payload);
89
+ try {
90
+ store.register({ instance, connectionId: ctx.id, identity: ctx.identity, capability });
91
+ }
92
+ catch (err) {
93
+ // An instance owned by another identity must not be hijacked: surface the
94
+ // rejection and leave S1's in-memory registry untouched.
95
+ if (err instanceof PresenceOwnershipError) {
96
+ onError(err, ctx.id);
97
+ return;
98
+ }
99
+ throw err;
100
+ }
101
+ // Mirror the enrolment onto S1's in-memory connection registry so a live
102
+ // connection carries its instance+capability without a DB read.
103
+ ctx.registry.setPresence(ctx.id, { instance, capability });
104
+ });
105
+ hub.registerFamilyHandler("heartbeat", (frame, ctx) => {
106
+ const payload = frame.payload;
107
+ const result = validatePayload("heartbeat", payload);
108
+ if (!result.ok) {
109
+ reject("heartbeat", ctx.id, result.errors.map((e) => e.message).join("; "));
110
+ return;
111
+ }
112
+ if (!isPlainObject(payload)) {
113
+ reject("heartbeat", ctx.id, "not an object");
114
+ return;
115
+ }
116
+ const instance = readString(payload, "instance");
117
+ if (instance === undefined) {
118
+ reject("heartbeat", ctx.id, "missing instance");
119
+ return;
120
+ }
121
+ store.heartbeat(instance, ctx.identity);
122
+ });
123
+ hub.registerFamilyHandler("deregister", (frame, ctx) => {
124
+ const payload = frame.payload;
125
+ const result = validatePayload("deregister", payload);
126
+ if (!result.ok) {
127
+ reject("deregister", ctx.id, result.errors.map((e) => e.message).join("; "));
128
+ return;
129
+ }
130
+ if (!isPlainObject(payload)) {
131
+ reject("deregister", ctx.id, "not an object");
132
+ return;
133
+ }
134
+ const instance = readString(payload, "instance");
135
+ if (instance === undefined) {
136
+ reject("deregister", ctx.id, "missing instance");
137
+ return;
138
+ }
139
+ store.deregister(instance, ctx.identity);
140
+ });
141
+ const sweepNow = () => {
142
+ try {
143
+ return store.sweep();
144
+ }
145
+ catch (err) {
146
+ onError(err);
147
+ return [];
148
+ }
149
+ };
150
+ const interval = options.sweepIntervalMs ?? Math.max(1, Math.floor(store.ttlMs / 3));
151
+ let timer;
152
+ if (interval > 0) {
153
+ timer = setInterval(sweepNow, interval);
154
+ // Do not keep the process alive solely for the presence sweep.
155
+ timer.unref?.();
156
+ }
157
+ return {
158
+ sweepNow,
159
+ stop() {
160
+ if (timer !== undefined) {
161
+ clearInterval(timer);
162
+ timer = undefined;
163
+ }
164
+ },
165
+ };
166
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @nanobpm/agentic-presence — the presence & registry family for the Nano
3
+ * agentic protocol (ADR 0056, slice S2).
4
+ *
5
+ * Owns the `register` / `heartbeat` / `deregister` message families: a durable
6
+ * presence registry over the app DataLayer/SQLite ({@link PresenceStore}) plus
7
+ * a self-contained family module ({@link attachPresenceFamily}) that attaches to
8
+ * the S1 hub through its `registerFamilyHandler(family, handler)` seam — never a
9
+ * shared dispatch switch. S1 owns connection liveness; this slice layers durable
10
+ * presence rows with their own heartbeat-refreshed TTL on top.
11
+ *
12
+ * The wire contract lives in `@nanobpm/agentic-protocol`; the hub in
13
+ * `@nanobpm/agentic-channel`. This package builds on both.
14
+ */
15
+ export { PresenceOwnershipError, PresenceStore } from "./store.ts";
16
+ export type { PresenceRow, PresenceStoreOptions, RegisterInput, SqliteDb, } from "./store.ts";
17
+ export { attachPresenceFamily, PresencePayloadError } from "./family.ts";
18
+ export type { PresenceFamilyOptions, PresenceFamilyHandle } from "./family.ts";
19
+ export { PRESENCE_TABLE, PRESENCE_SCHEMA_SQL } from "./schema.ts";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @nanobpm/agentic-presence — the presence & registry family for the Nano
3
+ * agentic protocol (ADR 0056, slice S2).
4
+ *
5
+ * Owns the `register` / `heartbeat` / `deregister` message families: a durable
6
+ * presence registry over the app DataLayer/SQLite ({@link PresenceStore}) plus
7
+ * a self-contained family module ({@link attachPresenceFamily}) that attaches to
8
+ * the S1 hub through its `registerFamilyHandler(family, handler)` seam — never a
9
+ * shared dispatch switch. S1 owns connection liveness; this slice layers durable
10
+ * presence rows with their own heartbeat-refreshed TTL on top.
11
+ *
12
+ * The wire contract lives in `@nanobpm/agentic-protocol`; the hub in
13
+ * `@nanobpm/agentic-channel`. This package builds on both.
14
+ */
15
+ export { PresenceOwnershipError, PresenceStore } from "./store.js";
16
+ export { attachPresenceFamily, PresencePayloadError } from "./family.js";
17
+ export { PRESENCE_TABLE, PRESENCE_SCHEMA_SQL } from "./schema.js";
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The canonical presence-registry schema.
3
+ *
4
+ * The DDL here is the single source of truth the {@link PresenceStore} applies
5
+ * through {@link PresenceStore.ensureSchema}. The very same statements are
6
+ * mirrored in the app-boot migration `db/migrations/001_agentic_presence.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 presence & registry table name. */
13
+ export declare const PRESENCE_TABLE = "agentic_presence";
14
+ /**
15
+ * The canonical presence-registry DDL. Forward-only and additive; every column
16
+ * added here must also be added to the boot migration (the drift guard enforces
17
+ * it). Capability (cognition/weight/family/host) is stored as an ENROLMENT
18
+ * attribute — never a routing token.
19
+ */
20
+ export declare const PRESENCE_SCHEMA_SQL = "CREATE TABLE IF NOT EXISTS agentic_presence (\n instance TEXT PRIMARY KEY,\n connection_id TEXT NOT NULL,\n identity TEXT NOT NULL,\n cognition TEXT,\n weight REAL,\n family TEXT,\n host TEXT,\n registered_at TEXT NOT NULL,\n last_seen INTEGER NOT NULL\n);\nCREATE INDEX IF NOT EXISTS idx_agentic_presence_last_seen ON agentic_presence (last_seen);\nCREATE INDEX IF NOT EXISTS idx_agentic_presence_connection ON agentic_presence (connection_id);";
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The canonical presence-registry schema.
3
+ *
4
+ * The DDL here is the single source of truth the {@link PresenceStore} applies
5
+ * through {@link PresenceStore.ensureSchema}. The very same statements are
6
+ * mirrored in the app-boot migration `db/migrations/001_agentic_presence.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 presence & registry table name. */
13
+ export const PRESENCE_TABLE = "agentic_presence";
14
+ /**
15
+ * The canonical presence-registry DDL. Forward-only and additive; every column
16
+ * added here must also be added to the boot migration (the drift guard enforces
17
+ * it). Capability (cognition/weight/family/host) is stored as an ENROLMENT
18
+ * attribute — never a routing token.
19
+ */
20
+ export const PRESENCE_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${PRESENCE_TABLE} (
21
+ instance TEXT PRIMARY KEY,
22
+ connection_id TEXT NOT NULL,
23
+ identity TEXT NOT NULL,
24
+ cognition TEXT,
25
+ weight REAL,
26
+ family TEXT,
27
+ host TEXT,
28
+ registered_at TEXT NOT NULL,
29
+ last_seen INTEGER NOT NULL
30
+ );
31
+ CREATE INDEX IF NOT EXISTS idx_${PRESENCE_TABLE}_last_seen ON ${PRESENCE_TABLE} (last_seen);
32
+ CREATE INDEX IF NOT EXISTS idx_${PRESENCE_TABLE}_connection ON ${PRESENCE_TABLE} (connection_id);`;
@@ -0,0 +1,130 @@
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 type { Clock } from "../channel/index.ts";
17
+ /**
18
+ * The minimal synchronous SQLite handle the store needs — structurally the same
19
+ * surface the Urban runtime's DataLayer exposes (`host.openSqlite`). Kept local
20
+ * so the store depends on a shape, not on the runtime package.
21
+ */
22
+ export interface SqliteDb {
23
+ /** Execute one or more statements with no result (DDL, migrations). */
24
+ exec(sql: string): void;
25
+ /** Run a parameterised statement, returning the changed-row count. */
26
+ run(sql: string, params?: unknown[]): {
27
+ changes: number;
28
+ lastInsertRowid: number | bigint;
29
+ };
30
+ /** Run a parameterised query, returning all rows as plain objects. */
31
+ all<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[];
32
+ }
33
+ /** A durable presence row for one registered worker instance. */
34
+ export interface PresenceRow {
35
+ /** The worker instance id (`register.instance`) — the primary key. */
36
+ readonly instance: string;
37
+ /** The channel connection the instance last registered on. */
38
+ readonly connectionId: string;
39
+ /** The authenticated principal (ADR 0028 identity) of that connection. */
40
+ readonly identity: string;
41
+ /** The declared enrolment capability (never a routing token). */
42
+ readonly capability: Capability;
43
+ /** When the instance first registered, ISO-8601. */
44
+ readonly registeredAt: string;
45
+ /** Last liveness refresh (register/heartbeat), epoch ms. */
46
+ readonly lastSeen: number;
47
+ }
48
+ /** Input to {@link PresenceStore.register}. */
49
+ export interface RegisterInput {
50
+ readonly instance: string;
51
+ readonly connectionId: string;
52
+ readonly identity: string;
53
+ readonly capability: Capability;
54
+ }
55
+ export interface PresenceStoreOptions {
56
+ /** Presence liveness TTL in ms; a row unseen for longer ages out. Default 30000. */
57
+ ttlMs?: number;
58
+ /** Injectable clock for deterministic tests. Default {@link systemClock}. */
59
+ clock?: Clock;
60
+ }
61
+ /**
62
+ * Raised when a `register` would overwrite an instance already owned by a
63
+ * different authenticated identity. Presence rows are bound to the identity
64
+ * that first registered them, so one authenticated peer can never take over
65
+ * (or change the identity of) another peer's instance.
66
+ */
67
+ export declare class PresenceOwnershipError extends Error {
68
+ readonly instance: string;
69
+ constructor(instance: string);
70
+ }
71
+ export declare class PresenceStore {
72
+ #private;
73
+ constructor(db: SqliteDb, options?: PresenceStoreOptions);
74
+ /** The presence liveness TTL in ms. */
75
+ get ttlMs(): number;
76
+ /**
77
+ * Apply the canonical presence DDL (idempotent). Callers that let the app
78
+ * DataLayer migration runner apply `db/migrations/001_agentic_presence.sql`
79
+ * do not need this — but the family module calls it so the store is usable
80
+ * against a bare source too. The DDL is identical to the migration (guarded).
81
+ */
82
+ ensureSchema(): void;
83
+ /**
84
+ * Register (or re-register) an instance. A first registration stamps
85
+ * `registered_at`; a re-registration (e.g. after a reconnect on a new
86
+ * connection) keeps the original `registered_at` and refreshes everything
87
+ * else, including `last_seen`. Returns the stored row.
88
+ *
89
+ * Ownership is bound to the authenticated `identity` that first registered the
90
+ * instance: a re-register from the same identity (the reconnect case) is
91
+ * allowed, but one from a *different* identity is rejected with a
92
+ * {@link PresenceOwnershipError} and leaves the existing row untouched — no
93
+ * peer can take over another peer's instance or rewrite its identity.
94
+ */
95
+ register(input: RegisterInput): PresenceRow;
96
+ /**
97
+ * Refresh an instance's liveness to now. Returns `true` if the instance was
98
+ * registered, `false` if there is no such row (a heartbeat before register).
99
+ * When `identity` is given, the refresh is scoped to the owning identity, so a
100
+ * heartbeat from a foreign identity is a silent no-op (and cannot probe for
101
+ * the existence of another peer's instance).
102
+ */
103
+ heartbeat(instance: string, identity?: string): boolean;
104
+ /**
105
+ * Remove an instance's presence row. Returns `true` if a row was removed. When
106
+ * `identity` is given, the removal is scoped to the owning identity, so a
107
+ * deregister from a foreign identity is a silent no-op.
108
+ */
109
+ deregister(instance: string, identity?: string): boolean;
110
+ /**
111
+ * Remove every presence row registered on a now-dead connection (e.g. one S1
112
+ * closed on its own liveness sweep). Returns the removed instance ids so the
113
+ * caller can react. Presence also ages out via {@link sweep}; this is the
114
+ * eager path when a disconnect is observed.
115
+ */
116
+ removeByConnection(connectionId: string): string[];
117
+ /**
118
+ * Age out every presence row whose `last_seen` is older than the TTL and
119
+ * return the removed rows. `now` defaults to the clock; pass an explicit value
120
+ * for deterministic tests. Matches S1's liveness predicate (`now - lastSeen >
121
+ * ttl`).
122
+ */
123
+ sweep(now?: number): PresenceRow[];
124
+ /** Look up a single instance's presence row. */
125
+ get(instance: string): PresenceRow | undefined;
126
+ /** Every presence row, ordered by first registration then instance id. */
127
+ list(): PresenceRow[];
128
+ /** Number of registered instances. */
129
+ count(): number;
130
+ }
@@ -0,0 +1,191 @@
1
+ import { systemClock } from "../channel/index.js";
2
+ import { PRESENCE_SCHEMA_SQL, PRESENCE_TABLE } from "./schema.js";
3
+ const DEFAULT_TTL_MS = 30_000;
4
+ /**
5
+ * Raised when a `register` would overwrite an instance already owned by a
6
+ * different authenticated identity. Presence rows are bound to the identity
7
+ * that first registered them, so one authenticated peer can never take over
8
+ * (or change the identity of) another peer's instance.
9
+ */
10
+ export class PresenceOwnershipError extends Error {
11
+ instance;
12
+ constructor(instance) {
13
+ super(`instance "${instance}" is registered to a different identity`);
14
+ this.name = "PresenceOwnershipError";
15
+ this.instance = instance;
16
+ }
17
+ }
18
+ function buildCapability(row) {
19
+ const cap = {};
20
+ if (row.cognition !== null)
21
+ cap.cognition = row.cognition;
22
+ if (row.weight !== null)
23
+ cap.weight = row.weight;
24
+ if (row.family !== null)
25
+ cap.family = row.family;
26
+ if (row.host !== null)
27
+ cap.host = row.host;
28
+ return cap;
29
+ }
30
+ function toRow(row) {
31
+ return {
32
+ instance: row.instance,
33
+ connectionId: row.connection_id,
34
+ identity: row.identity,
35
+ capability: buildCapability(row),
36
+ registeredAt: row.registered_at,
37
+ lastSeen: row.last_seen,
38
+ };
39
+ }
40
+ export class PresenceStore {
41
+ #db;
42
+ #ttlMs;
43
+ #clock;
44
+ constructor(db, options = {}) {
45
+ this.#db = db;
46
+ this.#ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
47
+ this.#clock = options.clock ?? systemClock;
48
+ }
49
+ /** The presence liveness TTL in ms. */
50
+ get ttlMs() {
51
+ return this.#ttlMs;
52
+ }
53
+ /**
54
+ * Apply the canonical presence DDL (idempotent). Callers that let the app
55
+ * DataLayer migration runner apply `db/migrations/001_agentic_presence.sql`
56
+ * do not need this — but the family module calls it so the store is usable
57
+ * against a bare source too. The DDL is identical to the migration (guarded).
58
+ */
59
+ ensureSchema() {
60
+ this.#db.exec(PRESENCE_SCHEMA_SQL);
61
+ }
62
+ /**
63
+ * Register (or re-register) an instance. A first registration stamps
64
+ * `registered_at`; a re-registration (e.g. after a reconnect on a new
65
+ * connection) keeps the original `registered_at` and refreshes everything
66
+ * else, including `last_seen`. Returns the stored row.
67
+ *
68
+ * Ownership is bound to the authenticated `identity` that first registered the
69
+ * instance: a re-register from the same identity (the reconnect case) is
70
+ * allowed, but one from a *different* identity is rejected with a
71
+ * {@link PresenceOwnershipError} and leaves the existing row untouched — no
72
+ * peer can take over another peer's instance or rewrite its identity.
73
+ */
74
+ register(input) {
75
+ const now = this.#clock.now();
76
+ const cap = input.capability;
77
+ this.#db.run(`INSERT INTO ${PRESENCE_TABLE}
78
+ (instance, connection_id, identity, cognition, weight, family, host, registered_at, last_seen)
79
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
80
+ ON CONFLICT(instance) DO UPDATE SET
81
+ connection_id = excluded.connection_id,
82
+ identity = excluded.identity,
83
+ cognition = excluded.cognition,
84
+ weight = excluded.weight,
85
+ family = excluded.family,
86
+ host = excluded.host,
87
+ last_seen = excluded.last_seen
88
+ WHERE ${PRESENCE_TABLE}.identity = excluded.identity`, [
89
+ input.instance,
90
+ input.connectionId,
91
+ input.identity,
92
+ cap.cognition ?? null,
93
+ cap.weight ?? null,
94
+ cap.family ?? null,
95
+ cap.host ?? null,
96
+ new Date(now).toISOString(),
97
+ now,
98
+ ]);
99
+ // Enforce ownership on the *persisted* row rather than the driver's
100
+ // changed-row count. The identity guard in the UPSERT's `WHERE` leaves a
101
+ // foreign-owned row untouched, so after the statement the stored identity
102
+ // is authoritative: if it differs from the caller's, this register was a
103
+ // rejected takeover. Reading it back keeps the invariant independent of how
104
+ // SQLite counts UPSERT changes (which counts WHERE-matched rows, not just
105
+ // value-changing ones), so a same-identity no-op re-register never trips it.
106
+ const row = this.get(input.instance);
107
+ if (row === undefined) {
108
+ throw new Error(`presence row vanished immediately after register: ${input.instance}`);
109
+ }
110
+ if (row.identity !== input.identity) {
111
+ throw new PresenceOwnershipError(input.instance);
112
+ }
113
+ return row;
114
+ }
115
+ /**
116
+ * Refresh an instance's liveness to now. Returns `true` if the instance was
117
+ * registered, `false` if there is no such row (a heartbeat before register).
118
+ * When `identity` is given, the refresh is scoped to the owning identity, so a
119
+ * heartbeat from a foreign identity is a silent no-op (and cannot probe for
120
+ * the existence of another peer's instance).
121
+ */
122
+ heartbeat(instance, identity) {
123
+ const { changes } = identity === undefined
124
+ ? this.#db.run(`UPDATE ${PRESENCE_TABLE} SET last_seen = ? WHERE instance = ?`, [this.#clock.now(), instance])
125
+ : this.#db.run(`UPDATE ${PRESENCE_TABLE} SET last_seen = ? WHERE instance = ? AND identity = ?`, [
126
+ this.#clock.now(),
127
+ instance,
128
+ identity,
129
+ ]);
130
+ return changes > 0;
131
+ }
132
+ /**
133
+ * Remove an instance's presence row. Returns `true` if a row was removed. When
134
+ * `identity` is given, the removal is scoped to the owning identity, so a
135
+ * deregister from a foreign identity is a silent no-op.
136
+ */
137
+ deregister(instance, identity) {
138
+ const { changes } = identity === undefined
139
+ ? this.#db.run(`DELETE FROM ${PRESENCE_TABLE} WHERE instance = ?`, [instance])
140
+ : this.#db.run(`DELETE FROM ${PRESENCE_TABLE} WHERE instance = ? AND identity = ?`, [instance, identity]);
141
+ return changes > 0;
142
+ }
143
+ /**
144
+ * Remove every presence row registered on a now-dead connection (e.g. one S1
145
+ * closed on its own liveness sweep). Returns the removed instance ids so the
146
+ * caller can react. Presence also ages out via {@link sweep}; this is the
147
+ * eager path when a disconnect is observed.
148
+ */
149
+ removeByConnection(connectionId) {
150
+ const removed = this.#db
151
+ .all(`SELECT instance FROM ${PRESENCE_TABLE} WHERE connection_id = ?`, [connectionId])
152
+ .map((r) => r.instance);
153
+ if (removed.length > 0) {
154
+ this.#db.run(`DELETE FROM ${PRESENCE_TABLE} WHERE connection_id = ?`, [connectionId]);
155
+ }
156
+ return removed;
157
+ }
158
+ /**
159
+ * Age out every presence row whose `last_seen` is older than the TTL and
160
+ * return the removed rows. `now` defaults to the clock; pass an explicit value
161
+ * for deterministic tests. Matches S1's liveness predicate (`now - lastSeen >
162
+ * ttl`).
163
+ */
164
+ sweep(now = this.#clock.now()) {
165
+ const cutoff = now - this.#ttlMs;
166
+ const stale = this.#db
167
+ .all(`SELECT * FROM ${PRESENCE_TABLE} WHERE last_seen < ?`, [cutoff])
168
+ .map(toRow);
169
+ if (stale.length > 0) {
170
+ this.#db.run(`DELETE FROM ${PRESENCE_TABLE} WHERE last_seen < ?`, [cutoff]);
171
+ }
172
+ return stale;
173
+ }
174
+ /** Look up a single instance's presence row. */
175
+ get(instance) {
176
+ const rows = this.#db.all(`SELECT * FROM ${PRESENCE_TABLE} WHERE instance = ?`, [instance]);
177
+ const row = rows[0];
178
+ return row === undefined ? undefined : toRow(row);
179
+ }
180
+ /** Every presence row, ordered by first registration then instance id. */
181
+ list() {
182
+ return this.#db
183
+ .all(`SELECT * FROM ${PRESENCE_TABLE} ORDER BY registered_at, instance`)
184
+ .map(toRow);
185
+ }
186
+ /** Number of registered instances. */
187
+ count() {
188
+ const rows = this.#db.all(`SELECT COUNT(*) AS n FROM ${PRESENCE_TABLE}`);
189
+ return rows[0]?.n ?? 0;
190
+ }
191
+ }
@@ -0,0 +1,5 @@
1
+ import type { SqliteDb } from "./store.ts";
2
+ export interface TestDb extends SqliteDb {
3
+ close(): void;
4
+ }
5
+ export declare function openTestDb(): TestDb;
@@ -0,0 +1,42 @@
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
+ function toParams(params) {
9
+ return params.map((p) => {
10
+ if (p === null)
11
+ return null;
12
+ if (typeof p === "string" || typeof p === "number" || typeof p === "bigint" || p instanceof Uint8Array) {
13
+ return p;
14
+ }
15
+ if (typeof p === "boolean")
16
+ return p ? 1 : 0;
17
+ // Mirror wrapNodeSqlite's `sqliteParams`: unsupported types (including
18
+ // `undefined`) throw rather than coerce, so a test can never pass on a
19
+ // parameter production would reject.
20
+ throw new TypeError(`unsupported SQLite parameter type: ${typeof p}`);
21
+ });
22
+ }
23
+ export function openTestDb() {
24
+ const db = new DatabaseSync(":memory:");
25
+ return {
26
+ exec: (sql) => db.exec(sql),
27
+ run: (sql, params = []) => {
28
+ const stmt = db.prepare(sql);
29
+ const r = stmt.run(...toParams(params));
30
+ return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid };
31
+ },
32
+ all: (sql, params = []) => {
33
+ const stmt = db.prepare(sql);
34
+ // Mirror wrapNodeSqlite exactly: return the driver's row objects directly.
35
+ // A JSON round-trip would throw on `bigint` values and mangle `Uint8Array`
36
+ // blobs, diverging from production and breaking test-only reads.
37
+ // biome-ignore lint/plugin: Node sqlite returns untyped row objects; SqliteDb.all<T> is the host adapter boundary.
38
+ return stmt.all(...toParams(params));
39
+ },
40
+ close: () => db.close(),
41
+ };
42
+ }