@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.
Files changed (82) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +24 -2
  3. package/THIRD_PARTY_NOTICES.md +23 -0
  4. package/dist/blocks/builders.js +125 -0
  5. package/dist/blocks/images.js +57 -0
  6. package/dist/blocks/index.js +20 -0
  7. package/dist/blocks/limits.js +26 -0
  8. package/dist/blocks/placements.js +99 -0
  9. package/dist/blocks/schema.js +559 -0
  10. package/dist/blocks/validate.js +602 -0
  11. package/dist/config.js +31 -0
  12. package/dist/extension.d.ts +476 -9
  13. package/dist/harness/agent-env.js +62 -0
  14. package/dist/harness/boot.js +8 -1
  15. package/dist/harness/event-bus.js +33 -0
  16. package/dist/harness/identity/users.js +3 -2
  17. package/dist/harness/loader.js +369 -13
  18. package/dist/harness/packages.js +71 -2
  19. package/dist/harness/seam-host.js +69 -2
  20. package/dist/harness/settings.js +5 -0
  21. package/dist/harness/workspace.js +6 -4
  22. package/dist/jobs/fire.js +11 -0
  23. package/dist/jobs/spec.js +19 -0
  24. package/dist/serve.js +29 -39
  25. package/dist/server/auth/pipeline.js +16 -1
  26. package/dist/server/conversation-ops.js +482 -10
  27. package/dist/server/gateway.js +122 -0
  28. package/dist/server/helpers.js +26 -0
  29. package/dist/server/host-bridge.js +141 -4
  30. package/dist/server/index.js +27 -0
  31. package/dist/server/notifications.js +6 -2
  32. package/dist/server/routes/conversations.js +37 -93
  33. package/dist/server/routes/events.js +39 -0
  34. package/dist/server/routes/ext-assets.js +84 -0
  35. package/dist/server/routes/extensions.js +31 -1
  36. package/dist/server/routes/files.js +38 -83
  37. package/dist/server/routes/meta.js +35 -1
  38. package/dist/server/routes/providers.js +5 -21
  39. package/dist/server/routes/submissions.js +36 -111
  40. package/dist/server/routes/surfaces.js +387 -0
  41. package/dist/server/routes/users.js +6 -1
  42. package/dist/server/session-meta.js +12 -6
  43. package/dist/server/shutdown.js +85 -0
  44. package/dist/server/surfaces/access-cache.js +43 -0
  45. package/dist/server/surfaces/dedup.js +67 -0
  46. package/dist/server/surfaces/instance.js +42 -0
  47. package/dist/server/surfaces/mime.js +73 -0
  48. package/dist/server/surfaces/rate-limit.js +63 -0
  49. package/dist/server/surfaces/service.js +493 -0
  50. package/dist/server/surfaces/watchers.js +94 -0
  51. package/node_modules/@sigrun/shared/CHANGELOG.md +9 -0
  52. package/node_modules/@sigrun/shared/dist/blocks/format.d.ts +28 -0
  53. package/node_modules/@sigrun/shared/dist/blocks/format.js +199 -0
  54. package/node_modules/@sigrun/shared/dist/blocks/icons.d.ts +12 -0
  55. package/node_modules/@sigrun/shared/dist/blocks/icons.js +64 -0
  56. package/node_modules/@sigrun/shared/dist/blocks/index.d.ts +25 -0
  57. package/node_modules/@sigrun/shared/dist/blocks/index.js +21 -0
  58. package/node_modules/@sigrun/shared/dist/blocks/instance.d.ts +29 -0
  59. package/node_modules/@sigrun/shared/dist/blocks/instance.js +51 -0
  60. package/node_modules/@sigrun/shared/dist/blocks/lttb.d.ts +16 -0
  61. package/node_modules/@sigrun/shared/dist/blocks/lttb.js +67 -0
  62. package/node_modules/@sigrun/shared/dist/blocks/ops.d.ts +44 -0
  63. package/node_modules/@sigrun/shared/dist/blocks/ops.js +294 -0
  64. package/node_modules/@sigrun/shared/dist/blocks/text.d.ts +43 -0
  65. package/node_modules/@sigrun/shared/dist/blocks/text.js +790 -0
  66. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +77 -0
  67. package/node_modules/@sigrun/shared/dist/blocks/transport.js +11 -0
  68. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +541 -0
  69. package/node_modules/@sigrun/shared/dist/blocks/types.js +17 -0
  70. package/node_modules/@sigrun/shared/dist/jobs.d.ts +9 -0
  71. package/node_modules/@sigrun/shared/dist/live.d.ts +71 -0
  72. package/node_modules/@sigrun/shared/dist/live.js +82 -0
  73. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +19 -3
  74. package/node_modules/@sigrun/shared/dist/settings.d.ts +7 -0
  75. package/node_modules/@sigrun/shared/dist/views.d.ts +81 -3
  76. package/node_modules/@sigrun/shared/package.json +10 -2
  77. package/package.json +11 -5
  78. package/webui/dist/assets/index-CiYInlLx.css +1 -0
  79. package/webui/dist/assets/index-JDfOQV9z.js +52 -0
  80. package/webui/dist/index.html +2 -2
  81. package/webui/dist/assets/index-BiSMljer.css +0 -1
  82. package/webui/dist/assets/index-hVLvsizV.js +0 -50
@@ -17,24 +17,31 @@
17
17
  */
18
18
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
19
19
  import { accessOf, isSharing, } from "@sigrun/shared/access";
20
+ import { extname } from "node:path";
20
21
  import { touchActivity } from "../harness/activity.js";
21
22
  import { resolveServedModelRefreshing } from "../harness/providers/refresh.js";
22
- import { parseSubmissionInput, resolveParts } from "./content-parts.js";
23
+ import { isSuperuser } from "./auth/principal.js";
24
+ import { isConfinedPath, parseSubmissionInput, resolveParts } from "./content-parts.js";
23
25
  import { CommandUsageError, expandInput, EXPANDED_ENTRY_KIND, } from "../commands/expand.js";
24
- import { conversationEnv, readView, SCAN_PAGE_SIZE, storeAgentChange, WHEN_BUSY_MODES, } from "./helpers.js";
25
- import { DEFAULT_SESSION_META, SessionMetaDoc, } from "./session-meta.js";
26
+ import { conversationEnv, readView, SCAN_PAGE_SIZE, storeAgentChange, THINKING_LEVELS, WHEN_BUSY_MODES, } from "./helpers.js";
27
+ import { applySessionMetaPatch, DEFAULT_SESSION_META, SessionMetaDoc, validateSessionMetaPatch, } from "./session-meta.js";
26
28
  /**
27
- * One client error from a conversation op: the exact 400 body the routes
28
- * serve (`{error: "invalid_input"}`, `{error: "invalid_field", field}`),
29
- * so the HTTP route maps it verbatim and the bridge caller sees the
30
- * same fail-loud vocabulary.
29
+ * One client error from a conversation op: the exact body the routes
30
+ * serve (`{error: "invalid_input"}`, `{error: "invalid_field", field}`)
31
+ * plus the HTTP status it maps to — 400 for the validation vocabulary,
32
+ * 404 for `no_active_submission`, 403 for the sharing gate — so one
33
+ * route-side mapping (`reply.code(err.status).send(err.body)`) covers
34
+ * every op error, and the bridge caller sees the same fail-loud
35
+ * vocabulary.
31
36
  */
32
37
  export class ConversationOpError extends Error {
33
38
  body;
34
- constructor(body) {
39
+ status;
40
+ constructor(body, status = 400) {
35
41
  super(body.error);
36
42
  this.body = body;
37
43
  this.name = "ConversationOpError";
44
+ this.status = status;
38
45
  }
39
46
  }
40
47
  /** The §7.1 ceiling for `?waitMs` — a positive integer at most this. */
@@ -66,6 +73,57 @@ async function waitForSubmission(submission, waitMs) {
66
73
  clearTimeout(timer);
67
74
  }
68
75
  }
76
+ /**
77
+ * Validate + resolve the `agent` selection (gateway C4): shape first
78
+ * (an object whose members are string arrays — non-object →
79
+ * `invalid_field` `agent`, a non-array member or non-string entry →
80
+ * `invalid_field` naming `agent.<member>`), then names against the
81
+ * registry snapshot (`extension(name)` for extensions; `tools()`
82
+ * entries' `tool` for tools — `AgentChange.tools` takes
83
+ * ToolRegistration OBJECTS, not names). An unknown name throws
84
+ * `invalid_field` naming `agent.<member>`.
85
+ */
86
+ function resolveAgentSelection(ctx, agent) {
87
+ const snapshot = ctx.opened.registry.snapshot();
88
+ const resolved = {};
89
+ if (agent.extensions !== undefined) {
90
+ const extensions = [];
91
+ for (const name of agent.extensions) {
92
+ const extension = snapshot.extension(name);
93
+ if (extension === undefined) {
94
+ throw new ConversationOpError({
95
+ error: "invalid_field",
96
+ field: "agent.extensions",
97
+ });
98
+ }
99
+ extensions.push(extension);
100
+ }
101
+ resolved.extensions = extensions;
102
+ }
103
+ if (agent.tools !== undefined) {
104
+ // Tools may repeat across extensions (RegistrySnapshot.tools); a
105
+ // name selects the FIRST registered tool with that name — the same
106
+ // resolution `resolveAgent`'s composed map applies.
107
+ const byName = new Map();
108
+ for (const entry of snapshot.tools()) {
109
+ if (!byName.has(entry.tool.name))
110
+ byName.set(entry.tool.name, entry.tool);
111
+ }
112
+ const tools = [];
113
+ for (const name of agent.tools) {
114
+ const tool = byName.get(name);
115
+ if (tool === undefined) {
116
+ throw new ConversationOpError({
117
+ error: "invalid_field",
118
+ field: "agent.tools",
119
+ });
120
+ }
121
+ tools.push(tool);
122
+ }
123
+ resolved.tools = tools;
124
+ }
125
+ return resolved;
126
+ }
69
127
  /**
70
128
  * Create one conversation the server-create way: ownerless ownership, the
71
129
  * boot default model plus the store's agent settings (re-read per create),
@@ -87,14 +145,47 @@ export async function createConversationOp(ctx, input = {}) {
87
145
  if (input.source !== undefined && typeof input.source !== "string") {
88
146
  throw new ConversationOpError({ error: "invalid_field", field: "source" });
89
147
  }
148
+ // The agent selection (gateway C4): shape fail-loud first, then name
149
+ // resolution against the registry snapshot (unknown names throw).
150
+ if (input.agent !== undefined) {
151
+ if (typeof input.agent !== "object" ||
152
+ input.agent === null ||
153
+ Array.isArray(input.agent)) {
154
+ throw new ConversationOpError({ error: "invalid_field", field: "agent" });
155
+ }
156
+ for (const member of ["extensions", "tools"]) {
157
+ const value = input.agent[member];
158
+ if (value === undefined)
159
+ continue;
160
+ if (!Array.isArray(value) ||
161
+ value.some((name) => typeof name !== "string")) {
162
+ throw new ConversationOpError({
163
+ error: "invalid_field",
164
+ field: `agent.${member}`,
165
+ });
166
+ }
167
+ }
168
+ }
169
+ const selection = input.agent === undefined
170
+ ? undefined
171
+ : resolveAgentSelection(ctx, input.agent);
90
172
  const conversation = await opened.harness.createConversation({
91
173
  ownership: { kind: "ownerless" },
92
174
  // A conversation with no agent model cannot generate (no_model):
93
175
  // server-created conversations start on the boot default model,
94
176
  // like the root conversation, and carry the store's
95
177
  // defaultTools/perModelThinking — re-read per create, so PATCHes
96
- // reach new conversations without a restart.
97
- agent: storeAgentChange(opened, opened.defaultModel),
178
+ // reach new conversations without a restart. The C4 `agent`
179
+ // selection rides alongside: `extensions`/`tools` pin the stored
180
+ // filters by name while the store's `modelTools` narrowing still
181
+ // applies (both come from storeAgentChange + the spread).
182
+ agent: {
183
+ ...storeAgentChange(opened, opened.defaultModel),
184
+ ...(selection?.extensions === undefined
185
+ ? {}
186
+ : { extensions: selection.extensions }),
187
+ ...(selection?.tools === undefined ? {} : { tools: selection.tools }),
188
+ },
98
189
  }, BACKGROUND_CONTEXT);
99
190
  // Creation IS activity: stamp lastActivityAt now, or the sidebar's
100
191
  // recency sort puts a never-submitted conversation at the BOTTOM of
@@ -306,3 +397,384 @@ export async function readMetaOp(opened, id) {
306
397
  return ((await opened.harness.snapshot(SessionMetaDoc, id, BACKGROUND_CONTEXT)) ??
307
398
  DEFAULT_SESSION_META);
308
399
  }
400
+ /**
401
+ * Configure one conversation's model and/or thinking level (the
402
+ * configure route's body, verbatim): EXACTLY `model` + `thinking` — any
403
+ * other key is `invalid_field`; a body changing neither is
404
+ * `invalid_body`; `model` is `"provider/modelId"` split on the FIRST
405
+ * `/` (a modelId may itself contain slashes) with both parts non-empty;
406
+ * `thinking` must be a pi-ai level. A model change re-applies the
407
+ * store's `perModelThinking` override for the NEW model unless the
408
+ * request pins `thinking` (the store is the deployment default, the
409
+ * explicit field the caller's win — re-read here so a PATCH reaches
410
+ * later configures). Returns the configured surface projection
411
+ * (`{model?, thinkingLevel}`).
412
+ */
413
+ export async function configureOp(ctx, conversation, body) {
414
+ const { opened } = ctx;
415
+ if (body === undefined ||
416
+ body === null ||
417
+ typeof body !== "object" ||
418
+ Array.isArray(body)) {
419
+ throw new ConversationOpError({ error: "invalid_body" });
420
+ }
421
+ const record = body;
422
+ const unknownKey = Object.keys(record).find((key) => key !== "model" && key !== "thinking");
423
+ if (unknownKey !== undefined) {
424
+ throw new ConversationOpError({
425
+ error: "invalid_field",
426
+ field: unknownKey,
427
+ });
428
+ }
429
+ const model = record.model;
430
+ const thinking = record.thinking;
431
+ if (model === undefined && thinking === undefined) {
432
+ // A configure that changes nothing is a client error, not a no-op.
433
+ throw new ConversationOpError({ error: "invalid_body" });
434
+ }
435
+ let parsedModel;
436
+ if (model !== undefined) {
437
+ if (typeof model !== "string" || !model.includes("/")) {
438
+ throw new ConversationOpError({ error: "invalid_field", field: "model" });
439
+ }
440
+ const slash = model.indexOf("/");
441
+ const provider = model.slice(0, slash);
442
+ const modelId = model.slice(slash + 1);
443
+ if (provider.length === 0 || modelId.length === 0) {
444
+ throw new ConversationOpError({ error: "invalid_field", field: "model" });
445
+ }
446
+ parsedModel = { provider, modelId };
447
+ }
448
+ if (thinking !== undefined &&
449
+ (typeof thinking !== "string" || !THINKING_LEVELS.includes(thinking))) {
450
+ throw new ConversationOpError({ error: "invalid_field", field: "thinking" });
451
+ }
452
+ // perModelThinking: a model change re-applies the store's override for
453
+ // the NEW model unless the request pins `thinking` — the map is the
454
+ // deployment default, the explicit field the caller's win.
455
+ const storeThinking = storeThinkingFor(opened, parsedModel);
456
+ await conversation.configure({
457
+ ...(parsedModel === undefined ? {} : { model: parsedModel }),
458
+ ...(thinking !== undefined
459
+ ? { thinkingLevel: thinking }
460
+ : storeThinking === undefined
461
+ ? {}
462
+ : { thinkingLevel: storeThinking }),
463
+ }, BACKGROUND_CONTEXT);
464
+ // Read the agent back and project the configured surface only —
465
+ // resolved extensions/tools/sections stay server-side.
466
+ const agent = await conversation.agent(BACKGROUND_CONTEXT);
467
+ return {
468
+ ...(agent.model === undefined ? {} : { model: agent.model }),
469
+ thinkingLevel: agent.thinkingLevel,
470
+ };
471
+ }
472
+ /** The store's per-model thinking override for `model` (re-read per call). */
473
+ function storeThinkingFor(opened, model) {
474
+ if (model === undefined)
475
+ return undefined;
476
+ return opened.settings.read().perModelThinking?.[`${model.provider}/${model.modelId}`];
477
+ }
478
+ /**
479
+ * Withdraw/settle the conversation's active submission (the abort
480
+ * route's body, verbatim): `inspect()` is the read-only union of placed
481
+ * run inputs and queued inbox items (all conversations); filtered to
482
+ * this conversation, the LOWEST id is the active one — anything queued
483
+ * behind a busy conversation was admitted later and has a higher id.
484
+ * Nothing active is the 404 `no_active_submission`; a `not_found` abort
485
+ * result (the record vanished between inspect and abort) is the same
486
+ * honest answer. `already_placed`/`settled` still report which state
487
+ * the submission was found in (abort is idempotent).
488
+ */
489
+ export async function abortOp(ctx, conversation) {
490
+ const { opened } = ctx;
491
+ const inspection = await opened.harness.inspect(BACKGROUND_CONTEXT);
492
+ // Lowest live id = oldest admitted = the placed run input over later-
493
+ // queued inbox items. reduce() with a first-element seed is ordering-
494
+ // independent (a future pi-durable inspect() ordering change cannot
495
+ // silently target the wrong submission); empty → undefined.
496
+ const active = inspection.submissions
497
+ .filter((s) => s.conversationId === conversation.id)
498
+ .reduce((min, s) => (min === undefined || s.id < min.id ? s : min), undefined);
499
+ if (active === undefined) {
500
+ throw new ConversationOpError({ error: "no_active_submission" }, 404);
501
+ }
502
+ const result = await opened.harness.abortSubmission(active.id, BACKGROUND_CONTEXT, conversation.id);
503
+ // `not_found` means the submission's record vanished between `inspect`
504
+ // and `abort` — the conversation is no longer busy, so the honest answer
505
+ // is the same as nothing-active.
506
+ if (result === "not_found") {
507
+ throw new ConversationOpError({ error: "no_active_submission" }, 404);
508
+ }
509
+ return { result, submissionId: active.id };
510
+ }
511
+ /**
512
+ * Admit one manual compaction (the compact route's op): `instructions`
513
+ * is validated here — a non-string is `invalid_field`, an empty string
514
+ * reads as none (the route's normalization) — and `Conversation.compact`
515
+ * creates the conversation-owned durable task with `reason: "manual"`:
516
+ * it compacts even when idle and places its summary through a write
517
+ * submission. Manual compaction ignores the policy's `enabled`; the
518
+ * task id is handed back (the SSE stream carries the `pi.compaction`
519
+ * entry when the summary lands). Returns the admit shape `{taskId}`;
520
+ * the caller owns the 202.
521
+ */
522
+ export async function compactOp(ctx, conversation, instructions) {
523
+ // The route's instructions validation, moved in: the HTTP caller's
524
+ // raw value and the host's typed value take the SAME check (host
525
+ // inputs are typed, but the op still validates).
526
+ if (instructions !== undefined && typeof instructions !== "string") {
527
+ throw new ConversationOpError({
528
+ error: "invalid_field",
529
+ field: "instructions",
530
+ });
531
+ }
532
+ const normalized = instructions === "" ? undefined : instructions;
533
+ const taskId = await conversation.compact(normalized, BACKGROUND_CONTEXT);
534
+ return { taskId };
535
+ }
536
+ /**
537
+ * Fork one conversation's history at a visible entry (the fork route's
538
+ * body, verbatim): absent `at` defaults to the newest entry (400
539
+ * `empty_conversation` when the parent has no history at all); an
540
+ * explicit `at` must be an id the parent actually had (400
541
+ * `invalid_field` — never a silent empty fork). The fork inherits the
542
+ * parent's agent model (falling back to the boot default like create),
543
+ * and ONE commit resets the copied meta: name/autoName/attempts/sharing
544
+ * dropped, the owner re-stamped from `opts.owner` (undefined = the key
545
+ * removed), the autoName seeded `"<source> (fork)"` when the source had
546
+ * a display name, and activity stamped so the fork sorts where it
547
+ * happened. Returns the create shape `{id, view}`.
548
+ */
549
+ export async function forkOp(ctx, conversation, opts = {}) {
550
+ const { opened } = ctx;
551
+ // The route's `at` type check, verbatim: absent body = newest entry;
552
+ // a present `at` must be a number (the host's typed EntryId passes).
553
+ if (opts.at !== undefined && typeof opts.at !== "number") {
554
+ throw new ConversationOpError({ error: "invalid_field", field: "at" });
555
+ }
556
+ let at = opts.at;
557
+ if (at === undefined) {
558
+ // The entries query is newest-first: the first item of the first
559
+ // page is the newest entry. An empty page means there is nothing
560
+ // to fork — the parent conversation has no history at all.
561
+ const page = await conversation.entries({}, 1, undefined, BACKGROUND_CONTEXT);
562
+ if (page.items.length === 0) {
563
+ throw new ConversationOpError({ error: "empty_conversation" });
564
+ }
565
+ at = page.items[0].id;
566
+ }
567
+ else {
568
+ // Validate `at` against the parent's own history: `fork()` accepts
569
+ // any EntryId, but an id this conversation never had is a client
570
+ // error, not a silent empty fork.
571
+ const page = await conversation.entries({ maxEntryId: at }, 1, undefined, BACKGROUND_CONTEXT);
572
+ const newest = page.items[0];
573
+ if (newest === undefined || newest.id !== at) {
574
+ throw new ConversationOpError({ error: "invalid_field", field: "at" });
575
+ }
576
+ }
577
+ // The fork inherits the parent's agent model — read the resolved
578
+ // agent, fall back to the boot default like create.
579
+ const agent = await conversation.agent(BACKGROUND_CONTEXT);
580
+ const fork = await conversation.fork(at, {
581
+ ownership: { kind: "ownerless" },
582
+ agent: { model: agent.model ?? opened.defaultModel },
583
+ }, BACKGROUND_CONTEXT);
584
+ // The fork's sidebar identity: the copied meta doc carries the
585
+ // source's name/autoName and the namer's attempt count; ONE commit
586
+ // drops all three (a fork is a new conversation, not the source's
587
+ // name — and it starts naming-fresh: inheriting an exhausted attempt
588
+ // count would make the fork forever unnameable), re-seeds it with
589
+ // "<source display name> (fork)" as its AUTO name when the source
590
+ // had one, and stamps activity so the fresh fork sorts where it
591
+ // happened. §5.9 (Review Focus 3): the doc copied the source's
592
+ // `owner` and `sharing` (fork: "current") — the SAME commit resets
593
+ // them: a fork is the FORKER's private copy (owner = the caller,
594
+ // sharing deleted; an ownerless caller means no owner at all — the
595
+ // key is removed).
596
+ const sourceMeta = await opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT);
597
+ const sourceName = sourceMeta?.name ?? sourceMeta?.autoName;
598
+ await fork.commit(async (tx) => {
599
+ const draft = await tx.doc(SessionMetaDoc, fork.id);
600
+ delete draft.name;
601
+ delete draft.autoName;
602
+ delete draft.autoNameAttempts;
603
+ delete draft.sharing;
604
+ if (opts.owner === undefined)
605
+ delete draft.owner;
606
+ else
607
+ draft.owner = opts.owner;
608
+ if (sourceName !== undefined)
609
+ draft.autoName = `${sourceName} (fork)`;
610
+ draft.lastActivityAt = opened.now().toISOString();
611
+ }, BACKGROUND_CONTEXT);
612
+ const view = await readView(fork);
613
+ return { id: fork.id, view };
614
+ }
615
+ /**
616
+ * Patch one conversation's session meta (the meta PATCH route's body,
617
+ * verbatim): validate FIRST (a rejected patch never touches the doc),
618
+ * then — when a principal is given and the patch carries `sharing` —
619
+ * the owner-or-superuser gate (403 `forbidden`), then the
620
+ * read-modify-write merge inside ONE commit callback (concurrent
621
+ * patches cannot lose each other's fields; the mutation line
622
+ * serializes commits). The response is a FRESH post-commit snapshot —
623
+ * reading a newly-SET nested draft value trips chord's settled-overlay
624
+ * rule, so the reply must never read the mutated draft.
625
+ */
626
+ export async function patchMetaOp(ctx, conversation, body, opts = {}) {
627
+ const { opened } = ctx;
628
+ // Validate FIRST — a rejected patch never touches the doc.
629
+ const validated = validateSessionMetaPatch(body);
630
+ if ("error" in validated) {
631
+ throw new ConversationOpError(validated);
632
+ }
633
+ // §5.9: `sharing` additionally requires the OWNER or a superuser —
634
+ // write access (granted by a share) must not let a recipient re-share
635
+ // the resource. Host callers pass no principal (trusted).
636
+ if (opts.principal !== undefined && validated.sharing !== undefined) {
637
+ const meta = await opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT);
638
+ const isOwner = opts.principal.user !== undefined &&
639
+ opts.principal.user === meta?.owner;
640
+ if (!isOwner && !isSuperuser(opts.principal)) {
641
+ throw new ConversationOpError({ error: "forbidden" }, 403);
642
+ }
643
+ }
644
+ // Read-modify-write inside ONE commit: tx.doc materializes the doc
645
+ // (initial()) when absent and the patch mutates the tracked draft.
646
+ await conversation.commit(async (tx) => {
647
+ const draft = await tx.doc(SessionMetaDoc, conversation.id);
648
+ applySessionMetaPatch(draft, validated);
649
+ }, BACKGROUND_CONTEXT);
650
+ // The response is a FRESH snapshot, not a draft projection (see the
651
+ // doc comment).
652
+ return ((await opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT)) ?? DEFAULT_SESSION_META);
653
+ }
654
+ // ─── Upload + raw read (gateway C4b — the files routes' ops) ───────────────
655
+ /**
656
+ * The one upload-name rule (gateway C4b): no `/` or `\`, not `.` or
657
+ * `..`, non-empty. The single validator BOTH upload routes and the host
658
+ * `conversations.upload` op share — the routes pre-validate with it to
659
+ * keep their exact `{error: "invalid_body"}` wire bodies while the op
660
+ * throws the host-side `invalid_field` `name` vocabulary.
661
+ */
662
+ export function validUploadName(name) {
663
+ return (name.length > 0 &&
664
+ !name.includes("/") &&
665
+ !name.includes("\\") &&
666
+ name !== "." &&
667
+ name !== "..");
668
+ }
669
+ /**
670
+ * The extension→mime map (spec §7.8): the common workspace-file
671
+ * extensions a client renders inline; an absent extension serves
672
+ * `application/octet-stream`. The KEY is the LOWERCASE extension
673
+ * including the dot (the lookup lowercases, so `Photo.PNG` maps too).
674
+ * One map for the raw route AND the host `readFile` op (its `mime`
675
+ * field) — never two.
676
+ */
677
+ export const MIME_BY_EXTENSION = new Map([
678
+ [".png", "image/png"],
679
+ [".jpg", "image/jpeg"],
680
+ [".jpeg", "image/jpeg"],
681
+ [".gif", "image/gif"],
682
+ [".webp", "image/webp"],
683
+ [".svg", "image/svg+xml"],
684
+ [".pdf", "application/pdf"],
685
+ [".txt", "text/plain"],
686
+ [".md", "text/markdown"],
687
+ [".json", "application/json"],
688
+ [".html", "text/html"],
689
+ [".css", "text/css"],
690
+ [".js", "text/javascript"],
691
+ ]);
692
+ /**
693
+ * Write one upload into the conversation's upload namespace (gateway
694
+ * C4b, the raw-upload/base64 routes' shared core): validates the NAME
695
+ * (the one rule — a bad name throws `invalid_field` `name`; the routes
696
+ * pre-validate with `validUploadName` to keep their `invalid_body`
697
+ * bodies byte-identical), writes `uploads/<conversationId>/<name>`
698
+ * through the conversation's workspace env (correct for remote
699
+ * workspaces), and returns the path exactly as a content part takes it.
700
+ * A failed env write is a server-side fault: fail loud.
701
+ */
702
+ export async function uploadOp(ctx, conversation, file) {
703
+ if (!validUploadName(file.name)) {
704
+ throw new ConversationOpError({ error: "invalid_field", field: "name" });
705
+ }
706
+ // `mime` is validated (never persisted — the workspace write is the
707
+ // file's own bytes; a content part carries its own mime) because JS
708
+ // extensions cross this signature untyped at runtime: the op is the
709
+ // host caller's only validation point, like compactOp's instructions.
710
+ if (file.mime !== undefined && typeof file.mime !== "string") {
711
+ throw new ConversationOpError({ error: "invalid_field", field: "mime" });
712
+ }
713
+ // v1 namespaces uploads per conversation (workspaces are static and
714
+ // shared in v1, so un-namespaced names would clobber across
715
+ // conversations).
716
+ const path = `uploads/${conversation.id}/${file.name}`;
717
+ const env = await conversationEnv(ctx.opened, conversation);
718
+ const written = await env.writeFile(path, file.bytes, BACKGROUND_CONTEXT);
719
+ if (!written.ok) {
720
+ throw written.error;
721
+ }
722
+ return { path };
723
+ }
724
+ /**
725
+ * Read one workspace file raw (gateway C4b, the files/raw route's op):
726
+ * the route's confinement + cap vocabulary as op errors — non-confined/
727
+ * empty/`.`/directory → `invalid_path` (400), absent → `not_found`
728
+ * (404), over `server.maxFileReadBytes` → `too_large` (413; the HTTP
729
+ * route keeps its own `file_too_large` + `maxBytes` body). The read is
730
+ * `readBinaryFile` (the op buffers — the host caller wants the bytes;
731
+ * the ROUTE keeps its chunked streaming through `readFileMetaOp`'s env
732
+ * so it never buffers) with the extension-derived mime from the shared
733
+ * map (unknown extensions read as `application/octet-stream`, the raw
734
+ * route's own default — one behavior, never two).
735
+ */
736
+ export async function readFileOp(ctx, conversation, path) {
737
+ const { env, mime } = await readFileMetaOp(ctx, conversation, path);
738
+ const read = await env.readBinaryFile(path, BACKGROUND_CONTEXT);
739
+ if (!read.ok) {
740
+ if (read.error.code === "not_found") {
741
+ throw new ConversationOpError({ error: "not_found" }, 404);
742
+ }
743
+ throw read.error;
744
+ }
745
+ return { bytes: read.value, mime };
746
+ }
747
+ /**
748
+ * The raw-read pre-checks (gateway C4b): the shared validation BOTH the
749
+ * `files/raw` route and the host `readFileOp` run — the content route's
750
+ * confinement rule (`isConfinedPath`; empty and `.` rejected too), the
751
+ * directory rejection, and the `server.maxFileReadBytes` cap — plus the
752
+ * size/mime the caller needs to serve the file and the conversation env
753
+ * it serves through (one env build per read, never two). The route maps
754
+ * `too_large` onto its own `file_too_large` + `maxBytes` body; every
755
+ * other op error maps 1:1 onto the route's existing responses.
756
+ */
757
+ export async function readFileMetaOp(ctx, conversation, path) {
758
+ if (!isConfinedPath(path) || path.length === 0 || path === ".") {
759
+ throw new ConversationOpError({ error: "invalid_path" });
760
+ }
761
+ const env = await conversationEnv(ctx.opened, conversation);
762
+ const info = await env.fileInfo(path, BACKGROUND_CONTEXT);
763
+ if (!info.ok) {
764
+ if (info.error.code === "not_found") {
765
+ throw new ConversationOpError({ error: "not_found" }, 404);
766
+ }
767
+ throw info.error;
768
+ }
769
+ if (info.value.kind === "directory") {
770
+ throw new ConversationOpError({ error: "invalid_path" });
771
+ }
772
+ if (info.value.size > ctx.config.maxFileReadBytes) {
773
+ throw new ConversationOpError({ error: "too_large" }, 413);
774
+ }
775
+ return {
776
+ env,
777
+ size: info.value.size,
778
+ mime: MIME_BY_EXTENSION.get(extname(path).toLowerCase()) ?? "application/octet-stream",
779
+ };
780
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The gateway engine lifecycle (gateway C2, spec §8.0).
3
+ *
4
+ * `startGateway` wraps the ONE loaded engine (boot's
5
+ * `OpenedHarness.gatewayEngine` — first declarer wins) in a
6
+ * {@link GatewayHandle}: the serve composition creates the handle BEFORE
7
+ * `createServer` (threaded through `ServerContext.gateway` so the meta
8
+ * route reaches it) and starts it after `app.listen`.
9
+ *
10
+ * The no-engine deploy (core ships no engine) gets a degenerate handle:
11
+ * every method resolves, `health()` reads "ok" but is NEVER consulted —
12
+ * the meta route keys `health.gateway` on `opened.gatewayEngine`, not on
13
+ * the handle, so a deploy with channel adapters and no engine reports NO
14
+ * gateway health (the adapters are inert; the extensions inventory marks
15
+ * their rows `inert: "no gateway"`).
16
+ *
17
+ * Failure posture (the spec's Review Focus 1): a `start` rejection is
18
+ * logged once (`gateway: engine start failed`) and LATCHES degraded —
19
+ * `started` still RESOLVES (a failed engine must not wedge boot or the
20
+ * ordered shutdown); a throwing `health()` reads "degraded" and logs
21
+ * once per process (never per call). Health stays degraded until
22
+ * restart — recovery is a new process, never a live re-start.
23
+ */
24
+ /**
25
+ * The degenerate no-engine handle: everything resolves, health reads
26
+ * "ok" (never consulted — the meta route keys on
27
+ * `opened.gatewayEngine`, not the handle).
28
+ */
29
+ function noEngineHandle() {
30
+ return {
31
+ started: Promise.resolve(),
32
+ health: () => "ok",
33
+ stopIntake: () => Promise.resolve(),
34
+ stop: () => Promise.resolve(),
35
+ };
36
+ }
37
+ /**
38
+ * The once-per-process no-engine warning flag: adapters contributed
39
+ * channels but no engine loaded, so the adapters are INERT (the
40
+ * `/v1/extensions` inventory marks their rows `inert: "no gateway"`).
41
+ * Logged once per process — `startGateway` may be called more than once
42
+ * over one boot (tests), and a deploy reboot re-runs it legitimately
43
+ * only on a new process.
44
+ */
45
+ let noEngineWarned = false;
46
+ /** Start the loaded gateway engine, wrapping it in the shutdown-side handle. */
47
+ export function startGateway(opened) {
48
+ const binding = opened.gatewayEngine;
49
+ if (binding === undefined) {
50
+ if (opened.channelBindings.length > 0 && !noEngineWarned) {
51
+ noEngineWarned = true;
52
+ console.warn("channels contributed but no gateway engine is loaded");
53
+ }
54
+ return noEngineHandle();
55
+ }
56
+ const engine = binding.engine;
57
+ // The shutdown signal: `stop()` aborts the controller the engine's
58
+ // start ctx carried — receive loops observe it (the same contract as a
59
+ // `ChannelContext.signal`).
60
+ const abort = new AbortController();
61
+ const ctx = {
62
+ channels: opened.channelBindings.flatMap((binding) => binding.channels.map((channel) => ({
63
+ extension: binding.extension,
64
+ channel,
65
+ }))),
66
+ signal: abort.signal,
67
+ };
68
+ // Latched by a start rejection: health reads degraded until restart.
69
+ let startFailed = false;
70
+ // LAZY start: the handle is created BEFORE `createServer` (the meta
71
+ // route needs it at construction) but the ENGINE starts on the first
72
+ // `started` read — the serve composition reads it after `app.listen`
73
+ // (gateway C2's lifecycle: start after listen). One promise forever
74
+ // (re-reads join the same start).
75
+ let startPromise;
76
+ const beginStart = () => {
77
+ startPromise ??= engine
78
+ .start(ctx)
79
+ .catch((error) => {
80
+ startFailed = true;
81
+ console.error("gateway: engine start failed", error);
82
+ });
83
+ return startPromise;
84
+ };
85
+ // IDEMPOTENT stop, the mirror of beginStart: the FIRST call runs
86
+ // `abort.abort()` + `engine.stop()`; every later call (and any racing
87
+ // the first) joins the SAME promise — a double-stop never calls
88
+ // `engine.stop()` twice (the interface documents that contract).
89
+ let stopPromise;
90
+ const stopOnce = () => {
91
+ stopPromise ??= (async () => {
92
+ abort.abort();
93
+ await engine.stop();
94
+ })();
95
+ return stopPromise;
96
+ };
97
+ // A throwing health() logs ONCE per process — the meta route calls
98
+ // health per request, and a persistently-throwing engine would spam
99
+ // the log on every poll otherwise.
100
+ let healthFailureLogged = false;
101
+ return {
102
+ get started() {
103
+ return beginStart();
104
+ },
105
+ health: () => {
106
+ if (startFailed)
107
+ return "degraded";
108
+ try {
109
+ return engine.health();
110
+ }
111
+ catch (error) {
112
+ if (!healthFailureLogged) {
113
+ healthFailureLogged = true;
114
+ console.error("gateway: engine health failed", error);
115
+ }
116
+ return "degraded";
117
+ }
118
+ },
119
+ stopIntake: () => engine.stopIntake(),
120
+ stop: stopOnce,
121
+ };
122
+ }