@sigrun/sigrun 0.1.3 → 0.1.5
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 +46 -0
- package/README.md +24 -2
- 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/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/jobs/fire.js +11 -0
- package/dist/jobs/spec.js +19 -0
- package/dist/serve.js +33 -41
- 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 +37 -2
- 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/schema-fixture.js +7 -0
- 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/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 +83 -3
- package/node_modules/@sigrun/shared/package.json +10 -2
- package/package.json +9 -3
- package/webui/dist/assets/index-B4-GYqEb.js +50 -0
- package/webui/dist/assets/index-BiSMljer.css +1 -0
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-2RfqJO57.css +0 -1
- package/webui/dist/assets/index-DkZgTngD.js +0 -50
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
|
}
|
package/dist/harness/packages.js
CHANGED
|
@@ -69,12 +69,23 @@ export const NPM_TIMEOUT_MS = 120_000;
|
|
|
69
69
|
* own. {@link ensureHostModuleLinks} links sigrun's own copies into the
|
|
70
70
|
* packages dir's node_modules so the dynamic import of such a package
|
|
71
71
|
* resolves them — the SAME module instances the host runs.
|
|
72
|
+
*
|
|
73
|
+
* `@sigrun/sigrun` itself is here too (spec §14.1): the builders behind
|
|
74
|
+
* `@sigrun/sigrun/blocks` must emit the SERVER's block-set version, so
|
|
75
|
+
* extensions import the running copy — never a bundled or
|
|
76
|
+
* registry-fetched one (the loader's exact-match rule quarantines a
|
|
77
|
+
* tree that resolves a different version). Sigrun is the RUNNING
|
|
78
|
+
* package, not a dependency: {@link resolveHostModuleDir} resolves it
|
|
79
|
+
* to its own root instead of walking node_modules.
|
|
72
80
|
*/
|
|
81
|
+
/** The package this code ships in — the host runtime module that is the host itself. */
|
|
82
|
+
const SELF_MODULE_NAME = "@sigrun/sigrun";
|
|
73
83
|
export const HOST_RUNTIME_MODULES = [
|
|
74
84
|
"@earendil-works/pi-ai",
|
|
75
85
|
"@earendil-works/pi-durable",
|
|
76
86
|
"@earendil-works/chord",
|
|
77
87
|
"typebox",
|
|
88
|
+
SELF_MODULE_NAME,
|
|
78
89
|
];
|
|
79
90
|
/**
|
|
80
91
|
* Read the `version` field of the package at `moduleDir` (following
|
|
@@ -101,13 +112,47 @@ export function hostModuleVersion(name) {
|
|
|
101
112
|
const dir = resolveHostModuleDir(name);
|
|
102
113
|
return dir === undefined ? undefined : installedVersionAt(dir);
|
|
103
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* The running package's own root: the nearest ancestor of THIS module
|
|
117
|
+
* whose manifest is named `@sigrun/sigrun`. The node_modules walk below
|
|
118
|
+
* can never find the running package — sigrun is not a dependency of
|
|
119
|
+
* itself, and a source checkout carries no
|
|
120
|
+
* `node_modules/@sigrun/sigrun` anywhere above `src/` (the published
|
|
121
|
+
* install resolves through its OWN root, which is this same dir).
|
|
122
|
+
* `undefined` when no ancestor carries the manifest.
|
|
123
|
+
*/
|
|
124
|
+
function selfPackageRoot() {
|
|
125
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
126
|
+
for (let hops = 0; hops < 10; hops++) {
|
|
127
|
+
const manifest = join(dir, "package.json");
|
|
128
|
+
if (existsSync(manifest)) {
|
|
129
|
+
try {
|
|
130
|
+
const parsed = JSON.parse(readFileSync(manifest, "utf8"));
|
|
131
|
+
if (parsed.name === SELF_MODULE_NAME)
|
|
132
|
+
return dir;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Unreadable manifest — keep walking.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const parent = dirname(dir);
|
|
139
|
+
if (parent === dir)
|
|
140
|
+
return undefined;
|
|
141
|
+
dir = parent;
|
|
142
|
+
}
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
104
145
|
/**
|
|
105
146
|
* Resolve a host runtime module's package dir from sigrun's own tree: the
|
|
106
147
|
* nearest `node_modules/<name>` walking up from THIS module's location
|
|
107
148
|
* (the repo root in every deploy shape — sigrun runs from source). The
|
|
108
149
|
* walk stops at the filesystem root; `undefined` when nothing resolves.
|
|
150
|
+
* The ONE exception is sigrun itself (see {@link selfPackageRoot}): the
|
|
151
|
+
* link target is the running package's own root.
|
|
109
152
|
*/
|
|
110
153
|
function resolveHostModuleDir(name) {
|
|
154
|
+
if (name === SELF_MODULE_NAME)
|
|
155
|
+
return selfPackageRoot();
|
|
111
156
|
let dir = dirname(fileURLToPath(import.meta.url));
|
|
112
157
|
for (let hops = 0; hops < 10; hops++) {
|
|
113
158
|
const candidate = join(dir, "node_modules", ...name.split("/"));
|
|
@@ -210,13 +255,25 @@ export class PackageSyncError extends Error {
|
|
|
210
255
|
}
|
|
211
256
|
/**
|
|
212
257
|
* One `npm <args>` invocation against the packages dir (execFile — no
|
|
213
|
-
* shell, so no quoting surface).
|
|
258
|
+
* shell, so no quoting surface). `--legacy-peer-deps` disables npm's
|
|
259
|
+
* peer-resolution for every managed install — pi's own posture for its
|
|
260
|
+
* extension installs: an extension declaring `@sigrun/sigrun` (or
|
|
261
|
+
* pi-durable/pi-ai) a `peerDependency: "*"` must resolve the HOST's
|
|
262
|
+
* linked copy, never an npm-solved registry copy whose version would
|
|
263
|
+
* quarantine the extension at load. Failures and timeouts reject with a
|
|
214
264
|
* `PackageSyncError` carrying npm's stderr tail; `verbose` captures the
|
|
215
265
|
* full output for the error message.
|
|
216
266
|
*/
|
|
217
267
|
function runNpm(args, packagesDir, verbose) {
|
|
218
268
|
return new Promise((resolve, reject) => {
|
|
219
|
-
execFile("npm", [
|
|
269
|
+
execFile("npm", [
|
|
270
|
+
...args,
|
|
271
|
+
"--prefix",
|
|
272
|
+
packagesDir,
|
|
273
|
+
"--no-audit",
|
|
274
|
+
"--no-fund",
|
|
275
|
+
"--legacy-peer-deps",
|
|
276
|
+
], { timeout: NPM_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
220
277
|
if (error === null) {
|
|
221
278
|
if (verbose === true && stderr !== "") {
|
|
222
279
|
console.warn(`packages: npm ${args[0] ?? ""}: ${stderr.trim()}`);
|
|
@@ -346,6 +403,18 @@ export function readPackageVersion(absPath) {
|
|
|
346
403
|
const version = readManifest(absPath)?.version;
|
|
347
404
|
return typeof version === "string" && version !== "" ? version : undefined;
|
|
348
405
|
}
|
|
406
|
+
/**
|
|
407
|
+
* The declared `sigrun.assets` directory (spec §11.1), resolved
|
|
408
|
+
* absolute against the package root; `undefined` when the manifest
|
|
409
|
+
* declares none (or no manifest reads). A non-string declaration is
|
|
410
|
+
* ignored — assets are simply absent.
|
|
411
|
+
*/
|
|
412
|
+
export function readPackageAssetsDir(absPath) {
|
|
413
|
+
const declared = readManifest(absPath)?.sigrun?.assets;
|
|
414
|
+
if (typeof declared !== "string" || declared === "")
|
|
415
|
+
return undefined;
|
|
416
|
+
return resolve(absPath, declared);
|
|
417
|
+
}
|
|
349
418
|
/**
|
|
350
419
|
* The identity a specifier installs under: registry specs key by their
|
|
351
420
|
* parsed name; path specs key by the target package's OWN manifest name
|