@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,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
|
+
];
|