@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,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The blackboard store — S7's durable layer over the app DataLayer.
|
|
3
|
+
*
|
|
4
|
+
* A per-scope advisory shared store. Agents READ it on dispatch and WRITE to it
|
|
5
|
+
* during their work ("I now also touch state.rs", "constraint X changed") so
|
|
6
|
+
* parallel siblings coordinate without a human relay. It is a faithful port of
|
|
7
|
+
* nano-workforce's `plan_blackboard` (issues #51 / #49 D4) — the same kinds,
|
|
8
|
+
* idempotent `dedupe_key` append, `file-claim` conflict reporting and
|
|
9
|
+
* `since`/cursor incremental reads — generalised from a hard-wired `plan_key` to
|
|
10
|
+
* a capability-derived `scope`, so any Urban app gets it for free.
|
|
11
|
+
*
|
|
12
|
+
* Design invariants (unchanged from the HTTP hook):
|
|
13
|
+
* - ADVISORY ONLY. It never gates a sequence flow; it is shared *knowledge*,
|
|
14
|
+
* read fresh, and is not part of deterministic replay.
|
|
15
|
+
* - IDEMPOTENT append. A re-POST carrying a stable `dedupeKey` collapses to one
|
|
16
|
+
* row (backed by a partial UNIQUE index; we also short-circuit here).
|
|
17
|
+
* - SCOPE-BOUND. Every row carries the `scope` it was written under; reads and
|
|
18
|
+
* conflict detection never cross scopes.
|
|
19
|
+
*
|
|
20
|
+
* The store speaks only the tiny synchronous SQLite subset the runtime exposes
|
|
21
|
+
* ({@link SqliteDb}), so it works against any app DataLayer source without
|
|
22
|
+
* pulling in the whole runtime (identical to the S2 presence / S6 transcript
|
|
23
|
+
* stores' `SqliteDb`).
|
|
24
|
+
*/
|
|
25
|
+
import { BLACKBOARD_SCHEMA_SQL, BLACKBOARD_TABLE } from "./schema.js";
|
|
26
|
+
/** The default clock: `Date.now()`. */
|
|
27
|
+
export const systemClock = { now: () => Date.now() };
|
|
28
|
+
/** The recognised blackboard entry kinds (verbatim from nano-workforce). */
|
|
29
|
+
export const BLACKBOARD_KINDS = ["file-claim", "constraint-change", "scope-change", "learning", "note"];
|
|
30
|
+
/** Coerce an arbitrary `kind` to a known value, defaulting to "note". */
|
|
31
|
+
export function normalizeKind(kind) {
|
|
32
|
+
return BLACKBOARD_KINDS.find((k) => k === kind) ?? "note";
|
|
33
|
+
}
|
|
34
|
+
function decodeFiles(raw) {
|
|
35
|
+
if (!raw)
|
|
36
|
+
return [];
|
|
37
|
+
try {
|
|
38
|
+
const v = JSON.parse(raw);
|
|
39
|
+
return Array.isArray(v)
|
|
40
|
+
? v.map((x) => String(x).trim()).filter((s) => s !== "")
|
|
41
|
+
: [];
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function normaliseFiles(files) {
|
|
48
|
+
return (files ?? []).map((f) => String(f).trim()).filter((s) => s !== "");
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Canonical author identity for a write. A blank/omitted `authorTask` is a host
|
|
52
|
+
* write and normalises to "system". This is the single source of truth for the
|
|
53
|
+
* default so `append` (what gets stored) and `detectFileClaimConflicts` (whose
|
|
54
|
+
* claim to self-exclude) can never drift and falsely self-conflict.
|
|
55
|
+
*/
|
|
56
|
+
function normaliseAuthor(authorTask) {
|
|
57
|
+
return authorTask?.trim() || "system";
|
|
58
|
+
}
|
|
59
|
+
function toEntry(r) {
|
|
60
|
+
return {
|
|
61
|
+
id: r.id,
|
|
62
|
+
authorTask: r.author_task,
|
|
63
|
+
kind: r.kind,
|
|
64
|
+
files: decodeFiles(r.files),
|
|
65
|
+
body: r.body,
|
|
66
|
+
wave: r.wave,
|
|
67
|
+
createdAt: r.created_at,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* True only for a UNIQUE / PRIMARY-KEY / duplicate violation — never a
|
|
72
|
+
* foreign-key or other constraint failure. We match the *specific* violation
|
|
73
|
+
* (extended SQLite codes, or the specific words) rather than the bare word
|
|
74
|
+
* "constraint", so a `FOREIGN KEY constraint failed` (real corruption, not a
|
|
75
|
+
* benign duplicate) is always rethrown rather than silently swallowed.
|
|
76
|
+
*/
|
|
77
|
+
export function isUniqueViolation(err) {
|
|
78
|
+
if (typeof err !== "object" || err === null)
|
|
79
|
+
return false;
|
|
80
|
+
const code = "code" in err ? err.code : undefined;
|
|
81
|
+
if (code === "SQLITE_CONSTRAINT_UNIQUE" || code === "SQLITE_CONSTRAINT_PRIMARYKEY")
|
|
82
|
+
return true;
|
|
83
|
+
const message = "message" in err ? err.message : undefined;
|
|
84
|
+
return typeof message === "string" && /(unique|primary key) constraint failed|duplicate/i.test(message);
|
|
85
|
+
}
|
|
86
|
+
export class BlackboardStore {
|
|
87
|
+
#db;
|
|
88
|
+
#clock;
|
|
89
|
+
constructor(db, options = {}) {
|
|
90
|
+
this.#db = db;
|
|
91
|
+
this.#clock = options.clock ?? systemClock;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Apply the canonical blackboard DDL (idempotent). Callers that let the app
|
|
95
|
+
* DataLayer migration runner apply `db/migrations/003_agentic_blackboard.sql`
|
|
96
|
+
* do not need this — the family module calls it so the store is usable against
|
|
97
|
+
* a bare source too. The DDL is identical to the migration (drift-guarded).
|
|
98
|
+
*/
|
|
99
|
+
ensureSchema() {
|
|
100
|
+
this.#db.exec(BLACKBOARD_SCHEMA_SQL);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Append an entry to a board, idempotently. A blank `body` is rejected. When a
|
|
104
|
+
* `dedupeKey` is supplied and an entry already exists for it under this
|
|
105
|
+
* `scope`, the write is a no-op and the existing id is returned
|
|
106
|
+
* (`inserted: false`) — so an engine job retry re-appending the same fact
|
|
107
|
+
* never duplicates.
|
|
108
|
+
*/
|
|
109
|
+
append(scope, input) {
|
|
110
|
+
const body = typeof input.body === "string" ? input.body.trim() : "";
|
|
111
|
+
if (!body)
|
|
112
|
+
throw new Error("blackboard entry requires a non-empty body");
|
|
113
|
+
const dedupeKey = input.dedupeKey?.trim() || undefined;
|
|
114
|
+
if (dedupeKey) {
|
|
115
|
+
const existing = this.#findByDedupe(scope, dedupeKey);
|
|
116
|
+
if (existing !== undefined)
|
|
117
|
+
return { inserted: false, id: existing };
|
|
118
|
+
}
|
|
119
|
+
const files = normaliseFiles(input.files);
|
|
120
|
+
try {
|
|
121
|
+
const { lastInsertRowid } = this.#db.run(`INSERT INTO ${BLACKBOARD_TABLE}
|
|
122
|
+
(scope, author_task, kind, files, body, wave, dedupe_key, created_at)
|
|
123
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
124
|
+
scope,
|
|
125
|
+
normaliseAuthor(input.authorTask),
|
|
126
|
+
normalizeKind(input.kind),
|
|
127
|
+
files.length ? JSON.stringify(files) : null,
|
|
128
|
+
body,
|
|
129
|
+
typeof input.wave === "number" ? input.wave : null,
|
|
130
|
+
dedupeKey ?? null,
|
|
131
|
+
new Date(this.#clock.now()).toISOString(),
|
|
132
|
+
]);
|
|
133
|
+
return { inserted: true, id: Number(lastInsertRowid) };
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
// Idempotent write-back under concurrency: two appends sharing a dedupeKey
|
|
137
|
+
// can both miss the pre-check above, then one loses the race on the UNIQUE
|
|
138
|
+
// (scope, dedupe_key) index. Convert that collision into a no-op by
|
|
139
|
+
// re-reading the winner's row, so a retry never throws.
|
|
140
|
+
if (dedupeKey && isUniqueViolation(err)) {
|
|
141
|
+
const existing = this.#findByDedupe(scope, dedupeKey);
|
|
142
|
+
if (existing !== undefined)
|
|
143
|
+
return { inserted: false, id: existing };
|
|
144
|
+
}
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* One incremental read of a board: entries with `id > since` in write order,
|
|
150
|
+
* plus the board's current head `cursor`. An agent polling mid-flight passes
|
|
151
|
+
* `cursor` back as the next `since`, so it pulls only what siblings added since
|
|
152
|
+
* its last read.
|
|
153
|
+
*/
|
|
154
|
+
readPage(scope, opts = {}) {
|
|
155
|
+
const since = opts.since ?? 0;
|
|
156
|
+
// Read the head id independently so the cursor is the board's true head even
|
|
157
|
+
// when `since` filters every entry out (a caught-up poller learns it is
|
|
158
|
+
// caught up), and let SQL page with `id > since` against the (scope, id)
|
|
159
|
+
// index instead of loading the whole board to filter client-side.
|
|
160
|
+
const head = this.#db.all(`SELECT MAX(id) AS cursor FROM ${BLACKBOARD_TABLE} WHERE scope = ?`, [scope]);
|
|
161
|
+
const cursor = Number(head[0]?.cursor ?? 0);
|
|
162
|
+
const rows = this.#db.all(`SELECT * FROM ${BLACKBOARD_TABLE} WHERE scope = ? AND id > ? ORDER BY id`, [scope, since]);
|
|
163
|
+
const entries = rows.map(toEntry);
|
|
164
|
+
return { entries, cursor };
|
|
165
|
+
}
|
|
166
|
+
/** A board's entries in write order (id asc). `since` returns only `id > since`. */
|
|
167
|
+
read(scope, opts = {}) {
|
|
168
|
+
return this.readPage(scope, opts).entries;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Prior `file-claim` entries by OTHER authors under this `scope` that overlap
|
|
172
|
+
* `files`. A writer's own earlier claim is never a conflict with itself. Pass
|
|
173
|
+
* `beforeId` to restrict to strictly prior claims (`id < beforeId`) — the
|
|
174
|
+
* family computes conflicts AFTER inserting its own claim and sets `beforeId`
|
|
175
|
+
* to that new id, so first-writer-wins is decided by insertion order and a
|
|
176
|
+
* sibling claim that raced in concurrently is still surfaced without matching
|
|
177
|
+
* the writer's own just-written row.
|
|
178
|
+
*/
|
|
179
|
+
detectFileClaimConflicts(scope, opts) {
|
|
180
|
+
const want = new Set(normaliseFiles(opts.files));
|
|
181
|
+
if (want.size === 0)
|
|
182
|
+
return [];
|
|
183
|
+
const me = normaliseAuthor(opts.authorTask);
|
|
184
|
+
const beforeId = opts.beforeId;
|
|
185
|
+
// Push `id < beforeId` into SQL (the common path per the docstring) so we
|
|
186
|
+
// scan only strictly-prior claims via the (scope, id) index rather than
|
|
187
|
+
// loading every file-claim for the scope and filtering client-side.
|
|
188
|
+
const clauses = ["scope = ?", "kind = 'file-claim'"];
|
|
189
|
+
const params = [scope];
|
|
190
|
+
if (beforeId != null) {
|
|
191
|
+
clauses.push("id < ?");
|
|
192
|
+
params.push(beforeId);
|
|
193
|
+
}
|
|
194
|
+
const rows = this.#db.all(`SELECT * FROM ${BLACKBOARD_TABLE} WHERE ${clauses.join(" AND ")} ORDER BY id`, params);
|
|
195
|
+
const out = [];
|
|
196
|
+
for (const r of rows) {
|
|
197
|
+
if ((r.author_task || "") === me)
|
|
198
|
+
continue;
|
|
199
|
+
for (const f of new Set(decodeFiles(r.files))) {
|
|
200
|
+
if (want.has(f)) {
|
|
201
|
+
out.push({ file: f, authorTask: r.author_task, id: r.id, body: r.body, createdAt: r.created_at });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
/** Number of entries under a scope. */
|
|
208
|
+
count(scope) {
|
|
209
|
+
const rows = this.#db.all(`SELECT COUNT(*) AS n FROM ${BLACKBOARD_TABLE} WHERE scope = ?`, [scope]);
|
|
210
|
+
return rows[0]?.n ?? 0;
|
|
211
|
+
}
|
|
212
|
+
#findByDedupe(scope, dedupeKey) {
|
|
213
|
+
const rows = this.#db.all(`SELECT id FROM ${BLACKBOARD_TABLE} WHERE scope = ? AND dedupe_key = ?`, [scope, dedupeKey]);
|
|
214
|
+
return rows[0]?.id;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { HandshakeRequest } from "./connection.ts";
|
|
2
|
+
/** What the authenticator grants a peer once it passes the gate. */
|
|
3
|
+
export interface AuthGrant {
|
|
4
|
+
/** The authenticated principal (ADR 0028 identity). */
|
|
5
|
+
readonly identity: string;
|
|
6
|
+
/** Optional scope the identity is confined to (e.g. a plan/network). */
|
|
7
|
+
readonly scope?: string;
|
|
8
|
+
/** The capability credential the peer presented, if any. */
|
|
9
|
+
readonly capability?: string;
|
|
10
|
+
}
|
|
11
|
+
/** Application close code for a rejected identity token. */
|
|
12
|
+
export declare const AUTH_UNAUTHORIZED = 4401;
|
|
13
|
+
/** Application close code for a missing/rejected capability credential. */
|
|
14
|
+
export declare const AUTH_FORBIDDEN = 4403;
|
|
15
|
+
export type AuthResult = {
|
|
16
|
+
readonly ok: true;
|
|
17
|
+
readonly grant: AuthGrant;
|
|
18
|
+
} | {
|
|
19
|
+
readonly ok: false;
|
|
20
|
+
readonly code: number;
|
|
21
|
+
readonly reason: string;
|
|
22
|
+
};
|
|
23
|
+
/** Verifies a handshake and either grants or rejects the connection. */
|
|
24
|
+
export type Authenticator = (req: HandshakeRequest) => AuthResult | Promise<AuthResult>;
|
|
25
|
+
export interface SharedSecretAuthOptions {
|
|
26
|
+
/** The shared identity-token secret every valid peer presents. */
|
|
27
|
+
readonly secret: string;
|
|
28
|
+
/** Require a capability credential too (default true). */
|
|
29
|
+
readonly requireCredential?: boolean;
|
|
30
|
+
/** Extra check on the credential; return false to reject. Default accept-any. */
|
|
31
|
+
readonly verifyCredential?: (credential: string, identity: string) => boolean;
|
|
32
|
+
/** Derive the identity from the handshake. Default: the connection's remote address (`req.remote`), or `anonymous`. */
|
|
33
|
+
readonly identityFor?: (req: HandshakeRequest) => string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The default authenticator: a shared-secret identity token gate plus a required
|
|
37
|
+
* capability credential. Mirrors nano-workforce's blackboard-hook `?token=…`
|
|
38
|
+
* pattern; swap in a real ADR 0028 verifier by passing your own
|
|
39
|
+
* {@link Authenticator} to the hub.
|
|
40
|
+
*/
|
|
41
|
+
export declare function sharedSecretAuthenticator(options: SharedSecretAuthOptions): Authenticator;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection authentication.
|
|
3
|
+
*
|
|
4
|
+
* A peer authenticates at the app-tier handshake with an ADR 0028 identity
|
|
5
|
+
* token PLUS a capability credential — the same pattern nano-workforce's
|
|
6
|
+
* blackboard hook already uses (`?token=…`). This is a connection-level gate:
|
|
7
|
+
* capability (cognition / weight / family / host) travels later on the
|
|
8
|
+
* `register` frame as an enrolment attribute and is NEVER a routing token.
|
|
9
|
+
*
|
|
10
|
+
* The hub takes any {@link Authenticator}; {@link sharedSecretAuthenticator} is
|
|
11
|
+
* the batteries-included default.
|
|
12
|
+
*/
|
|
13
|
+
import { timingSafeEqual } from "node:crypto";
|
|
14
|
+
/** Application close code for a rejected identity token. */
|
|
15
|
+
export const AUTH_UNAUTHORIZED = 4401;
|
|
16
|
+
/** Application close code for a missing/rejected capability credential. */
|
|
17
|
+
export const AUTH_FORBIDDEN = 4403;
|
|
18
|
+
/**
|
|
19
|
+
* Constant-time string comparison. Unequal-length inputs short-circuit to
|
|
20
|
+
* `false` (length is not itself secret here); equal-length inputs are compared
|
|
21
|
+
* in constant time via {@link timingSafeEqual}.
|
|
22
|
+
*/
|
|
23
|
+
function safeEqual(a, b) {
|
|
24
|
+
const ab = new TextEncoder().encode(a);
|
|
25
|
+
const bb = new TextEncoder().encode(b);
|
|
26
|
+
if (ab.length !== bb.length) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
return timingSafeEqual(ab, bb);
|
|
30
|
+
}
|
|
31
|
+
/** Read the identity token from the handshake (explicit field or `token` query). */
|
|
32
|
+
function tokenOf(req) {
|
|
33
|
+
return req.token ?? req.query?.token;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Read the capability credential from the handshake: explicit field, the
|
|
37
|
+
* `capability` query param, or the `x-capability-credential` header.
|
|
38
|
+
*/
|
|
39
|
+
function credentialOf(req) {
|
|
40
|
+
return req.credential ?? req.query?.capability ?? req.headers?.["x-capability-credential"];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The default authenticator: a shared-secret identity token gate plus a required
|
|
44
|
+
* capability credential. Mirrors nano-workforce's blackboard-hook `?token=…`
|
|
45
|
+
* pattern; swap in a real ADR 0028 verifier by passing your own
|
|
46
|
+
* {@link Authenticator} to the hub.
|
|
47
|
+
*/
|
|
48
|
+
export function sharedSecretAuthenticator(options) {
|
|
49
|
+
const requireCredential = options.requireCredential ?? true;
|
|
50
|
+
return (req) => {
|
|
51
|
+
const token = tokenOf(req);
|
|
52
|
+
if (token === undefined || !safeEqual(token, options.secret)) {
|
|
53
|
+
return { ok: false, code: AUTH_UNAUTHORIZED, reason: "invalid identity token" };
|
|
54
|
+
}
|
|
55
|
+
const identity = options.identityFor ? options.identityFor(req) : (req.remote ?? "anonymous");
|
|
56
|
+
const credential = credentialOf(req);
|
|
57
|
+
if (requireCredential) {
|
|
58
|
+
if (credential === undefined || credential.length === 0) {
|
|
59
|
+
return { ok: false, code: AUTH_FORBIDDEN, reason: "missing capability credential" };
|
|
60
|
+
}
|
|
61
|
+
if (options.verifyCredential && !options.verifyCredential(credential, identity)) {
|
|
62
|
+
return { ok: false, code: AUTH_FORBIDDEN, reason: "capability credential rejected" };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { ok: true, grant: { identity, capability: credential } };
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An injectable monotonic-ish wall clock. The hub and connection registry read
|
|
3
|
+
* time only through a {@link Clock} so liveness/TTL behaviour is deterministic
|
|
4
|
+
* under test (pass a fake clock) without sleeping on the real timer.
|
|
5
|
+
*/
|
|
6
|
+
export interface Clock {
|
|
7
|
+
/** Current time in epoch milliseconds. */
|
|
8
|
+
now(): number;
|
|
9
|
+
}
|
|
10
|
+
/** The production clock, backed by {@link Date.now}. */
|
|
11
|
+
export declare const systemClock: Clock;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport-agnostic connection contracts.
|
|
3
|
+
*
|
|
4
|
+
* The hub owns connection lifecycle and framing; it speaks to the network only
|
|
5
|
+
* through {@link ChannelTransport} / {@link ChannelConnection}. That keeps the
|
|
6
|
+
* hub deterministically testable over an in-memory transport and lets the real
|
|
7
|
+
* {@link WebSocketChannelTransport} (or any future transport) be a thin adapter.
|
|
8
|
+
*
|
|
9
|
+
* A connection carries opaque, already-encoded protocol frames as bytes — the
|
|
10
|
+
* hub decodes them with the S0 codec (`@nanobpm/agentic-protocol`). This channel
|
|
11
|
+
* is served by the app on its OWN bound port; the Camunda-8 engine transport is
|
|
12
|
+
* a separate connection and is never touched here.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Everything the transport learns about a peer at connect time, BEFORE any
|
|
16
|
+
* protocol frame is exchanged. The default authenticator reads the ADR 0028
|
|
17
|
+
* identity token and the capability credential from here (mirroring the
|
|
18
|
+
* `?token=…` pattern nano-workforce's blackboard hook already uses).
|
|
19
|
+
*/
|
|
20
|
+
export interface HandshakeRequest {
|
|
21
|
+
/** ADR 0028 identity token (typically the `token` query param or a header). */
|
|
22
|
+
readonly token?: string;
|
|
23
|
+
/** Capability credential authorising the peer to enrol. */
|
|
24
|
+
readonly credential?: string;
|
|
25
|
+
/** Remote address, for logging/diagnostics only. */
|
|
26
|
+
readonly remote?: string;
|
|
27
|
+
/** Lower-cased request headers. */
|
|
28
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
29
|
+
/** Parsed query-string parameters. */
|
|
30
|
+
readonly query?: Readonly<Record<string, string>>;
|
|
31
|
+
}
|
|
32
|
+
/** Close-status code (WebSocket application range 3000–4999 for our own codes). */
|
|
33
|
+
export type CloseCode = number;
|
|
34
|
+
/**
|
|
35
|
+
* A single duplex connection to a peer. The hub registers exactly one message
|
|
36
|
+
* and one close listener; a transport may deliver `pong` for keepalive.
|
|
37
|
+
*/
|
|
38
|
+
export interface ChannelConnection {
|
|
39
|
+
/** Stable per-connection id (unique for the life of the process). */
|
|
40
|
+
readonly id: string;
|
|
41
|
+
/** What the transport captured about the peer at connect time. */
|
|
42
|
+
readonly handshake: HandshakeRequest;
|
|
43
|
+
/** Send one already-encoded frame as binary bytes. */
|
|
44
|
+
send(bytes: Uint8Array): void;
|
|
45
|
+
/** Close the connection with an optional application close code + reason. */
|
|
46
|
+
close(code?: CloseCode, reason?: string): void;
|
|
47
|
+
/** Register the (single) inbound-bytes listener. */
|
|
48
|
+
onMessage(listener: (bytes: Uint8Array) => void): void;
|
|
49
|
+
/** Register the (single) close listener. */
|
|
50
|
+
onClose(listener: (code?: CloseCode, reason?: string) => void): void;
|
|
51
|
+
/** Register a keepalive pong listener, if the transport supports ping/pong. */
|
|
52
|
+
onPong?(listener: () => void): void;
|
|
53
|
+
/**
|
|
54
|
+
* Register an inbound-ping listener, if the transport supports ping/pong. A
|
|
55
|
+
* peer-driven keepalive typically arrives as an inbound ping (the transport
|
|
56
|
+
* auto-replies with a pong), so an inbound ping is also proof of life.
|
|
57
|
+
*/
|
|
58
|
+
onPing?(listener: () => void): void;
|
|
59
|
+
/** Send a keepalive ping, if the transport supports it. */
|
|
60
|
+
ping?(): void;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A listening transport, bound to the app's own port. It emits a
|
|
64
|
+
* {@link ChannelConnection} for every accepted, upgraded peer.
|
|
65
|
+
*/
|
|
66
|
+
export interface ChannelTransport {
|
|
67
|
+
/** Register the handler invoked for each newly accepted connection. */
|
|
68
|
+
onConnection(listener: (conn: ChannelConnection) => void): void;
|
|
69
|
+
/** The bound address once listening, or `null` before/after. */
|
|
70
|
+
readonly address: {
|
|
71
|
+
readonly port: number;
|
|
72
|
+
} | null;
|
|
73
|
+
/** Stop accepting and release the port. */
|
|
74
|
+
close(): Promise<void>;
|
|
75
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport-agnostic connection contracts.
|
|
3
|
+
*
|
|
4
|
+
* The hub owns connection lifecycle and framing; it speaks to the network only
|
|
5
|
+
* through {@link ChannelTransport} / {@link ChannelConnection}. That keeps the
|
|
6
|
+
* hub deterministically testable over an in-memory transport and lets the real
|
|
7
|
+
* {@link WebSocketChannelTransport} (or any future transport) be a thin adapter.
|
|
8
|
+
*
|
|
9
|
+
* A connection carries opaque, already-encoded protocol frames as bytes — the
|
|
10
|
+
* hub decodes them with the S0 codec (`@nanobpm/agentic-protocol`). This channel
|
|
11
|
+
* is served by the app on its OWN bound port; the Camunda-8 engine transport is
|
|
12
|
+
* a separate connection and is never touched here.
|
|
13
|
+
*/
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Frame, MessageFamily } from "../protocol/index.ts";
|
|
2
|
+
/**
|
|
3
|
+
* A handler for one message family. `ctx` is whatever per-connection context
|
|
4
|
+
* the hub threads through (see {@link HubConnection}); the router itself is
|
|
5
|
+
* generic so it can be unit-tested without the hub.
|
|
6
|
+
*/
|
|
7
|
+
export type FamilyHandler<Ctx> = (frame: Frame, ctx: Ctx) => void | Promise<void>;
|
|
8
|
+
/** Raised when a family key outside the S0 {@link MessageFamily} set is used. */
|
|
9
|
+
export declare class UnknownFamilyError extends Error {
|
|
10
|
+
readonly family: string;
|
|
11
|
+
constructor(family: string);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Raised when a second handler is registered for a family that already has one.
|
|
15
|
+
* One family, one owning module — this guard is what stops two sibling slices
|
|
16
|
+
* silently clobbering each other's handler.
|
|
17
|
+
*/
|
|
18
|
+
export declare class DuplicateFamilyHandlerError extends Error {
|
|
19
|
+
readonly family: MessageFamily;
|
|
20
|
+
constructor(family: MessageFamily);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The derived frame→family routing table. The hub holds one of these; every
|
|
24
|
+
* family module attaches through {@link registerFamilyHandler}.
|
|
25
|
+
*/
|
|
26
|
+
export declare class FamilyRouter<Ctx> {
|
|
27
|
+
#private;
|
|
28
|
+
/**
|
|
29
|
+
* Attach the handler that owns `family`. This is the seam every family module
|
|
30
|
+
* calls; it keys off the S0 family set and refuses a duplicate so two slices
|
|
31
|
+
* cannot both claim one family.
|
|
32
|
+
*/
|
|
33
|
+
registerFamilyHandler(family: MessageFamily, handler: FamilyHandler<Ctx>): void;
|
|
34
|
+
/** Set the fallback invoked for a frame whose family has no handler. */
|
|
35
|
+
onUnhandled(handler: FamilyHandler<Ctx>): void;
|
|
36
|
+
/** Whether a handler is registered for `family`. */
|
|
37
|
+
has(family: MessageFamily): boolean;
|
|
38
|
+
/** The families that currently have a handler (the derived table's keys). */
|
|
39
|
+
families(): MessageFamily[];
|
|
40
|
+
/**
|
|
41
|
+
* Route one decoded frame to its family handler by table lookup. Returns
|
|
42
|
+
* `true` if a family handler ran, `false` if it fell through to the unhandled
|
|
43
|
+
* fallback (or nowhere). Any handler rejection propagates to the caller.
|
|
44
|
+
*/
|
|
45
|
+
route(frame: Frame, ctx: Ctx): Promise<boolean>;
|
|
46
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The family-handler registration seam — S1's canonical extension point.
|
|
3
|
+
*
|
|
4
|
+
* Multiple wave-2 slices (S2 presence/registry, S5 relay, S7 blackboard) each
|
|
5
|
+
* attach a NEW inbound message-family handler to the single hub. To stop them
|
|
6
|
+
* colliding on a central `switch (frame.family)`, the hub's frame→family routing
|
|
7
|
+
* is DERIVED from this registration table: each family is a self-contained
|
|
8
|
+
* module that attaches itself with {@link FamilyRouter.registerFamilyHandler}
|
|
9
|
+
* and the router dispatches by table lookup — never a hand-edited switch.
|
|
10
|
+
*
|
|
11
|
+
* Family keys are the S0 {@link MessageFamily} set (`@nanobpm/agentic-protocol`),
|
|
12
|
+
* the one source of truth; registering a key outside that set is rejected.
|
|
13
|
+
*/
|
|
14
|
+
import { isMessageFamily } from "../protocol/index.js";
|
|
15
|
+
/** Raised when a family key outside the S0 {@link MessageFamily} set is used. */
|
|
16
|
+
export class UnknownFamilyError extends Error {
|
|
17
|
+
family;
|
|
18
|
+
constructor(family) {
|
|
19
|
+
super(`unknown message family: ${family}`);
|
|
20
|
+
this.name = "UnknownFamilyError";
|
|
21
|
+
this.family = family;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Raised when a second handler is registered for a family that already has one.
|
|
26
|
+
* One family, one owning module — this guard is what stops two sibling slices
|
|
27
|
+
* silently clobbering each other's handler.
|
|
28
|
+
*/
|
|
29
|
+
export class DuplicateFamilyHandlerError extends Error {
|
|
30
|
+
family;
|
|
31
|
+
constructor(family) {
|
|
32
|
+
super(`a handler is already registered for family: ${family}`);
|
|
33
|
+
this.name = "DuplicateFamilyHandlerError";
|
|
34
|
+
this.family = family;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The derived frame→family routing table. The hub holds one of these; every
|
|
39
|
+
* family module attaches through {@link registerFamilyHandler}.
|
|
40
|
+
*/
|
|
41
|
+
export class FamilyRouter {
|
|
42
|
+
#handlers = new Map();
|
|
43
|
+
#onUnhandled;
|
|
44
|
+
/**
|
|
45
|
+
* Attach the handler that owns `family`. This is the seam every family module
|
|
46
|
+
* calls; it keys off the S0 family set and refuses a duplicate so two slices
|
|
47
|
+
* cannot both claim one family.
|
|
48
|
+
*/
|
|
49
|
+
registerFamilyHandler(family, handler) {
|
|
50
|
+
if (!isMessageFamily(family)) {
|
|
51
|
+
throw new UnknownFamilyError(family);
|
|
52
|
+
}
|
|
53
|
+
if (this.#handlers.has(family)) {
|
|
54
|
+
throw new DuplicateFamilyHandlerError(family);
|
|
55
|
+
}
|
|
56
|
+
this.#handlers.set(family, handler);
|
|
57
|
+
}
|
|
58
|
+
/** Set the fallback invoked for a frame whose family has no handler. */
|
|
59
|
+
onUnhandled(handler) {
|
|
60
|
+
this.#onUnhandled = handler;
|
|
61
|
+
}
|
|
62
|
+
/** Whether a handler is registered for `family`. */
|
|
63
|
+
has(family) {
|
|
64
|
+
return this.#handlers.has(family);
|
|
65
|
+
}
|
|
66
|
+
/** The families that currently have a handler (the derived table's keys). */
|
|
67
|
+
families() {
|
|
68
|
+
return [...this.#handlers.keys()];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Route one decoded frame to its family handler by table lookup. Returns
|
|
72
|
+
* `true` if a family handler ran, `false` if it fell through to the unhandled
|
|
73
|
+
* fallback (or nowhere). Any handler rejection propagates to the caller.
|
|
74
|
+
*/
|
|
75
|
+
async route(frame, ctx) {
|
|
76
|
+
const handler = this.#handlers.get(frame.family);
|
|
77
|
+
if (handler === undefined) {
|
|
78
|
+
if (this.#onUnhandled !== undefined) {
|
|
79
|
+
await this.#onUnhandled(frame, ctx);
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
await handler(frame, ctx);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
}
|