@sigrun/sigrun 0.1.4 → 0.1.6
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/CHANGELOG.md +61 -0
- package/README.md +24 -2
- package/THIRD_PARTY_NOTICES.md +23 -0
- package/dist/blocks/builders.js +125 -0
- package/dist/blocks/images.js +57 -0
- package/dist/blocks/index.js +20 -0
- package/dist/blocks/limits.js +26 -0
- package/dist/blocks/placements.js +99 -0
- package/dist/blocks/schema.js +559 -0
- package/dist/blocks/validate.js +602 -0
- package/dist/config.js +31 -0
- package/dist/extension.d.ts +476 -9
- package/dist/harness/agent-env.js +62 -0
- package/dist/harness/boot.js +8 -1
- package/dist/harness/event-bus.js +33 -0
- package/dist/harness/identity/users.js +3 -2
- package/dist/harness/loader.js +369 -13
- package/dist/harness/packages.js +71 -2
- package/dist/harness/seam-host.js +69 -2
- package/dist/harness/settings.js +5 -0
- package/dist/harness/workspace.js +6 -4
- package/dist/jobs/fire.js +11 -0
- package/dist/jobs/spec.js +19 -0
- package/dist/serve.js +29 -39
- package/dist/server/auth/pipeline.js +16 -1
- package/dist/server/conversation-ops.js +482 -10
- package/dist/server/gateway.js +122 -0
- package/dist/server/helpers.js +26 -0
- package/dist/server/host-bridge.js +141 -4
- package/dist/server/index.js +27 -0
- package/dist/server/notifications.js +6 -2
- package/dist/server/routes/conversations.js +37 -93
- package/dist/server/routes/events.js +39 -0
- package/dist/server/routes/ext-assets.js +84 -0
- package/dist/server/routes/extensions.js +31 -1
- package/dist/server/routes/files.js +38 -83
- package/dist/server/routes/meta.js +35 -1
- package/dist/server/routes/providers.js +5 -21
- package/dist/server/routes/submissions.js +36 -111
- package/dist/server/routes/surfaces.js +387 -0
- package/dist/server/routes/users.js +6 -1
- package/dist/server/session-meta.js +12 -6
- package/dist/server/shutdown.js +85 -0
- package/dist/server/surfaces/access-cache.js +43 -0
- package/dist/server/surfaces/dedup.js +67 -0
- package/dist/server/surfaces/instance.js +42 -0
- package/dist/server/surfaces/mime.js +73 -0
- package/dist/server/surfaces/rate-limit.js +63 -0
- package/dist/server/surfaces/service.js +493 -0
- package/dist/server/surfaces/watchers.js +94 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +9 -0
- package/node_modules/@sigrun/shared/dist/blocks/format.d.ts +28 -0
- package/node_modules/@sigrun/shared/dist/blocks/format.js +199 -0
- package/node_modules/@sigrun/shared/dist/blocks/icons.d.ts +12 -0
- package/node_modules/@sigrun/shared/dist/blocks/icons.js +64 -0
- package/node_modules/@sigrun/shared/dist/blocks/index.d.ts +25 -0
- package/node_modules/@sigrun/shared/dist/blocks/index.js +21 -0
- package/node_modules/@sigrun/shared/dist/blocks/instance.d.ts +29 -0
- package/node_modules/@sigrun/shared/dist/blocks/instance.js +51 -0
- package/node_modules/@sigrun/shared/dist/blocks/lttb.d.ts +16 -0
- package/node_modules/@sigrun/shared/dist/blocks/lttb.js +67 -0
- package/node_modules/@sigrun/shared/dist/blocks/ops.d.ts +44 -0
- package/node_modules/@sigrun/shared/dist/blocks/ops.js +294 -0
- package/node_modules/@sigrun/shared/dist/blocks/text.d.ts +43 -0
- package/node_modules/@sigrun/shared/dist/blocks/text.js +790 -0
- package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +77 -0
- package/node_modules/@sigrun/shared/dist/blocks/transport.js +11 -0
- package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +541 -0
- package/node_modules/@sigrun/shared/dist/blocks/types.js +17 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +9 -0
- package/node_modules/@sigrun/shared/dist/live.d.ts +71 -0
- package/node_modules/@sigrun/shared/dist/live.js +82 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +19 -3
- package/node_modules/@sigrun/shared/dist/settings.d.ts +7 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +81 -3
- package/node_modules/@sigrun/shared/package.json +10 -2
- package/package.json +11 -5
- package/webui/dist/assets/index-CiYInlLx.css +1 -0
- package/webui/dist/assets/index-JDfOQV9z.js +52 -0
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-BiSMljer.css +0 -1
- package/webui/dist/assets/index-hVLvsizV.js +0 -50
|
@@ -648,6 +648,32 @@ export class EventBus {
|
|
|
648
648
|
callId: record.callId,
|
|
649
649
|
kind: record.kind,
|
|
650
650
|
});
|
|
651
|
+
// Gateway C6: a TERMINAL status transition additionally fans
|
|
652
|
+
// out to the extension lifecycle handlers
|
|
653
|
+
// (`interactionSettled`) from this same commit-observation
|
|
654
|
+
// site. Exactly one document change lands per transition —
|
|
655
|
+
// whatever wrote it (HTTP-route answer, host answer, host
|
|
656
|
+
// settleInteraction) — so exactly one event fires per
|
|
657
|
+
// transition; seeding (pending) fires nothing. It rides the
|
|
658
|
+
// same lifecycle queue + deadline-bounded dispatch as
|
|
659
|
+
// conversationCreated etc., NOT the host event stream. The
|
|
660
|
+
// host-event split above and this gate read the SAME
|
|
661
|
+
// imported set, so a future terminal status added to
|
|
662
|
+
// TERMINAL fires both events together by construction.
|
|
663
|
+
if (INTERACTION_TERMINAL.has(record.status)) {
|
|
664
|
+
lifecycle.push({
|
|
665
|
+
type: "interactionSettled",
|
|
666
|
+
conversationId: change.conversationId,
|
|
667
|
+
callId: record.callId,
|
|
668
|
+
kind: record.kind,
|
|
669
|
+
// SAFETY: `record.status` is a narrowed `string`, but
|
|
670
|
+
// the payload wants the terminal union — sound because
|
|
671
|
+
// the union and the TERMINAL set share one definition
|
|
672
|
+
// (src/harness/interactions.ts): membership in the set
|
|
673
|
+
// IS membership in the union.
|
|
674
|
+
status: record.status,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
651
677
|
}
|
|
652
678
|
}
|
|
653
679
|
else if (change.type === "document" &&
|
|
@@ -1010,6 +1036,13 @@ export class EventBus {
|
|
|
1010
1036
|
return { conversationId: event.conversationId, archived: event.archived };
|
|
1011
1037
|
case "conversationIdle":
|
|
1012
1038
|
return { conversationId: event.conversationId, idleMs: event.idleMs };
|
|
1039
|
+
case "interactionSettled":
|
|
1040
|
+
return {
|
|
1041
|
+
conversationId: event.conversationId,
|
|
1042
|
+
callId: event.callId,
|
|
1043
|
+
kind: event.kind,
|
|
1044
|
+
status: event.status,
|
|
1045
|
+
};
|
|
1013
1046
|
}
|
|
1014
1047
|
}
|
|
1015
1048
|
/** The conversation a lifecycle event's api scopes to (the fork child on forks). */
|
|
@@ -62,8 +62,9 @@ export class UserDirectory {
|
|
|
62
62
|
}
|
|
63
63
|
/**
|
|
64
64
|
* Patch one user's mutable fields (`name`/`email`/`admin` — the
|
|
65
|
-
* username itself is immutable
|
|
66
|
-
*
|
|
65
|
+
* username itself is immutable; `groupAdmin` is the pipeline's own
|
|
66
|
+
* write-through field, never the PATCH route's surface). Absent user
|
|
67
|
+
* throws; the file is rewritten atomically.
|
|
67
68
|
*/
|
|
68
69
|
update(user, patch) {
|
|
69
70
|
return this.mutations.run(async () => {
|
package/dist/harness/loader.js
CHANGED
|
@@ -82,12 +82,13 @@
|
|
|
82
82
|
* factory-result key the loader reads is a key of `SigrunExtensionResult`.
|
|
83
83
|
* The implementation and the contract cannot drift silently.
|
|
84
84
|
*/
|
|
85
|
+
import { ICON_NAMES } from "@sigrun/shared/blocks";
|
|
85
86
|
import { validateJobSpec } from "../jobs/spec.js";
|
|
86
87
|
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
87
88
|
import { createRequire } from "node:module";
|
|
88
89
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
89
90
|
import { fileURLToPath } from "node:url";
|
|
90
|
-
import { HOST_RUNTIME_MODULES, hostModuleVersion, readPackageName, readPackageVersion, resolveExtensionEntry, } from "./packages.js";
|
|
91
|
+
import { HOST_RUNTIME_MODULES, hostModuleVersion, readPackageAssetsDir, readPackageName, readPackageVersion, resolveExtensionEntry, } from "./packages.js";
|
|
91
92
|
import { RESERVED_CORE_SECTION_KEYS } from "./reserved-sections.js";
|
|
92
93
|
import { slugifyName } from "./stores.js";
|
|
93
94
|
/** Every lifecycle field name, in validation order (mirrors the contract doc). */
|
|
@@ -96,6 +97,7 @@ const LIFECYCLE_FIELDS = [
|
|
|
96
97
|
"conversationForked",
|
|
97
98
|
"conversationArchived",
|
|
98
99
|
"conversationIdle",
|
|
100
|
+
"interactionSettled",
|
|
99
101
|
"harnessShutdown",
|
|
100
102
|
"userDeleted",
|
|
101
103
|
];
|
|
@@ -145,6 +147,7 @@ const WAKE_EVENTS = new Set([
|
|
|
145
147
|
"conversationForked",
|
|
146
148
|
"conversationArchived",
|
|
147
149
|
"conversationIdle",
|
|
150
|
+
"interactionSettled",
|
|
148
151
|
]);
|
|
149
152
|
/**
|
|
150
153
|
* The `every` retirement message (ruling 12, jobs Task 3): jobs are the
|
|
@@ -232,10 +235,113 @@ function validateJobs(value) {
|
|
|
232
235
|
return specs;
|
|
233
236
|
}
|
|
234
237
|
/**
|
|
235
|
-
* The
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
+
* The icon-name membership probe (spec §10.2): the closed set from
|
|
239
|
+
* `@sigrun/shared/blocks` — a `Set` so `unknown`-typed field values can
|
|
240
|
+
* be tested without a cast.
|
|
238
241
|
*/
|
|
242
|
+
const ICON_NAME_SET = new Set(ICON_NAMES);
|
|
243
|
+
/** The refreshMs floor (spec §7.1): channels refetch no faster than 5 s. */
|
|
244
|
+
const MIN_REFRESH_MS = 5000;
|
|
245
|
+
/**
|
|
246
|
+
* Validate a factory result's `surfaces` declaration (UI-blocks U3
|
|
247
|
+
* Task 1, spec §7.1/§15). Absent → undefined (no surfaces). Present
|
|
248
|
+
* but malformed → throw with ONE message per problem; the loader
|
|
249
|
+
* quarantines the extension with the thrown message (fail-loud: an
|
|
250
|
+
* extension that THINKS it declared a surface must not silently lose
|
|
251
|
+
* it — the providers/health class). Per def: non-empty string `id`
|
|
252
|
+
* (unique across BOTH lists — one namespace), string `title`, `icon`
|
|
253
|
+
* in the closed set, `placement` exactly `view` (in `views`) or
|
|
254
|
+
* `panel` (in `panels`), `sharedData` a boolean, `actions` an object
|
|
255
|
+
* whose every handler is a function (message names the action),
|
|
256
|
+
* `refreshMs ≥ 5000` when present, `data()` a function when
|
|
257
|
+
* `refreshMs` is set (a data refetch would otherwise re-run `render`,
|
|
258
|
+
* §7.1), `render` a function, and NO `settings` key (reserved — the
|
|
259
|
+
* §13.3 sections are a separate workstream). Returns the
|
|
260
|
+
* FLATTENED defs (views then panels, declaration order) attributed to
|
|
261
|
+
* the slug at collection.
|
|
262
|
+
*/
|
|
263
|
+
function validateSurfaces(value) {
|
|
264
|
+
if (value === undefined)
|
|
265
|
+
return undefined;
|
|
266
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
267
|
+
throw new Error("extension factory returned a surfaces field that is not an object");
|
|
268
|
+
}
|
|
269
|
+
// The settings sections key is RESERVED (spec §7.1 names it; §13.3
|
|
270
|
+
// owns it): this workstream cannot serve it, and a JS package
|
|
271
|
+
// declaring one must not lose it silently — quarantine loud, naming
|
|
272
|
+
// the workstream (TS consumers are already stopped by the contract's
|
|
273
|
+
// `readonly never[]`).
|
|
274
|
+
if (value.settings !== undefined) {
|
|
275
|
+
throw new Error("extension factory returned a surfaces.settings declaration — settings sections are a separate workstream (they land in U4)");
|
|
276
|
+
}
|
|
277
|
+
const defs = [];
|
|
278
|
+
const seen = new Set();
|
|
279
|
+
for (const list of ["views", "panels"]) {
|
|
280
|
+
const entries = value[list];
|
|
281
|
+
if (entries === undefined)
|
|
282
|
+
continue;
|
|
283
|
+
if (!Array.isArray(entries)) {
|
|
284
|
+
throw new Error(`extension factory returned a surfaces.${list} field that is not an array`);
|
|
285
|
+
}
|
|
286
|
+
const expected = list === "views" ? "view" : "panel";
|
|
287
|
+
for (const entry of entries) {
|
|
288
|
+
const def = entry;
|
|
289
|
+
if (typeof def?.id !== "string" || def.id.length === 0) {
|
|
290
|
+
throw new Error("extension factory returned a surface whose id is missing or not a non-empty string");
|
|
291
|
+
}
|
|
292
|
+
if (seen.has(def.id)) {
|
|
293
|
+
throw new Error(`extension factory returned a duplicate surface id "${def.id}"`);
|
|
294
|
+
}
|
|
295
|
+
seen.add(def.id);
|
|
296
|
+
if (typeof def?.title !== "string" || def.title.length === 0) {
|
|
297
|
+
throw new Error(`extension factory returned a surface "${def.id}" whose title is missing or not a non-empty string`);
|
|
298
|
+
}
|
|
299
|
+
if (typeof def?.icon !== "string" || !ICON_NAME_SET.has(def.icon)) {
|
|
300
|
+
throw new Error(`extension factory returned a surface "${def.id}" with unknown icon "${String(def?.icon)}"`);
|
|
301
|
+
}
|
|
302
|
+
if (def?.placement !== expected) {
|
|
303
|
+
throw new Error(`extension factory returned a surface "${def.id}" whose placement must be "${expected}" in surfaces.${list}`);
|
|
304
|
+
}
|
|
305
|
+
if (def?.instanceKey !== undefined && typeof def.instanceKey !== "function") {
|
|
306
|
+
throw new Error(`extension factory returned a surface "${def.id}" whose instanceKey is not a function`);
|
|
307
|
+
}
|
|
308
|
+
if (def?.sharedData !== undefined &&
|
|
309
|
+
typeof def.sharedData !== "boolean") {
|
|
310
|
+
throw new Error(`extension factory returned a surface "${def.id}" whose sharedData is not a boolean`);
|
|
311
|
+
}
|
|
312
|
+
if (def?.actions !== undefined) {
|
|
313
|
+
if (typeof def.actions !== "object" ||
|
|
314
|
+
def.actions === null ||
|
|
315
|
+
Array.isArray(def.actions)) {
|
|
316
|
+
throw new Error(`extension factory returned a surface "${def.id}" whose actions is not an object`);
|
|
317
|
+
}
|
|
318
|
+
for (const [action, handler] of Object.entries(def.actions)) {
|
|
319
|
+
if (typeof handler !== "function") {
|
|
320
|
+
throw new Error(`extension factory returned a surface "${def.id}" whose action "${action}" handler is not a function`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (typeof def?.render !== "function") {
|
|
325
|
+
throw new Error(`extension factory returned a surface "${def.id}" whose render is not a function`);
|
|
326
|
+
}
|
|
327
|
+
if (def?.data !== undefined && typeof def.data !== "function") {
|
|
328
|
+
throw new Error(`extension factory returned a surface "${def.id}" whose data is not a function`);
|
|
329
|
+
}
|
|
330
|
+
if (def?.refreshMs !== undefined) {
|
|
331
|
+
if (typeof def.refreshMs !== "number" ||
|
|
332
|
+
!Number.isInteger(def.refreshMs) ||
|
|
333
|
+
def.refreshMs < MIN_REFRESH_MS) {
|
|
334
|
+
throw new Error(`extension factory returned a surface "${def.id}" declaring refreshMs ${String(def.refreshMs)} (minimum ${MIN_REFRESH_MS})`);
|
|
335
|
+
}
|
|
336
|
+
if (def?.data === undefined) {
|
|
337
|
+
throw new Error(`extension factory returned a surface "${def.id}" declaring refreshMs without data()`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
defs.push(entry);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return defs;
|
|
344
|
+
}
|
|
239
345
|
const ROUTE_METHODS = new Set([
|
|
240
346
|
"GET",
|
|
241
347
|
"POST",
|
|
@@ -244,6 +350,13 @@ const ROUTE_METHODS = new Set([
|
|
|
244
350
|
"DELETE",
|
|
245
351
|
]);
|
|
246
352
|
const ROUTE_AUTHS = new Set(["user", "admin", "public"]);
|
|
353
|
+
/**
|
|
354
|
+
* The route-path prefixes reserved for core under `/v1/ext/<slug>/`
|
|
355
|
+
* (spec §7.2): `surfaces/` and `assets/` are the extension-surface
|
|
356
|
+
* and asset mounts — an extension route there would shadow them, so
|
|
357
|
+
* declaring one quarantines at load, naming the route.
|
|
358
|
+
*/
|
|
359
|
+
const RESERVED_ROUTE_PREFIXES = ["surfaces/", "assets/"];
|
|
247
360
|
/**
|
|
248
361
|
* Validate a factory result's `routes` declaration (core hardening W5
|
|
249
362
|
* Task 2, spec §9.2). Absent → undefined (no routes). Present but
|
|
@@ -251,9 +364,10 @@ const ROUTE_AUTHS = new Set(["user", "admin", "public"]);
|
|
|
251
364
|
* thrown message (fail-loud: an extension that THINKS it mounted a
|
|
252
365
|
* webhook must not silently lose it). Per entry: `method` in the five
|
|
253
366
|
* verbs, `path` a non-empty RELATIVE string (no leading `/`, no `..` —
|
|
254
|
-
* nothing may escape the `/v1/ext/<slug>/` mount
|
|
255
|
-
*
|
|
256
|
-
* `handler` a
|
|
367
|
+
* nothing may escape the `/v1/ext/<slug>/` mount, and never under a
|
|
368
|
+
* RESERVED core prefix — §7.2), `auth` one of the three values,
|
|
369
|
+
* `bodyLimit` a positive integer when present, and `handler` a
|
|
370
|
+
* function.
|
|
257
371
|
*/
|
|
258
372
|
function validateRoutes(value) {
|
|
259
373
|
if (value === undefined)
|
|
@@ -273,6 +387,13 @@ function validateRoutes(value) {
|
|
|
273
387
|
route.path.includes("..")) {
|
|
274
388
|
throw new Error(`extension factory returned a route whose path must be a non-empty relative string (no leading "/", no "..")`);
|
|
275
389
|
}
|
|
390
|
+
// Reserved core prefixes (spec §7.2): surfaces/ and assets/ belong
|
|
391
|
+
// to core under this slug's mount — quarantine naming the route.
|
|
392
|
+
const path = route?.path;
|
|
393
|
+
if (typeof path === "string" &&
|
|
394
|
+
RESERVED_ROUTE_PREFIXES.some((prefix) => path.startsWith(prefix))) {
|
|
395
|
+
throw new Error(`extension factory returned a ${describe()} whose path starts with a reserved core prefix (surfaces/ and assets/ belong to core under /v1/ext/<slug>/)`);
|
|
396
|
+
}
|
|
276
397
|
if (typeof route?.auth !== "string" || !ROUTE_AUTHS.has(route.auth)) {
|
|
277
398
|
throw new Error(`extension factory returned a ${describe()} whose auth is not one of user, admin, public`);
|
|
278
399
|
}
|
|
@@ -355,6 +476,156 @@ function validateNotifiers(value) {
|
|
|
355
476
|
}
|
|
356
477
|
return value;
|
|
357
478
|
}
|
|
479
|
+
/**
|
|
480
|
+
* The channel-id grammar (gateway C1, spec §7): `^[a-z][a-z0-9-]*$` —
|
|
481
|
+
* the same shape as every other extension-declared name (commands,
|
|
482
|
+
* workers, bundles), and the key the gateway engine's chat bindings are
|
|
483
|
+
* addressed by (why a collision quarantines instead of last-wins).
|
|
484
|
+
*/
|
|
485
|
+
const CHANNEL_ID = /^[a-z][a-z0-9-]*$/;
|
|
486
|
+
/** The `chatTypes` vocabulary of a `capabilities` declaration (spec §7). */
|
|
487
|
+
const CHANNEL_CHAT_TYPES = new Set([
|
|
488
|
+
"dm",
|
|
489
|
+
"group",
|
|
490
|
+
"thread",
|
|
491
|
+
]);
|
|
492
|
+
/** The `capabilities.format` vocabulary (spec §7). */
|
|
493
|
+
const CHANNEL_FORMATS = new Set([
|
|
494
|
+
"plain",
|
|
495
|
+
"markdown",
|
|
496
|
+
"native",
|
|
497
|
+
]);
|
|
498
|
+
/**
|
|
499
|
+
* Validate a factory result's `channels` declaration (gateway C1, spec
|
|
500
|
+
* §7). Absent → undefined (no contributions). Present but malformed →
|
|
501
|
+
* throw; the loader quarantines the extension with the thrown message
|
|
502
|
+
* (fail-loud: an adapter that THINKS it declared a channel must not
|
|
503
|
+
* silently lose it — the providers/health class). Per entry: `id` matching
|
|
504
|
+
* the grammar, `label` a non-empty string, `capabilities` an object with
|
|
505
|
+
* `chatTypes` a non-empty array over the vocabulary, `maxMessageLength` a
|
|
506
|
+
* positive integer, `format` one of the three values, the optional
|
|
507
|
+
* `edit.minIntervalMs`/`typing.intervalMs`/`buttons.max` positive
|
|
508
|
+
* integers, `configured`/`start`/`send` functions, the optional members
|
|
509
|
+
* functions when present, and `format` REQUIRED when
|
|
510
|
+
* `capabilities.format` is `"native"` (native markup has no default
|
|
511
|
+
* renderer). Duplicate ids within one result reject — they would collide
|
|
512
|
+
* on the engine's channel-id key exactly like a cross-extension pair.
|
|
513
|
+
*/
|
|
514
|
+
function validateChannels(value) {
|
|
515
|
+
if (value === undefined)
|
|
516
|
+
return undefined;
|
|
517
|
+
if (!Array.isArray(value)) {
|
|
518
|
+
throw new Error("extension factory returned a channels field that is not an array");
|
|
519
|
+
}
|
|
520
|
+
const seen = new Set();
|
|
521
|
+
for (const entry of value) {
|
|
522
|
+
const channel = entry;
|
|
523
|
+
const describe = () => `channel "${typeof channel?.id === "string" ? channel.id : String(channel?.id)}"`;
|
|
524
|
+
if (typeof channel?.id !== "string" ||
|
|
525
|
+
!CHANNEL_ID.test(channel.id)) {
|
|
526
|
+
throw new Error(`extension factory returned a ${describe()} whose id does not match ^[a-z][a-z0-9-]*$`);
|
|
527
|
+
}
|
|
528
|
+
if (seen.has(channel.id)) {
|
|
529
|
+
throw new Error(`extension factory returned a duplicate channel id "${channel.id}" within one result`);
|
|
530
|
+
}
|
|
531
|
+
seen.add(channel.id);
|
|
532
|
+
if (typeof channel?.label !== "string" || channel.label.length === 0) {
|
|
533
|
+
throw new Error(`extension factory returned a ${describe()} whose label is missing or empty`);
|
|
534
|
+
}
|
|
535
|
+
const capabilities = channel?.capabilities;
|
|
536
|
+
if (typeof capabilities !== "object" || capabilities === null) {
|
|
537
|
+
throw new Error(`extension factory returned a ${describe()} whose capabilities is missing or not an object`);
|
|
538
|
+
}
|
|
539
|
+
if (!Array.isArray(capabilities.chatTypes) ||
|
|
540
|
+
capabilities.chatTypes.length === 0 ||
|
|
541
|
+
capabilities.chatTypes.some((type) => typeof type !== "string" || !CHANNEL_CHAT_TYPES.has(type))) {
|
|
542
|
+
throw new Error(`extension factory returned a ${describe()} whose capabilities.chatTypes is not a non-empty array over dm, group, thread`);
|
|
543
|
+
}
|
|
544
|
+
if (typeof capabilities.maxMessageLength !== "number" ||
|
|
545
|
+
!Number.isInteger(capabilities.maxMessageLength) ||
|
|
546
|
+
capabilities.maxMessageLength <= 0) {
|
|
547
|
+
throw new Error(`extension factory returned a ${describe()} whose capabilities.maxMessageLength is not a positive integer`);
|
|
548
|
+
}
|
|
549
|
+
if (typeof capabilities.format !== "string" ||
|
|
550
|
+
!CHANNEL_FORMATS.has(capabilities.format)) {
|
|
551
|
+
throw new Error(`extension factory returned a ${describe()} whose capabilities.format is not one of plain, markdown, native`);
|
|
552
|
+
}
|
|
553
|
+
// The optional numeric members: positive integers when present.
|
|
554
|
+
if (capabilities.edit !== undefined &&
|
|
555
|
+
(typeof capabilities.edit?.minIntervalMs !== "number" ||
|
|
556
|
+
!Number.isInteger(capabilities.edit.minIntervalMs) ||
|
|
557
|
+
capabilities.edit.minIntervalMs <= 0)) {
|
|
558
|
+
throw new Error(`extension factory returned a ${describe()} whose capabilities.edit.minIntervalMs is not a positive integer`);
|
|
559
|
+
}
|
|
560
|
+
if (capabilities.buttons !== undefined &&
|
|
561
|
+
(typeof capabilities.buttons?.max !== "number" ||
|
|
562
|
+
!Number.isInteger(capabilities.buttons.max) ||
|
|
563
|
+
capabilities.buttons.max <= 0)) {
|
|
564
|
+
throw new Error(`extension factory returned a ${describe()} whose capabilities.buttons.max is not a positive integer`);
|
|
565
|
+
}
|
|
566
|
+
if (capabilities.typing !== undefined &&
|
|
567
|
+
(typeof capabilities.typing?.intervalMs !== "number" ||
|
|
568
|
+
!Number.isInteger(capabilities.typing.intervalMs) ||
|
|
569
|
+
capabilities.typing.intervalMs <= 0)) {
|
|
570
|
+
throw new Error(`extension factory returned a ${describe()} whose capabilities.typing.intervalMs is not a positive integer`);
|
|
571
|
+
}
|
|
572
|
+
if (typeof channel?.configured !== "function") {
|
|
573
|
+
throw new Error(`extension factory returned a ${describe()} whose configured is not a function`);
|
|
574
|
+
}
|
|
575
|
+
if (typeof channel?.start !== "function") {
|
|
576
|
+
throw new Error(`extension factory returned a ${describe()} whose start is not a function`);
|
|
577
|
+
}
|
|
578
|
+
if (typeof channel?.send !== "function") {
|
|
579
|
+
throw new Error(`extension factory returned a ${describe()} whose send is not a function`);
|
|
580
|
+
}
|
|
581
|
+
for (const field of [
|
|
582
|
+
"stop",
|
|
583
|
+
"edit",
|
|
584
|
+
"delete",
|
|
585
|
+
"typing",
|
|
586
|
+
"setCommands",
|
|
587
|
+
"format",
|
|
588
|
+
"classifyError",
|
|
589
|
+
]) {
|
|
590
|
+
if (channel[field] !== undefined && typeof channel[field] !== "function") {
|
|
591
|
+
throw new Error(`extension factory returned a ${describe()} whose ${field} is not a function`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (capabilities.format === "native" && channel?.format === undefined) {
|
|
595
|
+
throw new Error(`extension factory returned a ${describe()} without a format function while its capabilities.format is "native"`);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return value;
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Validate a factory result's `gateway` declaration (gateway C2, spec
|
|
602
|
+
* §8.0). Absent → undefined (no engine). Present but malformed → throw;
|
|
603
|
+
* the loader quarantines the extension with the thrown message
|
|
604
|
+
* (fail-loud: an extension that THINKS it declared the engine must not
|
|
605
|
+
* silently lose it — the providers/health class). An object whose
|
|
606
|
+
* `start`/`stopIntake`/`stop`/`health` are all functions is the shape; a
|
|
607
|
+
* result carrying BOTH `gateway` and the `health` field is a contract
|
|
608
|
+
* violation (an engine reports health through `gateway.health()` — the
|
|
609
|
+
* `health` field is the provider-contributor surface and would
|
|
610
|
+
* double-report), thrown with the documented message.
|
|
611
|
+
*/
|
|
612
|
+
function validateGateway(value, healthPresent) {
|
|
613
|
+
if (value === undefined)
|
|
614
|
+
return undefined;
|
|
615
|
+
if (typeof value !== "object" || value === null) {
|
|
616
|
+
throw new Error("extension factory returned a gateway field that is not an object");
|
|
617
|
+
}
|
|
618
|
+
const engine = value;
|
|
619
|
+
for (const field of ["start", "stopIntake", "stop", "health"]) {
|
|
620
|
+
if (typeof engine[field] !== "function") {
|
|
621
|
+
throw new Error(`extension factory returned a gateway whose ${field} is not a function`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
if (healthPresent) {
|
|
625
|
+
throw new Error("a gateway engine reports health through gateway.health(), not the health field");
|
|
626
|
+
}
|
|
627
|
+
return value;
|
|
628
|
+
}
|
|
358
629
|
/**
|
|
359
630
|
* Validate a factory result's `skills` declaration (core hardening W5
|
|
360
631
|
* Task 4, spec §9.4). Absent → undefined (no contributions). Present but
|
|
@@ -443,15 +714,20 @@ function validateWebuiBundles(value) {
|
|
|
443
714
|
return value;
|
|
444
715
|
}
|
|
445
716
|
/**
|
|
446
|
-
* The
|
|
717
|
+
* The host modules whose version must match the host EXACTLY (spec
|
|
447
718
|
* §6.4 R6): an extension's `Extension`/tool objects flow into the HOST's
|
|
448
719
|
* pi-durable registry under structural typing — a different pi-durable
|
|
449
720
|
* is the classic silent-breakage trap (identity never matches, hooks
|
|
450
|
-
* never fire), so any difference at all quarantines.
|
|
451
|
-
*
|
|
721
|
+
* never fire), so any difference at all quarantines. `@sigrun/sigrun`
|
|
722
|
+
* joins that tier for the same reason the spec makes it a peer dependency
|
|
723
|
+
* (§14.1): `@sigrun/sigrun/blocks` must emit the SERVER's block-set
|
|
724
|
+
* version, and a different sigrun can only ever emit a different one —
|
|
725
|
+
* any difference at all quarantines. Every other host module only shares
|
|
726
|
+
* the major (a minor/patch difference warns).
|
|
452
727
|
*/
|
|
453
728
|
const EXACT_HOST_MODULES = new Set([
|
|
454
729
|
"@earendil-works/pi-durable",
|
|
730
|
+
"@sigrun/sigrun",
|
|
455
731
|
]);
|
|
456
732
|
/** The major segment of a semver-ish version string (`"1.2.0"` → `"1"`). */
|
|
457
733
|
function majorSegment(version) {
|
|
@@ -675,17 +951,25 @@ async function resolvedFromFactory(factory, hostContext) {
|
|
|
675
951
|
const commands = validateCommands(candidate.commands);
|
|
676
952
|
const skills = validateSkills(candidate.skills);
|
|
677
953
|
const notifiers = validateNotifiers(candidate.notifiers);
|
|
954
|
+
const channels = validateChannels(candidate.channels);
|
|
955
|
+
// The engine (gateway C2): validated against the health-field conflict
|
|
956
|
+
// rule too — a result carrying both quarantines with the documented
|
|
957
|
+
// message (an engine reports health through gateway.health()).
|
|
958
|
+
const gateway = validateGateway(candidate.gateway, candidate.health !== undefined);
|
|
959
|
+
const surfaces = validateSurfaces(candidate.surfaces);
|
|
678
960
|
const extension = isExtension(candidate.extension)
|
|
679
961
|
? candidate.extension
|
|
680
962
|
: undefined;
|
|
681
963
|
if (extension === undefined) {
|
|
682
964
|
// Contributor-only result: valid when ANY contribution field is
|
|
683
|
-
// present (`providers`, `webuiBundles`); otherwise this
|
|
684
|
-
// result is not the contract form (fall through to the next
|
|
965
|
+
// present (`providers`, `webuiBundles`, `channels`); otherwise this
|
|
966
|
+
// factory result is not the contract form (fall through to the next
|
|
685
967
|
// resolution candidate). The contributor's name is its
|
|
686
968
|
// collision-record identity — without one there is nothing to
|
|
687
969
|
// attribute the contributions to, which is a contract violation.
|
|
688
|
-
if (providers === undefined &&
|
|
970
|
+
if (providers === undefined &&
|
|
971
|
+
webuiBundles === undefined &&
|
|
972
|
+
channels === undefined) {
|
|
689
973
|
return undefined;
|
|
690
974
|
}
|
|
691
975
|
if (typeof candidate.name !== "string" || candidate.name.length === 0) {
|
|
@@ -758,6 +1042,9 @@ async function resolvedFromFactory(factory, hostContext) {
|
|
|
758
1042
|
...(commands === undefined ? {} : { commands }),
|
|
759
1043
|
...(skills === undefined ? {} : { skills }),
|
|
760
1044
|
...(notifiers === undefined ? {} : { notifiers }),
|
|
1045
|
+
...(channels === undefined ? {} : { channels }),
|
|
1046
|
+
...(gateway === undefined ? {} : { gateway }),
|
|
1047
|
+
...(surfaces === undefined ? {} : { surfaces }),
|
|
761
1048
|
};
|
|
762
1049
|
}
|
|
763
1050
|
/**
|
|
@@ -913,6 +1200,19 @@ export async function loadExtensions(specifiers, registry, options = {}) {
|
|
|
913
1200
|
// Notifier transports (W5 Task 6, spec §9.6), attributed the same way —
|
|
914
1201
|
// the server's notifications surface delivers to every collected entry.
|
|
915
1202
|
const notifierBindings = [];
|
|
1203
|
+
// Channel adapters (gateway C1, spec §7), attributed the same way — the
|
|
1204
|
+
// gateway engine's start context and the /v1/extensions inventory read
|
|
1205
|
+
// them. Collected only for LOADED extensions (a colliding adapter never
|
|
1206
|
+
// reaches here — the channelIds check below throws first).
|
|
1207
|
+
const channelBindings = [];
|
|
1208
|
+
// The gateway engine (gateway C2, spec §8.0): the ONE-engine rule — the
|
|
1209
|
+
// first loaded declarer wins; a later result declaring `gateway`
|
|
1210
|
+
// quarantines naming the first slug (an engine owns supervision over the
|
|
1211
|
+
// whole channel set, so a shadowing engine would inherit another
|
|
1212
|
+
// engine's bindings — the same never-last-wins reasoning as channel ids).
|
|
1213
|
+
let gatewayEngine;
|
|
1214
|
+
// Surfaces (U3 Task 1): the flattened validated defs, slug-attributed.
|
|
1215
|
+
const surfaceBindings = [];
|
|
916
1216
|
// Skill roots (W5 Task 4, spec §9.4): every skill dir a loaded package
|
|
917
1217
|
// contributed — skills-only resolutions, entry-bearing packages' manifest
|
|
918
1218
|
// skills, and factory-result `skills` fields — in load order, attributed
|
|
@@ -922,6 +1222,13 @@ export async function loadExtensions(specifiers, registry, options = {}) {
|
|
|
922
1222
|
// Slug → declared name, for collision rejection at load (this boot's
|
|
923
1223
|
// loaded set only — quarantined extensions never claim a slug).
|
|
924
1224
|
const slugs = new Map();
|
|
1225
|
+
// Channel id → slug (gateway C1): the same claimed-only-by-LOADED pattern
|
|
1226
|
+
// as `slugs`. Chat bindings are keyed by channel id, so a shadowing
|
|
1227
|
+
// adapter would inherit another adapter's chats — the collision
|
|
1228
|
+
// quarantines the later loader instead of last-wins (deliberately unlike
|
|
1229
|
+
// commands and bundles). Checked BEFORE `registry.install` so a colliding
|
|
1230
|
+
// extension leaves no registry entry, no slug claim, and no binding.
|
|
1231
|
+
const channelIds = new Map();
|
|
925
1232
|
for (const { name: label, entry, skills, builtin, coreResolved, packageDir } of entries) {
|
|
926
1233
|
const display = label ?? entry ?? "skills-only";
|
|
927
1234
|
try {
|
|
@@ -1101,6 +1408,25 @@ export async function loadExtensions(specifiers, registry, options = {}) {
|
|
|
1101
1408
|
};
|
|
1102
1409
|
options.hostContextFactory(slug, kinds, host);
|
|
1103
1410
|
}
|
|
1411
|
+
if (resolved.channels !== undefined) {
|
|
1412
|
+
for (const channel of resolved.channels) {
|
|
1413
|
+
const owner = channelIds.get(channel.id);
|
|
1414
|
+
if (owner !== undefined) {
|
|
1415
|
+
throw new Error(`channel "${channel.id}" is already contributed by "${owner}" — ` +
|
|
1416
|
+
"channel ids must be unique across extensions (chat bindings are keyed by id)");
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
// The one-engine rule (gateway C2): checked BEFORE the registry
|
|
1421
|
+
// install, exactly like the channel-id collision — a second engine
|
|
1422
|
+
// leaves no registry entry, no slug claim, and no binding. First
|
|
1423
|
+
// loaded wins.
|
|
1424
|
+
if (resolved.gateway !== undefined) {
|
|
1425
|
+
if (gatewayEngine !== undefined) {
|
|
1426
|
+
throw new Error(`gateway engine already declared by "${gatewayEngine.extension}"`);
|
|
1427
|
+
}
|
|
1428
|
+
gatewayEngine = { extension: slug, engine: resolved.gateway };
|
|
1429
|
+
}
|
|
1104
1430
|
if (extension !== undefined) {
|
|
1105
1431
|
registry.install(extension);
|
|
1106
1432
|
}
|
|
@@ -1108,6 +1434,12 @@ export async function loadExtensions(specifiers, registry, options = {}) {
|
|
|
1108
1434
|
// later stage's quarantine (never loaded) leaves the slug free
|
|
1109
1435
|
// for the next boot's specifier list.
|
|
1110
1436
|
slugs.set(slug, declared);
|
|
1437
|
+
// ...and its channel ids (the same claimed-only-by-loaded rule).
|
|
1438
|
+
if (resolved.channels !== undefined) {
|
|
1439
|
+
for (const channel of resolved.channels) {
|
|
1440
|
+
channelIds.set(channel.id, slug);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1111
1443
|
// The manifest version for the §7.7 inventory: read off the
|
|
1112
1444
|
// module's own directory (a bare entry with no manifest stays
|
|
1113
1445
|
// versionless — the field is optional by design).
|
|
@@ -1118,6 +1450,9 @@ export async function loadExtensions(specifiers, registry, options = {}) {
|
|
|
1118
1450
|
status: "ok",
|
|
1119
1451
|
...(version === undefined ? {} : { version }),
|
|
1120
1452
|
...(builtin === true ? { builtin: true } : {}),
|
|
1453
|
+
...(resolved.channels === undefined
|
|
1454
|
+
? {}
|
|
1455
|
+
: { channels: resolved.channels.map((channel) => channel.id) }),
|
|
1121
1456
|
});
|
|
1122
1457
|
if (extension !== undefined && resolved.health !== undefined) {
|
|
1123
1458
|
healthProducers.push({
|
|
@@ -1167,6 +1502,24 @@ export async function loadExtensions(specifiers, registry, options = {}) {
|
|
|
1167
1502
|
if (resolved.notifiers !== undefined) {
|
|
1168
1503
|
notifierBindings.push({ extension: slug, notifiers: resolved.notifiers });
|
|
1169
1504
|
}
|
|
1505
|
+
if (resolved.channels !== undefined) {
|
|
1506
|
+
channelBindings.push({ extension: slug, channels: resolved.channels });
|
|
1507
|
+
}
|
|
1508
|
+
if (resolved.surfaces !== undefined) {
|
|
1509
|
+
// The §11.1 assets dir: the package manifest's `sigrun.assets`
|
|
1510
|
+
// relative to the package root (the same anchor the version
|
|
1511
|
+
// read below uses); absent or undeclared → `undefined` and
|
|
1512
|
+
// every asset request 404s.
|
|
1513
|
+
const assetsRoot = packageDir ?? extensionModuleDir(loaded);
|
|
1514
|
+
const declaredAssets = assetsRoot === undefined
|
|
1515
|
+
? undefined
|
|
1516
|
+
: readPackageAssetsDir(assetsRoot);
|
|
1517
|
+
surfaceBindings.push({
|
|
1518
|
+
extension: slug,
|
|
1519
|
+
defs: resolved.surfaces,
|
|
1520
|
+
...(declaredAssets === undefined ? {} : { assetsDir: declaredAssets }),
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1170
1523
|
if (resolved.providers !== undefined) {
|
|
1171
1524
|
providerContributors.push({
|
|
1172
1525
|
name: slug,
|
|
@@ -1232,5 +1585,8 @@ export async function loadExtensions(specifiers, registry, options = {}) {
|
|
|
1232
1585
|
routeBindings,
|
|
1233
1586
|
commandBindings,
|
|
1234
1587
|
notifierBindings,
|
|
1588
|
+
channelBindings,
|
|
1589
|
+
gatewayEngine,
|
|
1590
|
+
surfaces: surfaceBindings,
|
|
1235
1591
|
};
|
|
1236
1592
|
}
|