@sigrun/sigrun 0.1.4 → 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.
Files changed (76) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +24 -2
  3. package/dist/blocks/builders.js +125 -0
  4. package/dist/blocks/images.js +57 -0
  5. package/dist/blocks/index.js +20 -0
  6. package/dist/blocks/limits.js +26 -0
  7. package/dist/blocks/placements.js +99 -0
  8. package/dist/blocks/schema.js +559 -0
  9. package/dist/blocks/validate.js +602 -0
  10. package/dist/config.js +31 -0
  11. package/dist/extension.d.ts +476 -9
  12. package/dist/harness/boot.js +8 -1
  13. package/dist/harness/event-bus.js +33 -0
  14. package/dist/harness/identity/users.js +3 -2
  15. package/dist/harness/loader.js +369 -13
  16. package/dist/harness/packages.js +71 -2
  17. package/dist/harness/seam-host.js +69 -2
  18. package/dist/harness/settings.js +5 -0
  19. package/dist/jobs/fire.js +11 -0
  20. package/dist/jobs/spec.js +19 -0
  21. package/dist/serve.js +29 -39
  22. package/dist/server/auth/pipeline.js +16 -1
  23. package/dist/server/conversation-ops.js +482 -10
  24. package/dist/server/gateway.js +122 -0
  25. package/dist/server/helpers.js +26 -0
  26. package/dist/server/host-bridge.js +141 -4
  27. package/dist/server/index.js +27 -0
  28. package/dist/server/notifications.js +6 -2
  29. package/dist/server/routes/conversations.js +37 -93
  30. package/dist/server/routes/events.js +39 -0
  31. package/dist/server/routes/ext-assets.js +84 -0
  32. package/dist/server/routes/extensions.js +31 -1
  33. package/dist/server/routes/files.js +38 -83
  34. package/dist/server/routes/meta.js +35 -1
  35. package/dist/server/routes/providers.js +5 -21
  36. package/dist/server/routes/submissions.js +36 -111
  37. package/dist/server/routes/surfaces.js +387 -0
  38. package/dist/server/routes/users.js +6 -1
  39. package/dist/server/session-meta.js +12 -6
  40. package/dist/server/shutdown.js +85 -0
  41. package/dist/server/surfaces/access-cache.js +43 -0
  42. package/dist/server/surfaces/dedup.js +67 -0
  43. package/dist/server/surfaces/instance.js +42 -0
  44. package/dist/server/surfaces/mime.js +73 -0
  45. package/dist/server/surfaces/rate-limit.js +63 -0
  46. package/dist/server/surfaces/service.js +493 -0
  47. package/dist/server/surfaces/watchers.js +94 -0
  48. package/node_modules/@sigrun/shared/dist/blocks/format.d.ts +28 -0
  49. package/node_modules/@sigrun/shared/dist/blocks/format.js +199 -0
  50. package/node_modules/@sigrun/shared/dist/blocks/icons.d.ts +12 -0
  51. package/node_modules/@sigrun/shared/dist/blocks/icons.js +64 -0
  52. package/node_modules/@sigrun/shared/dist/blocks/index.d.ts +25 -0
  53. package/node_modules/@sigrun/shared/dist/blocks/index.js +21 -0
  54. package/node_modules/@sigrun/shared/dist/blocks/instance.d.ts +29 -0
  55. package/node_modules/@sigrun/shared/dist/blocks/instance.js +51 -0
  56. package/node_modules/@sigrun/shared/dist/blocks/lttb.d.ts +16 -0
  57. package/node_modules/@sigrun/shared/dist/blocks/lttb.js +67 -0
  58. package/node_modules/@sigrun/shared/dist/blocks/ops.d.ts +44 -0
  59. package/node_modules/@sigrun/shared/dist/blocks/ops.js +294 -0
  60. package/node_modules/@sigrun/shared/dist/blocks/text.d.ts +43 -0
  61. package/node_modules/@sigrun/shared/dist/blocks/text.js +790 -0
  62. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +77 -0
  63. package/node_modules/@sigrun/shared/dist/blocks/transport.js +11 -0
  64. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +541 -0
  65. package/node_modules/@sigrun/shared/dist/blocks/types.js +17 -0
  66. package/node_modules/@sigrun/shared/dist/jobs.d.ts +9 -0
  67. package/node_modules/@sigrun/shared/dist/live.d.ts +71 -0
  68. package/node_modules/@sigrun/shared/dist/live.js +82 -0
  69. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +19 -3
  70. package/node_modules/@sigrun/shared/dist/settings.d.ts +7 -0
  71. package/node_modules/@sigrun/shared/dist/views.d.ts +81 -3
  72. package/node_modules/@sigrun/shared/package.json +10 -2
  73. package/package.json +9 -3
  74. package/webui/dist/assets/index-B4-GYqEb.js +50 -0
  75. package/webui/dist/index.html +1 -1
  76. package/webui/dist/assets/index-hVLvsizV.js +0 -50
@@ -273,6 +273,32 @@ export async function conversationEnv(opened, conversation) {
273
273
  }
274
274
  /** The upload read cap for `files/content` (binary-safe; 1 MiB). */
275
275
  export const FILE_CONTENT_MAX_BYTES = 1024 * 1024;
276
+ /**
277
+ * The served model catalog as picker entries (gateway C4b): the ONE
278
+ * construction behind `GET /v1/models` `.models` AND the host's
279
+ * `models.list()` — id/provider/modelId/name/contextWindow/maxTokens/
280
+ * reasoning/input plus the four rate fields (tiers dropped for v1, the
281
+ * picker doesn't use them — no unsound cast). Provider internals
282
+ * (baseUrl/headers/auth) never cross either surface.
283
+ */
284
+ export function listModels(opened) {
285
+ return opened.models.getModels().map((model) => ({
286
+ id: `${model.provider}/${model.id}`,
287
+ provider: model.provider,
288
+ modelId: model.id,
289
+ name: model.name,
290
+ contextWindow: model.contextWindow,
291
+ maxTokens: model.maxTokens,
292
+ reasoning: model.reasoning,
293
+ input: model.input,
294
+ cost: {
295
+ input: model.cost.input,
296
+ output: model.cost.output,
297
+ cacheRead: model.cost.cacheRead,
298
+ cacheWrite: model.cost.cacheWrite,
299
+ },
300
+ }));
301
+ }
276
302
  /**
277
303
  * One provider's auth readiness, derived from boot-retained state (spec
278
304
  * §6.4 + provider plugins Task 6): the same three-priority chain
@@ -18,9 +18,25 @@
18
18
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
19
19
  import { accessOf as accessOfShared, } from "@sigrun/shared/access";
20
20
  import { isSuperuser } from "./auth/principal.js";
21
- import { createConversationOp, listConversationsOp, readMetaOp, submitOp, } from "./conversation-ops.js";
21
+ import { abortOp, compactOp, configureOp, ConversationOpError, createConversationOp, forkOp, listConversationsOp, patchMetaOp, readFileOp, readMetaOp, submitOp, uploadOp, } from "./conversation-ops.js";
22
+ import { listModels } from "./helpers.js";
22
23
  import { SessionMetaDoc } from "./session-meta.js";
23
24
  import { serializeConversationView } from "./views.js";
25
+ /**
26
+ * The effective-admin projection of one directory user (gateway C8):
27
+ * host-side callers (chat commands, no request headers) see the STORED
28
+ * flag OR the persisted group grant, plus the raw `groupAdmin` as a
29
+ * definite boolean. The `/v1/users` wire keeps `admin` = the stored
30
+ * flag (the users-admin UI toggles that one); the two surfaces are
31
+ * deliberately different.
32
+ */
33
+ function effectiveAdminOf(record) {
34
+ return {
35
+ ...record,
36
+ admin: record.admin || record.groupAdmin === true,
37
+ groupAdmin: record.groupAdmin === true,
38
+ };
39
+ }
24
40
  /** Build the host bridge over one server context. */
25
41
  export function createHostBridge(ctx) {
26
42
  const { opened } = ctx;
@@ -93,16 +109,78 @@ export function createHostBridge(ctx) {
93
109
  await conversationOf(id);
94
110
  return await readMetaOp(opened, id);
95
111
  },
112
+ // Gateway C4a: the remaining conversation ops, each the SAME op
113
+ // its HTTP route runs — resolved through conversationOf (unknown id
114
+ // → the bridge's loud `conversation <id> not found`).
115
+ async abort(id) {
116
+ const conversation = await conversationOf(id);
117
+ // `/stop` on an idle chat is not an error (the plan's ruling): the
118
+ // op's 404 no_active_submission resolves instead — the HTTP route
119
+ // keeps its exact 404.
120
+ try {
121
+ await abortOp(ctx, conversation);
122
+ }
123
+ catch (error) {
124
+ if (error instanceof ConversationOpError &&
125
+ error.status === 404 &&
126
+ error.body.error === "no_active_submission") {
127
+ return;
128
+ }
129
+ throw error;
130
+ }
131
+ },
132
+ async configure(id, opts = {}) {
133
+ const conversation = await conversationOf(id);
134
+ // Thin over the op (host inputs are typed — the op still
135
+ // validates exactly like the route's body).
136
+ await configureOp(ctx, conversation, {
137
+ ...(opts.model === undefined ? {} : { model: opts.model }),
138
+ ...(opts.thinking === undefined ? {} : { thinking: opts.thinking }),
139
+ });
140
+ },
141
+ async compact(id) {
142
+ const conversation = await conversationOf(id);
143
+ await compactOp(ctx, conversation);
144
+ },
145
+ async fork(id, opts = {}) {
146
+ const conversation = await conversationOf(id);
147
+ const forked = await forkOp(ctx, conversation, {
148
+ ...(opts.at === undefined ? {} : { at: opts.at }),
149
+ ...(opts.owner === undefined ? {} : { owner: opts.owner }),
150
+ });
151
+ return forked.id;
152
+ },
153
+ async patchMeta(id, patch) {
154
+ const conversation = await conversationOf(id);
155
+ // No principal: host callers are trusted — the sharing gate is
156
+ // the HTTP route's own concern (the plan's ruling).
157
+ return await patchMetaOp(ctx, conversation, patch);
158
+ },
159
+ // Gateway C4b: the files routes' ops, each the SAME op its HTTP
160
+ // route runs — the upload routes' shared write core and the
161
+ // files/raw route's confinement + cap (the host sees the op's own
162
+ // error vocabulary: invalid_field name / invalid_path / not_found /
163
+ // too_large).
164
+ async upload(id, file) {
165
+ const conversation = await conversationOf(id);
166
+ return await uploadOp(ctx, conversation, file);
167
+ },
168
+ async readFile(id, path) {
169
+ const conversation = await conversationOf(id);
170
+ return await readFileOp(ctx, conversation, path);
171
+ },
96
172
  },
97
173
  users: {
98
174
  // The directory in wire form (emails are admin material; the
99
175
  // bridge is in-process server code, the same trust as the
100
- // admin-gated /v1/users routes).
176
+ // admin-gated /v1/users routes) — with EFFECTIVE admin (gateway
177
+ // C8): a persisted group grant counts for host-side checks.
101
178
  async list() {
102
- return opened.identity.users.list();
179
+ return opened.identity.users.list().map(effectiveAdminOf);
103
180
  },
104
181
  async get(user) {
105
- return opened.identity.users.get(user);
182
+ const record = opened.identity.users.get(user);
183
+ return record === undefined ? undefined : effectiveAdminOf(record);
106
184
  },
107
185
  },
108
186
  access: {
@@ -127,5 +205,64 @@ export function createHostBridge(ctx) {
127
205
  return meta?.owner;
128
206
  },
129
207
  },
208
+ // Gateway C4b: the model catalog — the SAME source function the
209
+ // `GET /v1/models` route serves (one construction, never two).
210
+ models: {
211
+ async list() {
212
+ return listModels(opened);
213
+ },
214
+ },
215
+ commands: {
216
+ // The merged catalog rows (`GET /v1/commands`' core + extension
217
+ // half — the live skill/template expand entries are the composer's
218
+ // per-request concern, not this surface's).
219
+ async list() {
220
+ return ctx.commandCatalog.commands;
221
+ },
222
+ // The invoke route's resolution, in-process: the runnable map
223
+ // (core names and dropped/collided losers throw), the principal
224
+ // built from the directory with EFFECTIVE admin and method "host",
225
+ // and the extension's seam host exactly as the route passes it. A
226
+ // throwing run PROPAGATES (the HTTP route maps 500 command_failed;
227
+ // the host caller owns its own handling).
228
+ async run(conversationId, name, args, opts) {
229
+ const merged = ctx.commandCatalog.runnable.get(name);
230
+ if (merged === undefined) {
231
+ throw new Error(`command_not_found: ${name}`);
232
+ }
233
+ const record = opened.identity.users.get(opts.user);
234
+ if (record === undefined) {
235
+ throw new Error(`unknown user "${opts.user}"`);
236
+ }
237
+ // Effective admin (gateway C8): the stored flag OR the persisted
238
+ // group grant — host-side code has no request headers to
239
+ // recompute one.
240
+ const principal = {
241
+ user: record.user,
242
+ ...(record.name === undefined ? {} : { name: record.name }),
243
+ ...(record.email === undefined ? {} : { email: record.email }),
244
+ admin: effectiveAdminOf(record).admin,
245
+ service: false,
246
+ method: "host",
247
+ };
248
+ const host = ctx.opened.seamHosts.get(merged.extension);
249
+ const result = await merged.run({
250
+ conversationId,
251
+ args,
252
+ principal,
253
+ host: host,
254
+ });
255
+ return result === undefined || result.notice === undefined
256
+ ? {}
257
+ : { notice: result.notice };
258
+ },
259
+ },
260
+ surfaces: {
261
+ // UI-blocks U3 Task 4 (spec §7.3): the same bumpRev the actions
262
+ // route takes — the rev advance and the hub event are one step.
263
+ changed(slug, id, key, update) {
264
+ ctx.surfaces.bumpRev(slug, id, key, update);
265
+ },
266
+ },
130
267
  };
131
268
  }
@@ -46,6 +46,7 @@ import { registerConversationRoutes } from "./routes/conversations.js";
46
46
  import { registerEventRoutes } from "./routes/events.js";
47
47
  import { registerExtensionRoutes as registerExtensionSettingsRoutes } from "./routes/extensions.js";
48
48
  import { registerExtensionRoutes as mountExtensionRoutes } from "./routes/extension-routes.js";
49
+ import { registerExtAssetRoutes } from "./routes/ext-assets.js";
49
50
  import { registerFileRoutes } from "./routes/files.js";
50
51
  import { registerJobRoutes } from "./routes/jobs.js";
51
52
  import { registerInteractionRoutes } from "./routes/interactions.js";
@@ -58,6 +59,8 @@ import { registerSettingsRoutes } from "./routes/settings.js";
58
59
  import { registerStreamRoutes } from "./routes/stream.js";
59
60
  import { startNotifications } from "./notifications.js";
60
61
  import { registerSubmissionRoutes } from "./routes/submissions.js";
62
+ import { registerSurfaceRoutes } from "./routes/surfaces.js";
63
+ import { SurfaceService } from "./surfaces/service.js";
61
64
  import { registerUserRoutes } from "./routes/users.js";
62
65
  import { resolveWebuiBundle, serveWebuiBundle, } from "./static.js";
63
66
  /**
@@ -164,6 +167,20 @@ export async function createServer(opened, config, contributedBundles = [], opti
164
167
  // OUTSIDE before app.close() — a decoration is the typed, discoverable
165
168
  // way to hand that handle up without widening ServerContext's consumers.
166
169
  app.decorate("sse", sse);
170
+ const surfacesService = new SurfaceService(opened.extensionSurfaces, {
171
+ maxUploadBytes: config.maxUploadBytes,
172
+ // Task 4: bumpRev (actions AND the host seam's changed()) fans the
173
+ // surface.changed event out to the hub; the events route's
174
+ // visibility case delivers it to watching streams only.
175
+ publishChanged: (event) => {
176
+ // SAFETY: the hub is typed on the wire union; surface.changed's
177
+ // hub copy carries the internal `streams` field the events route
178
+ // reads and strips before delivery (spec §7.3's routing can never
179
+ // reverse the hashed k).
180
+ opened.events.publish(event);
181
+ },
182
+ });
183
+ app.decorate("surfaces", surfacesService);
167
184
  const ctx = {
168
185
  opened,
169
186
  config,
@@ -174,7 +191,9 @@ export async function createServer(opened, config, contributedBundles = [], opti
174
191
  methods,
175
192
  env,
176
193
  watchersByConversation: new Map(),
194
+ surfaces: surfacesService,
177
195
  sse,
196
+ ...(options?.gateway === undefined ? {} : { gateway: options.gateway }),
178
197
  };
179
198
  // The host bridge (W5 Task 5, spec §9.5/§9.7): ONE bridge over this
180
199
  // server's context, bound onto every seam host — the extension-facing
@@ -217,6 +236,14 @@ export async function createServer(opened, config, contributedBundles = [], opti
217
236
  // web-standard Request/Response adapter — per-route auth rides the
218
237
  // pipeline's stage-4b public skip and this module's admin check.
219
238
  mountExtensionRoutes(app, ctx);
239
+ // The extension surface routes (UI-blocks U3 Task 2, spec §7.2):
240
+ // render/data/actions under the reserved `surfaces/` prefix, over the
241
+ // shared SurfaceService's access cache and revision state.
242
+ registerSurfaceRoutes(app, ctx);
243
+ // The extension asset route (UI-blocks U3 Task 3, spec §11.1/§7.2):
244
+ // `/v1/ext/<slug>/assets/*` serves the package's declared
245
+ // `sigrun.assets` dir, realpath-contained, sniffed content types.
246
+ registerExtAssetRoutes(app, ctx);
220
247
  // The jobs routes (W4 Task 4, spec §8.4): the access-scoped CRUD +
221
248
  // run-now + history surface over `OpenedHarness.jobs` (Task 3's
222
249
  // service). Hidden-is-404 per §5.9; `file:`/`ext:` ids are immutable
@@ -73,6 +73,7 @@ export function startNotifications(ctx) {
73
73
  type: base.type,
74
74
  conversationId: base.conversationId,
75
75
  jobId: base.jobId,
76
+ ...(base.callId === undefined ? {} : { callId: base.callId }),
76
77
  user,
77
78
  title: base.title,
78
79
  body: base.body,
@@ -100,7 +101,7 @@ export function startNotifications(ctx) {
100
101
  }
101
102
  };
102
103
  /** interaction.pending → the owner plus every directory user with write. */
103
- const handleInteraction = async (conversationId) => {
104
+ const handleInteraction = async (conversationId, callId) => {
104
105
  const meta = await metaOf(conversationId);
105
106
  const owner = meta?.owner;
106
107
  const sharing = meta?.sharing;
@@ -116,6 +117,7 @@ export function startNotifications(ctx) {
116
117
  type: "interaction.pending",
117
118
  conversationId,
118
119
  jobId: undefined,
120
+ callId,
119
121
  title: "Sigrun needs you",
120
122
  body: displayName(meta, conversationId),
121
123
  }, recipients);
@@ -136,6 +138,7 @@ export function startNotifications(ctx) {
136
138
  type: "run.settled",
137
139
  conversationId,
138
140
  jobId: undefined,
141
+ callId: undefined,
139
142
  title: "Reply ready",
140
143
  body: displayName(meta, conversationId),
141
144
  }, [meta?.owner]);
@@ -159,6 +162,7 @@ export function startNotifications(ctx) {
159
162
  type: "job.failed",
160
163
  conversationId: run.conversationId,
161
164
  jobId: job.id,
165
+ callId: undefined,
162
166
  title: `Job ${job.name} failed`,
163
167
  // The observer always settles a failed run with its error
164
168
  // text; the fallback only covers a hand-corrupted record.
@@ -170,7 +174,7 @@ export function startNotifications(ctx) {
170
174
  // the bus's bounded side lane (resolution + delivery alike never
171
175
  // run inline here).
172
176
  if (event.type === "interaction.pending") {
173
- void bus.runBounded("notifications interaction.pending", () => handleInteraction(event.conversationId));
177
+ void bus.runBounded("notifications interaction.pending", () => handleInteraction(event.conversationId, event.callId));
174
178
  }
175
179
  else if (event.type === "run.settled") {
176
180
  void bus.runBounded("notifications run.settled", () => handleRunSettled(event.conversationId));
@@ -8,10 +8,9 @@
8
8
  */
9
9
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
10
10
  import { isSuperuser } from "../auth/principal.js";
11
- import { createConversationOp, listConversationsOp, readMetaOp, } from "../conversation-ops.js";
11
+ import { ConversationOpError, createConversationOp, forkOp, listConversationsOp, patchMetaOp, readMetaOp, } from "../conversation-ops.js";
12
12
  import { loadConversation } from "../conversation-handler.js";
13
13
  import { ID_PARAMS_SCHEMA, readView, stampSessionOwner, storeAgentChange, } from "../helpers.js";
14
- import { applySessionMetaPatch, DEFAULT_SESSION_META, SessionMetaDoc, validateSessionMetaPatch, } from "../session-meta.js";
15
14
  import { JSONL_CONTENT_TYPE, } from "../views.js";
16
15
  import { base64BodyLimit } from "../../config.js";
17
16
  /** Register the conversation lifecycle + session-meta routes. */
@@ -82,16 +81,16 @@ export function registerConversationRoutes(app, ctx) {
82
81
  return readView(conversation);
83
82
  });
84
83
  // Fork: a history fork at one visible entry (spec §5 session
85
- // management). `at` is the LAST entry id the fork keeps; absent = the
86
- // newest entry (resolved through the entries query — never 0, which is
87
- // not a real entry id). The fork inherits the parent's agent model and
88
- // the same ownerless ownership the create route uses; the response is
89
- // the create shape: 201 `{id, view}`.
84
+ // management). The shared fork op (gateway C4a) is the SAME code path
85
+ // the host bridge drives — `at` validation against the parent history,
86
+ // the empty_conversation guard, the agent-model inherit, and the fork
87
+ // meta commit (owner reset + "<source> (fork)" autoName +
88
+ // lastActivityAt) live in the op; this route owns the body shape and
89
+ // the 201. The response is the create shape: `{id, view}`.
90
90
  app.post("/v1/conversations/:id/fork", {
91
91
  schema: { params: ID_PARAMS_SCHEMA },
92
92
  preHandler: loadConversation(ctx, "write"),
93
93
  }, async (request, reply) => {
94
- const conversation = request.conversation;
95
94
  // Absent body is valid (`at` defaults to the newest entry); the
96
95
  // object may carry ONLY `at` — the compact/configure convention.
97
96
  const body = request.body;
@@ -107,73 +106,30 @@ export function registerConversationRoutes(app, ctx) {
107
106
  .code(400)
108
107
  .send({ error: "invalid_field", field: unknownKey });
109
108
  }
110
- if (record.at !== undefined &&
111
- typeof record.at !== "number") {
109
+ at = record.at;
110
+ if (at !== undefined && typeof at !== "number") {
112
111
  return await reply
113
112
  .code(400)
114
113
  .send({ error: "invalid_field", field: "at" });
115
114
  }
116
- at = record.at;
117
115
  }
118
- if (at === undefined) {
119
- // The entries query is newest-first: the first item of the first
120
- // page is the newest entry. An empty page means there is nothing
121
- // to fork — the parent conversation has no history at all.
122
- const page = await conversation.entries({}, 1, undefined, BACKGROUND_CONTEXT);
123
- if (page.items.length === 0) {
124
- return await reply.code(400).send({ error: "empty_conversation" });
125
- }
126
- at = page.items[0].id;
116
+ try {
117
+ // The route passes the principal's user as the fork's owner (the
118
+ // host passes its own opts.owner; undefined = the key removed).
119
+ const forked = await forkOp(ctx, request.conversation, {
120
+ ...(at === undefined ? {} : { at }),
121
+ ...(request.principal.user === undefined
122
+ ? {}
123
+ : { owner: request.principal.user }),
124
+ });
125
+ return await reply.code(201).send(forked);
127
126
  }
128
- else {
129
- // Validate `at` against the parent's own history: `fork()` accepts
130
- // any EntryId, but an id this conversation never had is a client
131
- // error, not a silent empty fork.
132
- const page = await conversation.entries({ maxEntryId: at }, 1, undefined, BACKGROUND_CONTEXT);
133
- const newest = page.items[0];
134
- if (newest === undefined || newest.id !== at) {
135
- return await reply
136
- .code(400)
137
- .send({ error: "invalid_field", field: "at" });
127
+ catch (error) {
128
+ if (error instanceof ConversationOpError) {
129
+ return await reply.code(error.status).send(error.body);
138
130
  }
131
+ throw error;
139
132
  }
140
- // The fork inherits the parent's agent model — read the resolved
141
- // agent, fall back to the boot default like create.
142
- const agent = await conversation.agent(BACKGROUND_CONTEXT);
143
- const fork = await conversation.fork(at, {
144
- ownership: { kind: "ownerless" },
145
- agent: { model: agent.model ?? opened.defaultModel },
146
- }, BACKGROUND_CONTEXT);
147
- // The fork's sidebar identity: the copied meta doc
148
- // carries the source's name/autoName and the namer's attempt count;
149
- // ONE commit drops all three (a fork is a new conversation, not the
150
- // source's name — and it starts naming-fresh: inheriting an exhausted
151
- // attempt count would make the fork forever unnameable), re-seeds
152
- // it with "<source display name> (fork)"
153
- // as its AUTO name when the source had one, and stamps activity so
154
- // the fresh fork sorts where it happened.
155
- // §5.9 (Review Focus 3): the doc copied the source's `owner` and
156
- // `sharing` (fork: "current") — the SAME commit resets them: a fork
157
- // is the FORKER's private copy (owner = the caller, sharing deleted;
158
- // a header-less caller means no owner at all — the key is removed).
159
- const sourceMeta = await opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT);
160
- const sourceName = sourceMeta?.name ?? sourceMeta?.autoName;
161
- await fork.commit(async (tx) => {
162
- const draft = await tx.doc(SessionMetaDoc, fork.id);
163
- delete draft.name;
164
- delete draft.autoName;
165
- delete draft.autoNameAttempts;
166
- delete draft.sharing;
167
- if (request.principal.user === undefined)
168
- delete draft.owner;
169
- else
170
- draft.owner = request.principal.user;
171
- if (sourceName !== undefined)
172
- draft.autoName = `${sourceName} (fork)`;
173
- draft.lastActivityAt = opened.now().toISOString();
174
- }, BACKGROUND_CONTEXT);
175
- const view = await readView(fork);
176
- return await reply.code(201).send({ id: fork.id, view });
177
133
  });
178
134
  // Export: the conversation's history as JSONL — pi's portable
179
135
  // format. One entry JSON object per line (the same per-entry
@@ -308,34 +264,22 @@ export function registerConversationRoutes(app, ctx) {
308
264
  schema: { params: ID_PARAMS_SCHEMA },
309
265
  preHandler: loadConversation(ctx, "write"),
310
266
  }, async (request, reply) => {
311
- const conversation = request.conversation;
312
- // Validate FIRST — a rejected patch never touches the doc.
313
- const validated = validateSessionMetaPatch(request.body);
314
- if ("error" in validated) {
315
- return await reply.code(400).send(validated);
267
+ // The shared patch op (gateway C4a) is the SAME code path the
268
+ // host bridge drives — validate-first, the owner-or-superuser
269
+ // sharing gate (the route passes its principal; the host passes
270
+ // none, trusted), the one-commit merge, and the post-commit
271
+ // snapshot response all live in the op. This route owns the HTTP
272
+ // mapping only.
273
+ try {
274
+ return await patchMetaOp(ctx, request.conversation, request.body, {
275
+ principal: request.principal,
276
+ });
316
277
  }
317
- // §5.9: `sharing` additionally requires the OWNER or a superuser —
318
- // write access (granted by a share) must not let a recipient
319
- // re-share the resource.
320
- if (validated.sharing !== undefined) {
321
- const meta = await opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT);
322
- const isOwner = request.principal.user !== undefined &&
323
- request.principal.user === meta?.owner;
324
- if (!isOwner && !isSuperuser(request.principal)) {
325
- return await reply.code(403).send({ error: "forbidden" });
278
+ catch (error) {
279
+ if (error instanceof ConversationOpError) {
280
+ return await reply.code(error.status).send(error.body);
326
281
  }
282
+ throw error;
327
283
  }
328
- // Read-modify-write inside ONE commit: tx.doc materializes the doc
329
- // (initial()) when absent and the patch mutates the tracked draft.
330
- await conversation.commit(async (tx) => {
331
- const draft = await tx.doc(SessionMetaDoc, conversation.id);
332
- applySessionMetaPatch(draft, validated);
333
- }, BACKGROUND_CONTEXT);
334
- // The response is a FRESH snapshot, not a draft projection:
335
- // reading a newly-SET nested draft value (`sharing`, §5.9) trips
336
- // chord's settled-overlay rule, so the reply must never read the
337
- // mutated draft — a post-commit snapshot is exactly what was
338
- // stored.
339
- return ((await opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT)) ?? DEFAULT_SESSION_META);
340
284
  });
341
285
  }
@@ -29,6 +29,7 @@
29
29
  * read-side posture.
30
30
  */
31
31
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
32
+ import { randomUUID } from "node:crypto";
32
33
  import { accessOf } from "@sigrun/shared/access";
33
34
  import { SessionMetaDoc } from "../session-meta.js";
34
35
  /** The heartbeat cadence (the /stream pattern: proxy idle-timeout survival). */
@@ -53,6 +54,17 @@ export function registerEventRoutes(app, ctx) {
53
54
  const send = (event) => {
54
55
  raw.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
55
56
  };
57
+ // The W3 amendment (spec §7.3): the FIRST frame names this
58
+ // connection — `event: hello` / `data: {"streamId": "<uuid>"}`,
59
+ // written BEFORE the hub subscription. Channels send the id as
60
+ // `X-Sigrun-Stream` on surface/data fetches (the watcher registry's
61
+ // entry); after a reconnect (a NEW streamId) they re-fetch every
62
+ // visible surface, which re-registers the entries. The hello is a
63
+ // CONNECTION-LOCAL frame (channels ignore unknown event types), so
64
+ // it is deliberately NOT a HostEventWire member — the exhaustive
65
+ // visibility switch covers only principal-facing hub events.
66
+ const streamId = randomUUID();
67
+ send({ type: "hello", streamId }); // SAFETY: hello is a connection-local frame, not a hub event — the wire writer is typed on HostEventWire only because every OTHER frame is one.
56
68
  let finished = false;
57
69
  const heartbeat = setInterval(() => {
58
70
  raw.write(": ping\n\n");
@@ -117,6 +129,30 @@ export function registerEventRoutes(app, ctx) {
117
129
  // principal-facing wire event.
118
130
  case "taskSettled":
119
131
  return undefined;
132
+ // UI-blocks U3 Task 4 (spec §7.3): delivered to a watching
133
+ // stream ONLY. The hub copy is the service's internal event
134
+ // (SurfaceChangedInternal) which carries the resolved stream
135
+ // list — the service computed it off the PLAIN key at publish
136
+ // time (the registry can never reverse the wire's hashed `k`).
137
+ // The DELIVERED frame is the wire shape (the internal field
138
+ // stripped — the stream list is routing metadata, never wire).
139
+ case "surface.changed": {
140
+ // SAFETY: the hub carries the service's internal event (the
141
+ // wire shape plus the publish-time stream list); every
142
+ // surface.changed the hub sees comes from bumpRev, which
143
+ // always attaches `streams`.
144
+ const internal = event;
145
+ if (!internal.streams.includes(streamId))
146
+ return undefined;
147
+ return {
148
+ type: "surface.changed",
149
+ slug: event.slug,
150
+ id: event.id,
151
+ k: event.k,
152
+ rev: event.rev,
153
+ treeRev: event.treeRev,
154
+ };
155
+ }
120
156
  // An unknown member is NEVER delivered — deny by construction,
121
157
  // not by enumerating the leak shapes. The satisfies check keeps
122
158
  // this arm exhaustive: a new HostEventWire member without a
@@ -146,6 +182,9 @@ export function registerEventRoutes(app, ctx) {
146
182
  finished = true;
147
183
  clearInterval(heartbeat);
148
184
  unsubscribe();
185
+ // The watcher registry's stream-close rule (spec §7.3): every
186
+ // entry this stream held drops with it.
187
+ ctx.surfaces.watcherRegistry.dropStream(streamId);
149
188
  raw.end();
150
189
  };
151
190
  // Client disconnect: `response` close (socket termination) — NOT
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Extension asset routes (UI-blocks U3 Task 3, spec §11.1/§7.2):
3
+ * `GET /v1/ext/<slug>/assets/<path>` serves files from the package's
4
+ * declared `sigrun.assets` directory to every signed-in user (assets
5
+ * are public to signed-in users, §11.1). The Content-Type is sniffed
6
+ * from the bytes (never the filename) and every response carries
7
+ * `X-Content-Type-Options: nosniff`.
8
+ *
9
+ * Containment: the requested path resolves against the assets dir and
10
+ * BOTH the dir and the target are `realpath`ed — a `..` segment or a
11
+ * symlink out of the dir answers 404, indistinguishable from a missing
12
+ * file. A package with no declared `sigrun.assets` answers 404 for
13
+ * every asset request.
14
+ */
15
+ import { open, realpath } from "node:fs/promises";
16
+ import { join, relative, sep } from "node:path";
17
+ import { sniffMime } from "../surfaces/mime.js";
18
+ /** One read's chunk size for the sniff buffer + small files. */
19
+ const READ_CHUNK_BYTES = 1024 * 1024;
20
+ /**
21
+ * Resolve one requested asset path inside the package's assets dir:
22
+ * the realpath'd absolute file, or `undefined` when the request escapes
23
+ * the dir (a `..` segment or a symlink out) or the file is missing.
24
+ */
25
+ async function resolveContained(assetsDir, path) {
26
+ let realDir;
27
+ try {
28
+ realDir = await realpath(assetsDir);
29
+ }
30
+ catch {
31
+ return undefined; // the declared dir doesn't exist
32
+ }
33
+ const candidate = join(realDir, path);
34
+ let realFile;
35
+ try {
36
+ realFile = await realpath(candidate);
37
+ }
38
+ catch {
39
+ return undefined; // missing file (or a dangling symlink)
40
+ }
41
+ const rel = relative(realDir, realFile);
42
+ if (rel === "" || rel.startsWith("..") || rel.includes(`..${sep}`)) {
43
+ return undefined;
44
+ }
45
+ return realFile;
46
+ }
47
+ /** Register the asset route over the shared context's bindings. */
48
+ export function registerExtAssetRoutes(app, ctx) {
49
+ app.get("/v1/ext/:slug/assets/*", async (request, reply) => {
50
+ const { slug } = request.params;
51
+ const path = request.params["*"];
52
+ const binding = ctx.opened.extensionSurfaces.find((entry) => entry.extension === slug);
53
+ // Unknown slug, or a package with no declared assets dir: 404.
54
+ if (binding?.assetsDir === undefined) {
55
+ return await reply.code(404).send({ error: "asset_not_found" });
56
+ }
57
+ const file = await resolveContained(binding.assetsDir, path);
58
+ if (file === undefined) {
59
+ return await reply.code(404).send({ error: "asset_not_found" });
60
+ }
61
+ const handle = await open(file, "r");
62
+ try {
63
+ const stat = await handle.stat();
64
+ if (!stat.isFile()) {
65
+ return await reply.code(404).send({ error: "asset_not_found" });
66
+ }
67
+ const bytes = Buffer.alloc(stat.size);
68
+ let offset = 0;
69
+ while (offset < bytes.byteLength) {
70
+ const { bytesRead } = await handle.read(bytes, offset, Math.min(READ_CHUNK_BYTES, bytes.byteLength - offset), offset);
71
+ if (bytesRead === 0)
72
+ break;
73
+ offset += bytesRead;
74
+ }
75
+ return await reply
76
+ .header("content-type", sniffMime(new Uint8Array(bytes)))
77
+ .header("x-content-type-options", "nosniff")
78
+ .send(bytes);
79
+ }
80
+ finally {
81
+ await handle.close();
82
+ }
83
+ });
84
+ }