@nanobpm/agentic 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/dist/blackboard/family.d.ts +40 -0
- package/dist/blackboard/family.js +151 -0
- package/dist/blackboard/index.d.ts +21 -0
- package/dist/blackboard/index.js +19 -0
- package/dist/blackboard/schema.d.ts +30 -0
- package/dist/blackboard/schema.js +42 -0
- package/dist/blackboard/store.d.ts +138 -0
- package/dist/blackboard/store.js +216 -0
- package/dist/blackboard/test-db.d.ts +5 -0
- package/dist/blackboard/test-db.js +42 -0
- package/dist/channel/auth.d.ts +41 -0
- package/dist/channel/auth.js +67 -0
- package/dist/channel/clock.d.ts +11 -0
- package/dist/channel/clock.js +4 -0
- package/dist/channel/connection.d.ts +75 -0
- package/dist/channel/connection.js +14 -0
- package/dist/channel/dispatch.d.ts +46 -0
- package/dist/channel/dispatch.js +86 -0
- package/dist/channel/hub.d.ts +78 -0
- package/dist/channel/hub.js +157 -0
- package/dist/channel/index.d.ts +27 -0
- package/dist/channel/index.js +20 -0
- package/dist/channel/registry.d.ts +68 -0
- package/dist/channel/registry.js +84 -0
- package/dist/channel/ws-transport.d.ts +23 -0
- package/dist/channel/ws-transport.js +178 -0
- package/dist/cockpit/boot.d.ts +68 -0
- package/dist/cockpit/boot.js +202 -0
- package/dist/cockpit/fake-dom.d.ts +37 -0
- package/dist/cockpit/fake-dom.js +73 -0
- package/dist/cockpit/index.d.ts +27 -0
- package/dist/cockpit/index.js +27 -0
- package/dist/cockpit/relay-client.d.ts +52 -0
- package/dist/cockpit/relay-client.js +192 -0
- package/dist/cockpit/render.d.ts +58 -0
- package/dist/cockpit/render.js +122 -0
- package/dist/cockpit/terminal-session.d.ts +95 -0
- package/dist/cockpit/terminal-session.js +123 -0
- package/dist/cockpit/view.d.ts +79 -0
- package/dist/cockpit/view.js +58 -0
- package/dist/demand/c8-rest.d.ts +77 -0
- package/dist/demand/c8-rest.js +123 -0
- package/dist/demand/index.d.ts +24 -0
- package/dist/demand/index.js +24 -0
- package/dist/demand/model.d.ts +68 -0
- package/dist/demand/model.js +118 -0
- package/dist/demand/taskdef.d.ts +40 -0
- package/dist/demand/taskdef.js +67 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +17 -0
- package/dist/presence/family.d.ts +40 -0
- package/dist/presence/family.js +166 -0
- package/dist/presence/index.d.ts +19 -0
- package/dist/presence/index.js +17 -0
- package/dist/presence/schema.d.ts +20 -0
- package/dist/presence/schema.js +32 -0
- package/dist/presence/store.d.ts +130 -0
- package/dist/presence/store.js +191 -0
- package/dist/presence/test-db.d.ts +5 -0
- package/dist/presence/test-db.js +42 -0
- package/dist/protocol/conformance/frames.d.ts +24 -0
- package/dist/protocol/conformance/frames.js +116 -0
- package/dist/protocol/conformance/index.d.ts +13 -0
- package/dist/protocol/conformance/index.js +13 -0
- package/dist/protocol/conformance/malformed.d.ts +14 -0
- package/dist/protocol/conformance/malformed.js +44 -0
- package/dist/protocol/conformance/tokens.d.ts +19 -0
- package/dist/protocol/conformance/tokens.js +49 -0
- package/dist/protocol/conformance/vocab.d.ts +23 -0
- package/dist/protocol/conformance/vocab.js +97 -0
- package/dist/protocol/families.d.ts +32 -0
- package/dist/protocol/families.js +45 -0
- package/dist/protocol/frame.d.ts +45 -0
- package/dist/protocol/frame.js +114 -0
- package/dist/protocol/hex.d.ts +7 -0
- package/dist/protocol/hex.js +26 -0
- package/dist/protocol/index.d.ts +23 -0
- package/dist/protocol/index.js +23 -0
- package/dist/protocol/lanes.d.ts +36 -0
- package/dist/protocol/lanes.js +40 -0
- package/dist/protocol/payloads.d.ts +64 -0
- package/dist/protocol/payloads.js +122 -0
- package/dist/protocol/token.d.ts +34 -0
- package/dist/protocol/token.js +81 -0
- package/dist/protocol/vocab/schema.d.ts +51 -0
- package/dist/protocol/vocab/schema.js +218 -0
- package/dist/relay/incarnation.d.ts +17 -0
- package/dist/relay/incarnation.js +50 -0
- package/dist/relay/index.d.ts +25 -0
- package/dist/relay/index.js +22 -0
- package/dist/relay/relay-family.d.ts +68 -0
- package/dist/relay/relay-family.js +272 -0
- package/dist/relay/ring.d.ts +49 -0
- package/dist/relay/ring.js +105 -0
- package/dist/relay/scheduler.d.ts +72 -0
- package/dist/relay/scheduler.js +180 -0
- package/dist/relay/validate.d.ts +29 -0
- package/dist/relay/validate.js +39 -0
- package/dist/transcript/index.d.ts +18 -0
- package/dist/transcript/index.js +17 -0
- package/dist/transcript/schema.d.ts +32 -0
- package/dist/transcript/schema.js +48 -0
- package/dist/transcript/store.d.ts +192 -0
- package/dist/transcript/store.js +347 -0
- package/dist/transcript/test-db.d.ts +5 -0
- package/dist/transcript/test-db.js +41 -0
- package/dist/vocab/core-vocab.d.ts +26 -0
- package/dist/vocab/core-vocab.js +67 -0
- package/dist/vocab/diversity.d.ts +78 -0
- package/dist/vocab/diversity.js +89 -0
- package/dist/vocab/index.d.ts +22 -0
- package/dist/vocab/index.js +22 -0
- package/dist/vocab/merge.d.ts +9 -0
- package/dist/vocab/merge.js +104 -0
- package/dist/vocab/requires.d.ts +49 -0
- package/dist/vocab/requires.js +107 -0
- package/dist/vocab/resolver.d.ts +62 -0
- package/dist/vocab/resolver.js +149 -0
- package/dist/vocab/serve.d.ts +39 -0
- package/dist/vocab/serve.js +36 -0
- package/package.json +108 -0
- package/page/cockpit.css +114 -0
- package/page/cockpit.page.json +33 -0
- package/page/embed.html +40 -0
- package/page/mount.js +78 -0
- package/page/standalone.html +43 -0
- package/src/blackboard/family.test.ts +280 -0
- package/src/blackboard/family.ts +208 -0
- package/src/blackboard/index.ts +42 -0
- package/src/blackboard/schema.test.ts +60 -0
- package/src/blackboard/schema.ts +44 -0
- package/src/blackboard/store.test.ts +189 -0
- package/src/blackboard/store.ts +331 -0
- package/src/blackboard/test-db.ts +47 -0
- package/src/channel/auth.test.ts +64 -0
- package/src/channel/auth.ts +101 -0
- package/src/channel/clock.ts +14 -0
- package/src/channel/connection.ts +77 -0
- package/src/channel/dispatch.test.ts +83 -0
- package/src/channel/dispatch.ts +102 -0
- package/src/channel/hub.test.ts +335 -0
- package/src/channel/hub.ts +222 -0
- package/src/channel/index.ts +55 -0
- package/src/channel/registry.test.ts +73 -0
- package/src/channel/registry.ts +137 -0
- package/src/channel/ws-transport.test.ts +234 -0
- package/src/channel/ws-transport.ts +212 -0
- package/src/cockpit/boot.test.ts +374 -0
- package/src/cockpit/boot.ts +280 -0
- package/src/cockpit/fake-dom.ts +90 -0
- package/src/cockpit/index.ts +63 -0
- package/src/cockpit/relay-client.test.ts +359 -0
- package/src/cockpit/relay-client.ts +234 -0
- package/src/cockpit/render.test.ts +149 -0
- package/src/cockpit/render.ts +194 -0
- package/src/cockpit/terminal-session.test.ts +252 -0
- package/src/cockpit/terminal-session.ts +194 -0
- package/src/cockpit/view.test.ts +117 -0
- package/src/cockpit/view.ts +140 -0
- package/src/demand/c8-rest.test.ts +140 -0
- package/src/demand/c8-rest.ts +167 -0
- package/src/demand/index.ts +42 -0
- package/src/demand/model.test.ts +197 -0
- package/src/demand/model.ts +183 -0
- package/src/demand/taskdef.test.ts +85 -0
- package/src/demand/taskdef.ts +78 -0
- package/src/index.ts +17 -0
- package/src/presence/family.test.ts +252 -0
- package/src/presence/family.ts +205 -0
- package/src/presence/index.ts +26 -0
- package/src/presence/schema.test.ts +53 -0
- package/src/presence/schema.ts +34 -0
- package/src/presence/store.test.ts +190 -0
- package/src/presence/store.ts +287 -0
- package/src/presence/test-db.test.ts +57 -0
- package/src/presence/test-db.ts +47 -0
- package/src/protocol/conformance/corpus.test.ts +66 -0
- package/src/protocol/conformance/frames.ts +142 -0
- package/src/protocol/conformance/index.ts +29 -0
- package/src/protocol/conformance/malformed.ts +59 -0
- package/src/protocol/conformance/tokens.ts +70 -0
- package/src/protocol/conformance/vocab.ts +122 -0
- package/src/protocol/families.ts +54 -0
- package/src/protocol/frame.test.ts +116 -0
- package/src/protocol/frame.ts +171 -0
- package/src/protocol/hex.ts +28 -0
- package/src/protocol/index.ts +84 -0
- package/src/protocol/lanes.test.ts +82 -0
- package/src/protocol/lanes.ts +54 -0
- package/src/protocol/payloads.test.ts +91 -0
- package/src/protocol/payloads.ts +201 -0
- package/src/protocol/token.test.ts +57 -0
- package/src/protocol/token.ts +123 -0
- package/src/protocol/vocab/schema.test.ts +67 -0
- package/src/protocol/vocab/schema.ts +281 -0
- package/src/relay/incarnation.test.ts +53 -0
- package/src/relay/incarnation.ts +54 -0
- package/src/relay/index.ts +34 -0
- package/src/relay/integration.test.ts +135 -0
- package/src/relay/relay-family.test.ts +236 -0
- package/src/relay/relay-family.ts +336 -0
- package/src/relay/ring.test.ts +138 -0
- package/src/relay/ring.ts +136 -0
- package/src/relay/scheduler.test.ts +233 -0
- package/src/relay/scheduler.ts +208 -0
- package/src/relay/validate.test.ts +43 -0
- package/src/relay/validate.ts +44 -0
- package/src/transcript/index.ts +33 -0
- package/src/transcript/integration.test.ts +108 -0
- package/src/transcript/schema.test.ts +69 -0
- package/src/transcript/schema.ts +51 -0
- package/src/transcript/store.test.ts +285 -0
- package/src/transcript/store.ts +530 -0
- package/src/transcript/test-db.ts +46 -0
- package/src/vocab/core-vocab.test.ts +34 -0
- package/src/vocab/core-vocab.ts +88 -0
- package/src/vocab/diversity.test.ts +153 -0
- package/src/vocab/diversity.ts +169 -0
- package/src/vocab/index.ts +55 -0
- package/src/vocab/merge.test.ts +73 -0
- package/src/vocab/merge.ts +117 -0
- package/src/vocab/requires.test.ts +69 -0
- package/src/vocab/requires.ts +155 -0
- package/src/vocab/resolver.test.ts +118 -0
- package/src/vocab/resolver.ts +187 -0
- package/src/vocab/serve.test.ts +64 -0
- package/src/vocab/serve.ts +66 -0
|
@@ -0,0 +1,205 @@
|
|
|
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.ts";
|
|
17
|
+
import type { Capability, Frame } from "../protocol/index.ts";
|
|
18
|
+
import type { AgenticHub, HubConnection } from "../channel/index.ts";
|
|
19
|
+
import type { PresenceRow, PresenceStore } from "./store.ts";
|
|
20
|
+
import { PresenceOwnershipError } from "./store.ts";
|
|
21
|
+
|
|
22
|
+
export interface PresenceFamilyOptions {
|
|
23
|
+
/**
|
|
24
|
+
* How often to age out stale presence rows, in ms. Default: a third of the
|
|
25
|
+
* store's TTL. Pass 0 to disable the internal timer (tests call
|
|
26
|
+
* {@link PresenceFamilyHandle.sweepNow} explicitly). The sweep reads "now"
|
|
27
|
+
* from the store's own clock, so liveness time has a single source of truth.
|
|
28
|
+
*/
|
|
29
|
+
sweepIntervalMs?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Notified of a fault this module handles while keeping the connection: a
|
|
32
|
+
* malformed presence payload ({@link PresencePayloadError}), a rejected
|
|
33
|
+
* ownership takeover ({@link PresenceOwnershipError}), or a presence-sweep
|
|
34
|
+
* error. Other handler exceptions are not routed here — they propagate to
|
|
35
|
+
* {@link AgenticHub} and surface via the hub's own error handling.
|
|
36
|
+
*/
|
|
37
|
+
onError?: (err: unknown, connectionId?: string) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Handle to the attached presence family — drives/stops the presence sweep. */
|
|
41
|
+
export interface PresenceFamilyHandle {
|
|
42
|
+
/** Age out stale presence rows now and return the removed rows. */
|
|
43
|
+
sweepNow(): PresenceRow[];
|
|
44
|
+
/** Stop the presence sweep timer. */
|
|
45
|
+
stop(): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A malformed presence payload rejected before it touches the store. */
|
|
49
|
+
export class PresencePayloadError extends Error {
|
|
50
|
+
readonly family: string;
|
|
51
|
+
constructor(family: string, detail: string) {
|
|
52
|
+
super(`invalid ${family} payload: ${detail}`);
|
|
53
|
+
this.name = "PresencePayloadError";
|
|
54
|
+
this.family = family;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
59
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readString(record: Record<string, unknown>, key: string): string | undefined {
|
|
63
|
+
const value = record[key];
|
|
64
|
+
return typeof value === "string" ? value : undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readNumber(record: Record<string, unknown>, key: string): number | undefined {
|
|
68
|
+
const value = record[key];
|
|
69
|
+
return typeof value === "number" ? value : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Extract the enrolment capability from a validated `register` payload. */
|
|
73
|
+
function readCapability(record: Record<string, unknown>): Capability {
|
|
74
|
+
const raw = record.capability;
|
|
75
|
+
if (!isPlainObject(raw)) return {};
|
|
76
|
+
const cap: { cognition?: string; weight?: number; family?: string; host?: string } = {};
|
|
77
|
+
const cognition = readString(raw, "cognition");
|
|
78
|
+
if (cognition !== undefined) cap.cognition = cognition;
|
|
79
|
+
const weight = readNumber(raw, "weight");
|
|
80
|
+
if (weight !== undefined) cap.weight = weight;
|
|
81
|
+
const family = readString(raw, "family");
|
|
82
|
+
if (family !== undefined) cap.family = family;
|
|
83
|
+
const host = readString(raw, "host");
|
|
84
|
+
if (host !== undefined) cap.host = host;
|
|
85
|
+
return cap;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Attach the presence family (`register`/`heartbeat`/`deregister`) to `hub`,
|
|
90
|
+
* backed by `store`. Registers three handlers via the S1 seam and schedules the
|
|
91
|
+
* presence-TTL sweep. Returns a handle to drive/stop the sweep.
|
|
92
|
+
*
|
|
93
|
+
* @throws DuplicateFamilyHandlerError if one of the three families already has
|
|
94
|
+
* a handler on this hub (one family, one owning module).
|
|
95
|
+
*/
|
|
96
|
+
export function attachPresenceFamily(
|
|
97
|
+
hub: AgenticHub,
|
|
98
|
+
store: PresenceStore,
|
|
99
|
+
options: PresenceFamilyOptions = {},
|
|
100
|
+
): PresenceFamilyHandle {
|
|
101
|
+
store.ensureSchema();
|
|
102
|
+
const onError = options.onError ?? (() => {});
|
|
103
|
+
|
|
104
|
+
const reject = (family: string, connectionId: string, detail: string): void => {
|
|
105
|
+
onError(new PresencePayloadError(family, detail), connectionId);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
hub.registerFamilyHandler("register", (frame: Frame, ctx: HubConnection) => {
|
|
109
|
+
const payload = frame.payload;
|
|
110
|
+
const result = validatePayload("register", payload);
|
|
111
|
+
if (!result.ok) {
|
|
112
|
+
reject("register", ctx.id, result.errors.map((e) => e.message).join("; "));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (!isPlainObject(payload)) {
|
|
116
|
+
reject("register", ctx.id, "not an object");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const instance = readString(payload, "instance");
|
|
120
|
+
if (instance === undefined) {
|
|
121
|
+
reject("register", ctx.id, "missing instance");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const capability = readCapability(payload);
|
|
125
|
+
try {
|
|
126
|
+
store.register({ instance, connectionId: ctx.id, identity: ctx.identity, capability });
|
|
127
|
+
} catch (err) {
|
|
128
|
+
// An instance owned by another identity must not be hijacked: surface the
|
|
129
|
+
// rejection and leave S1's in-memory registry untouched.
|
|
130
|
+
if (err instanceof PresenceOwnershipError) {
|
|
131
|
+
onError(err, ctx.id);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
// Mirror the enrolment onto S1's in-memory connection registry so a live
|
|
137
|
+
// connection carries its instance+capability without a DB read.
|
|
138
|
+
ctx.registry.setPresence(ctx.id, { instance, capability });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
hub.registerFamilyHandler("heartbeat", (frame: Frame, ctx: HubConnection) => {
|
|
142
|
+
const payload = frame.payload;
|
|
143
|
+
const result = validatePayload("heartbeat", payload);
|
|
144
|
+
if (!result.ok) {
|
|
145
|
+
reject("heartbeat", ctx.id, result.errors.map((e) => e.message).join("; "));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (!isPlainObject(payload)) {
|
|
149
|
+
reject("heartbeat", ctx.id, "not an object");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const instance = readString(payload, "instance");
|
|
153
|
+
if (instance === undefined) {
|
|
154
|
+
reject("heartbeat", ctx.id, "missing instance");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
store.heartbeat(instance, ctx.identity);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
hub.registerFamilyHandler("deregister", (frame: Frame, ctx: HubConnection) => {
|
|
161
|
+
const payload = frame.payload;
|
|
162
|
+
const result = validatePayload("deregister", payload);
|
|
163
|
+
if (!result.ok) {
|
|
164
|
+
reject("deregister", ctx.id, result.errors.map((e) => e.message).join("; "));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!isPlainObject(payload)) {
|
|
168
|
+
reject("deregister", ctx.id, "not an object");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const instance = readString(payload, "instance");
|
|
172
|
+
if (instance === undefined) {
|
|
173
|
+
reject("deregister", ctx.id, "missing instance");
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
store.deregister(instance, ctx.identity);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const sweepNow = (): PresenceRow[] => {
|
|
180
|
+
try {
|
|
181
|
+
return store.sweep();
|
|
182
|
+
} catch (err) {
|
|
183
|
+
onError(err);
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const interval = options.sweepIntervalMs ?? Math.max(1, Math.floor(store.ttlMs / 3));
|
|
189
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
190
|
+
if (interval > 0) {
|
|
191
|
+
timer = setInterval(sweepNow, interval);
|
|
192
|
+
// Do not keep the process alive solely for the presence sweep.
|
|
193
|
+
timer.unref?.();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
sweepNow,
|
|
198
|
+
stop() {
|
|
199
|
+
if (timer !== undefined) {
|
|
200
|
+
clearInterval(timer);
|
|
201
|
+
timer = undefined;
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
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 {
|
|
17
|
+
PresenceRow,
|
|
18
|
+
PresenceStoreOptions,
|
|
19
|
+
RegisterInput,
|
|
20
|
+
SqliteDb,
|
|
21
|
+
} from "./store.ts";
|
|
22
|
+
|
|
23
|
+
export { attachPresenceFamily, PresencePayloadError } from "./family.ts";
|
|
24
|
+
export type { PresenceFamilyOptions, PresenceFamilyHandle } from "./family.ts";
|
|
25
|
+
|
|
26
|
+
export { PRESENCE_TABLE, PRESENCE_SCHEMA_SQL } from "./schema.ts";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { PRESENCE_SCHEMA_SQL, PRESENCE_TABLE } from "./schema.ts";
|
|
6
|
+
import { PresenceStore } from "./store.ts";
|
|
7
|
+
import { openTestDb } from "./test-db.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Normalise SQL for a drift comparison: drop `-- …` line comments, collapse all
|
|
11
|
+
* runs of whitespace to a single space, and trim. Two DDL scripts that create
|
|
12
|
+
* the same objects with the same columns normalise identically regardless of
|
|
13
|
+
* comments/indentation.
|
|
14
|
+
*/
|
|
15
|
+
function normaliseSql(sql: string): string {
|
|
16
|
+
return sql
|
|
17
|
+
.split("\n")
|
|
18
|
+
.map((line) => line.replace(/--.*$/, ""))
|
|
19
|
+
.join("\n")
|
|
20
|
+
.replace(/\s+/g, " ")
|
|
21
|
+
.trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const migrationPath = fileURLToPath(new URL("../../../../db/migrations/001_agentic_presence.sql", import.meta.url));
|
|
25
|
+
|
|
26
|
+
test("the boot migration and PresenceStore's DDL do not drift", () => {
|
|
27
|
+
const migrationSql = readFileSync(migrationPath, "utf8");
|
|
28
|
+
assert.equal(
|
|
29
|
+
normaliseSql(migrationSql),
|
|
30
|
+
normaliseSql(PRESENCE_SCHEMA_SQL),
|
|
31
|
+
"db/migrations/001_agentic_presence.sql must match PRESENCE_SCHEMA_SQL — update both together",
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("the boot migration is forward-only and additive (IF NOT EXISTS, no drops)", () => {
|
|
36
|
+
const migrationSql = readFileSync(migrationPath, "utf8");
|
|
37
|
+
assert.match(migrationSql, /CREATE TABLE IF NOT EXISTS agentic_presence/);
|
|
38
|
+
assert.doesNotMatch(migrationSql, /\bDROP\b/i);
|
|
39
|
+
assert.doesNotMatch(migrationSql, /\bALTER\b/i);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("ensureSchema creates the presence table idempotently", () => {
|
|
43
|
+
const db = openTestDb();
|
|
44
|
+
const store = new PresenceStore(db);
|
|
45
|
+
store.ensureSchema();
|
|
46
|
+
store.ensureSchema();
|
|
47
|
+
const tables = db.all<{ name: string }>(
|
|
48
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name = ?",
|
|
49
|
+
[PRESENCE_TABLE],
|
|
50
|
+
);
|
|
51
|
+
assert.equal(tables.length, 1);
|
|
52
|
+
db.close();
|
|
53
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
|
|
13
|
+
/** The presence & registry table name. */
|
|
14
|
+
export const PRESENCE_TABLE = "agentic_presence";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The canonical presence-registry DDL. Forward-only and additive; every column
|
|
18
|
+
* added here must also be added to the boot migration (the drift guard enforces
|
|
19
|
+
* it). Capability (cognition/weight/family/host) is stored as an ENROLMENT
|
|
20
|
+
* attribute — never a routing token.
|
|
21
|
+
*/
|
|
22
|
+
export const PRESENCE_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${PRESENCE_TABLE} (
|
|
23
|
+
instance TEXT PRIMARY KEY,
|
|
24
|
+
connection_id TEXT NOT NULL,
|
|
25
|
+
identity TEXT NOT NULL,
|
|
26
|
+
cognition TEXT,
|
|
27
|
+
weight REAL,
|
|
28
|
+
family TEXT,
|
|
29
|
+
host TEXT,
|
|
30
|
+
registered_at TEXT NOT NULL,
|
|
31
|
+
last_seen INTEGER NOT NULL
|
|
32
|
+
);
|
|
33
|
+
CREATE INDEX IF NOT EXISTS idx_${PRESENCE_TABLE}_last_seen ON ${PRESENCE_TABLE} (last_seen);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_${PRESENCE_TABLE}_connection ON ${PRESENCE_TABLE} (connection_id);`;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import type { Clock } from "../channel/index.ts";
|
|
4
|
+
import { PresenceOwnershipError, PresenceStore } from "./store.ts";
|
|
5
|
+
import { openTestDb } from "./test-db.ts";
|
|
6
|
+
|
|
7
|
+
function fakeClock(start = 1000): Clock & { set(t: number): void } {
|
|
8
|
+
let t = start;
|
|
9
|
+
return { now: () => t, set: (v: number) => (t = v) };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function freshStore(opts: { ttlMs?: number; clock?: Clock } = {}): PresenceStore {
|
|
13
|
+
const db = openTestDb();
|
|
14
|
+
const store = new PresenceStore(db, opts);
|
|
15
|
+
store.ensureSchema();
|
|
16
|
+
return store;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test("register makes an instance appear with presence + host/family", () => {
|
|
20
|
+
const clock = fakeClock(5000);
|
|
21
|
+
const store = freshStore({ clock });
|
|
22
|
+
|
|
23
|
+
const row = store.register({
|
|
24
|
+
instance: "w-1",
|
|
25
|
+
connectionId: "c1",
|
|
26
|
+
identity: "peer-a",
|
|
27
|
+
capability: { cognition: "opus", weight: 4, family: "anthropic", host: "mac-1" },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
assert.equal(row.instance, "w-1");
|
|
31
|
+
assert.equal(row.connectionId, "c1");
|
|
32
|
+
assert.equal(row.identity, "peer-a");
|
|
33
|
+
assert.deepEqual(row.capability, { cognition: "opus", weight: 4, family: "anthropic", host: "mac-1" });
|
|
34
|
+
assert.equal(row.registeredAt, new Date(5000).toISOString());
|
|
35
|
+
assert.equal(row.lastSeen, 5000);
|
|
36
|
+
|
|
37
|
+
assert.deepEqual(store.get("w-1"), row);
|
|
38
|
+
assert.equal(store.count(), 1);
|
|
39
|
+
assert.deepEqual(store.list().map((r) => r.instance), ["w-1"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("a partial capability stores only the declared attributes", () => {
|
|
43
|
+
const store = freshStore();
|
|
44
|
+
const row = store.register({
|
|
45
|
+
instance: "w-2",
|
|
46
|
+
connectionId: "c2",
|
|
47
|
+
identity: "peer-b",
|
|
48
|
+
capability: { family: "openai" },
|
|
49
|
+
});
|
|
50
|
+
assert.deepEqual(row.capability, { family: "openai" });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("re-register keeps registered_at but refreshes connection, capability and liveness", () => {
|
|
54
|
+
const clock = fakeClock(1000);
|
|
55
|
+
const store = freshStore({ clock });
|
|
56
|
+
store.register({ instance: "w-1", connectionId: "c1", identity: "peer-a", capability: { host: "h1" } });
|
|
57
|
+
|
|
58
|
+
clock.set(2000);
|
|
59
|
+
const again = store.register({
|
|
60
|
+
instance: "w-1",
|
|
61
|
+
connectionId: "c2",
|
|
62
|
+
identity: "peer-a",
|
|
63
|
+
capability: { host: "h2" },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
assert.equal(again.registeredAt, new Date(1000).toISOString(), "registered_at is preserved");
|
|
67
|
+
assert.equal(again.connectionId, "c2");
|
|
68
|
+
assert.deepEqual(again.capability, { host: "h2" });
|
|
69
|
+
assert.equal(again.lastSeen, 2000);
|
|
70
|
+
assert.equal(store.count(), 1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("heartbeat refreshes liveness for a known instance and no-ops otherwise", () => {
|
|
74
|
+
const clock = fakeClock(1000);
|
|
75
|
+
const store = freshStore({ clock });
|
|
76
|
+
store.register({ instance: "w-1", connectionId: "c1", identity: "peer-a", capability: {} });
|
|
77
|
+
|
|
78
|
+
clock.set(9000);
|
|
79
|
+
assert.equal(store.heartbeat("w-1"), true);
|
|
80
|
+
assert.equal(store.get("w-1")?.lastSeen, 9000);
|
|
81
|
+
|
|
82
|
+
assert.equal(store.heartbeat("nope"), false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("deregister removes an instance", () => {
|
|
86
|
+
const store = freshStore();
|
|
87
|
+
store.register({ instance: "w-1", connectionId: "c1", identity: "peer-a", capability: {} });
|
|
88
|
+
assert.equal(store.deregister("w-1"), true);
|
|
89
|
+
assert.equal(store.get("w-1"), undefined);
|
|
90
|
+
assert.equal(store.deregister("w-1"), false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("re-register with identical values from the same identity is not a false ownership violation", () => {
|
|
94
|
+
const clock = fakeClock(1000);
|
|
95
|
+
const store = freshStore({ clock });
|
|
96
|
+
const input = {
|
|
97
|
+
instance: "w-1",
|
|
98
|
+
connectionId: "c1",
|
|
99
|
+
identity: "peer-a",
|
|
100
|
+
capability: { cognition: "opus", weight: 4, family: "anthropic", host: "h1" },
|
|
101
|
+
};
|
|
102
|
+
store.register(input);
|
|
103
|
+
|
|
104
|
+
// A byte-for-byte identical re-register (a no-op UPDATE) must still succeed:
|
|
105
|
+
// ownership is decided on the stored identity, not the driver's changed-row
|
|
106
|
+
// count, so a no-op UPSERT can never masquerade as a takeover.
|
|
107
|
+
assert.doesNotThrow(() => store.register(input));
|
|
108
|
+
const row = store.register(input);
|
|
109
|
+
assert.equal(row.identity, "peer-a");
|
|
110
|
+
assert.equal(store.count(), 1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("register refuses to hijack an instance owned by a different identity", () => {
|
|
114
|
+
const clock = fakeClock(1000);
|
|
115
|
+
const store = freshStore({ clock });
|
|
116
|
+
store.register({ instance: "w-1", connectionId: "c1", identity: "peer-a", capability: { host: "h1" } });
|
|
117
|
+
|
|
118
|
+
clock.set(2000);
|
|
119
|
+
assert.throws(
|
|
120
|
+
() =>
|
|
121
|
+
store.register({
|
|
122
|
+
instance: "w-1",
|
|
123
|
+
connectionId: "c2",
|
|
124
|
+
identity: "peer-b",
|
|
125
|
+
capability: { host: "evil" },
|
|
126
|
+
}),
|
|
127
|
+
PresenceOwnershipError,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// The original owner's row is untouched — no connection/identity/capability takeover.
|
|
131
|
+
const row = store.get("w-1");
|
|
132
|
+
assert.equal(row?.identity, "peer-a");
|
|
133
|
+
assert.equal(row?.connectionId, "c1");
|
|
134
|
+
assert.deepEqual(row?.capability, { host: "h1" });
|
|
135
|
+
assert.equal(row?.lastSeen, 1000, "a blocked re-register does not refresh liveness");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("heartbeat scoped to an identity no-ops for a foreign identity", () => {
|
|
139
|
+
const clock = fakeClock(1000);
|
|
140
|
+
const store = freshStore({ clock });
|
|
141
|
+
store.register({ instance: "w-1", connectionId: "c1", identity: "peer-a", capability: {} });
|
|
142
|
+
|
|
143
|
+
clock.set(9000);
|
|
144
|
+
assert.equal(store.heartbeat("w-1", "peer-b"), false, "a foreign identity cannot refresh liveness");
|
|
145
|
+
assert.equal(store.get("w-1")?.lastSeen, 1000, "liveness unchanged by a foreign heartbeat");
|
|
146
|
+
|
|
147
|
+
assert.equal(store.heartbeat("w-1", "peer-a"), true, "the owning identity refreshes liveness");
|
|
148
|
+
assert.equal(store.get("w-1")?.lastSeen, 9000);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("deregister scoped to an identity no-ops for a foreign identity", () => {
|
|
152
|
+
const store = freshStore();
|
|
153
|
+
store.register({ instance: "w-1", connectionId: "c1", identity: "peer-a", capability: {} });
|
|
154
|
+
|
|
155
|
+
assert.equal(store.deregister("w-1", "peer-b"), false, "a foreign identity cannot deregister");
|
|
156
|
+
assert.notEqual(store.get("w-1"), undefined, "row survives a foreign deregister");
|
|
157
|
+
|
|
158
|
+
assert.equal(store.deregister("w-1", "peer-a"), true, "the owning identity deregisters");
|
|
159
|
+
assert.equal(store.get("w-1"), undefined);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("sweep ages out instances past the TTL and keeps live ones", () => {
|
|
163
|
+
const clock = fakeClock(1000);
|
|
164
|
+
const store = freshStore({ ttlMs: 100, clock });
|
|
165
|
+
|
|
166
|
+
store.register({ instance: "stale", connectionId: "c1", identity: "peer-a", capability: {} });
|
|
167
|
+
clock.set(1050);
|
|
168
|
+
store.register({ instance: "fresh", connectionId: "c2", identity: "peer-b", capability: {} });
|
|
169
|
+
|
|
170
|
+
// now=1200: stale last_seen=1000 (age 200 > 100) ages out; fresh last_seen=1050 (age 150 > 100) too.
|
|
171
|
+
// Bump fresh so only stale is old.
|
|
172
|
+
clock.set(1120);
|
|
173
|
+
store.heartbeat("fresh"); // fresh last_seen=1120
|
|
174
|
+
const removed = store.sweep(1160); // stale age 160>100; fresh age 40<100
|
|
175
|
+
|
|
176
|
+
assert.deepEqual(removed.map((r) => r.instance), ["stale"]);
|
|
177
|
+
assert.deepEqual(store.list().map((r) => r.instance), ["fresh"]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("removeByConnection drops every instance on a dead connection", () => {
|
|
181
|
+
const store = freshStore();
|
|
182
|
+
store.register({ instance: "a", connectionId: "c1", identity: "peer-a", capability: {} });
|
|
183
|
+
store.register({ instance: "b", connectionId: "c1", identity: "peer-a", capability: {} });
|
|
184
|
+
store.register({ instance: "c", connectionId: "c2", identity: "peer-b", capability: {} });
|
|
185
|
+
|
|
186
|
+
const removed = store.removeByConnection("c1").sort();
|
|
187
|
+
assert.deepEqual(removed, ["a", "b"]);
|
|
188
|
+
assert.deepEqual(store.list().map((r) => r.instance), ["c"]);
|
|
189
|
+
assert.deepEqual(store.removeByConnection("cX"), []);
|
|
190
|
+
});
|