@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
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * - `GET /v1/extensions` — one entry per loaded outcome with `status`,
6
6
  * `error?`, the manifest `version?`, the declared
7
- * `interactionKinds`/`ambientKeys`, and the idle-worker projection.
7
+ * `interactionKinds`/`ambientKeys`, the idle-worker projection, and
8
+ * the contributed channel ids (gateway C1, `channels?`).
8
9
  * - `GET`/`PUT /v1/extensions/:slug/settings` (PUT admin) — the
9
10
  * `settings/<slug>.json` object verbatim. Values an extension reads at
10
11
  * factory time apply at RESTART; per-run reads see the write live.
@@ -40,6 +41,7 @@ export function registerExtensionRoutes(app, ctx) {
40
41
  const kinds = opened.seamKinds.get(outcome.name);
41
42
  const workers = workerStatuses.get(outcome.name);
42
43
  const routes = opened.extensionRoutes.find((binding) => binding.extension === outcome.name);
44
+ const surfaces = opened.extensionSurfaces.find((binding) => binding.extension === outcome.name);
43
45
  return {
44
46
  name: outcome.name,
45
47
  status: outcome.status,
@@ -48,6 +50,18 @@ export function registerExtensionRoutes(app, ctx) {
48
50
  ? {}
49
51
  : { version: outcome.version }),
50
52
  ...(outcome.builtin === true ? { builtin: true } : {}),
53
+ ...(outcome.channels === undefined
54
+ ? {}
55
+ : { channels: [...outcome.channels] }),
56
+ // Gateway C2: a channel contributor with NO loaded engine is
57
+ // INERT — its adapters never start (the boot warning says so);
58
+ // the row says which surface is missing. Only OK rows mark it
59
+ // (a quarantined loader's `channels` never landed anyway).
60
+ ...(outcome.channels !== undefined &&
61
+ outcome.status === "ok" &&
62
+ opened.gatewayEngine === undefined
63
+ ? { inert: "no gateway" }
64
+ : {}),
51
65
  interactionKinds: [...(kinds?.interaction ?? [])],
52
66
  ambientKeys: [...(kinds?.ambient ?? [])],
53
67
  workers: workers ?? [],
@@ -56,6 +70,22 @@ export function registerExtensionRoutes(app, ctx) {
56
70
  path: route.path,
57
71
  auth: route.auth,
58
72
  })) ?? [],
73
+ // Declared surfaces (U3 Task 1, spec §7.1): the flattened view +
74
+ // panel rows the channels register their rail/panel entries from —
75
+ // SPARSE (absent when the extension declared none).
76
+ ...(surfaces === undefined
77
+ ? {}
78
+ : {
79
+ surfaces: surfaces.defs.map((def) => ({
80
+ id: def.id,
81
+ title: def.title,
82
+ icon: def.icon,
83
+ placement: def.placement,
84
+ ...(def.sharedData === true
85
+ ? { sharedData: true }
86
+ : {}),
87
+ })),
88
+ }),
59
89
  };
60
90
  });
61
91
  // Replaced built-ins (Task 2 ruling 5): one row each, under the slug
@@ -9,25 +9,13 @@
9
9
  * all at once.
10
10
  */
11
11
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
12
- import { extname } from "node:path";
13
12
  import { loadConversation } from "../conversation-handler.js";
14
13
  import { isConfinedPath } from "../content-parts.js";
15
14
  import { base64BodyLimit } from "../../config.js";
15
+ import { ConversationOpError, readFileMetaOp, uploadOp, validUploadName, } from "../conversation-ops.js";
16
16
  import { ID_PARAMS_SCHEMA, FILE_CONTENT_MAX_BYTES, conversationEnv, decodeBase64Strict, } from "../helpers.js";
17
- /**
18
- * The one upload-name rule, shared by the base64 POST body's `name` and
19
- * the raw PUT's `:name` param: no `/` or `\\`, not `.` or `..`, non-empty.
20
- * The PUT checks it AFTER Fastify decodes the percent-encoded param
21
- * (`..%2Fx` arrives as `../x`, `%2e%2e` as `..`) — the check runs on the
22
- * decoded value, never the raw path text.
23
- */
24
- function validUploadName(name) {
25
- return (name.length > 0 &&
26
- !name.includes("/") &&
27
- !name.includes("\\") &&
28
- name !== "." &&
29
- name !== "..");
30
- }
17
+ /** The raw read's streaming chunk size (1 MiB): bounds reader memory. */
18
+ const RAW_READ_CHUNK_BYTES = 1024 * 1024;
31
19
  /** The `:id` + `:name` params of the raw upload route. */
32
20
  const UPLOAD_PARAMS_SCHEMA = {
33
21
  type: "object",
@@ -35,30 +23,6 @@ const UPLOAD_PARAMS_SCHEMA = {
35
23
  required: ["id", "name"],
36
24
  additionalProperties: false,
37
25
  };
38
- /**
39
- * The extension→mime map for the raw file read (spec §7.8): the common
40
- * workspace-file extensions a client renders inline. A file whose
41
- * extension is absent from the map serves `application/octet-stream`.
42
- * The KEY is the LOWERCASE extension including the dot (the lookup
43
- * lowercases, so `Photo.PNG` maps too).
44
- */
45
- const MIME_BY_EXTENSION = new Map([
46
- [".png", "image/png"],
47
- [".jpg", "image/jpeg"],
48
- [".jpeg", "image/jpeg"],
49
- [".gif", "image/gif"],
50
- [".webp", "image/webp"],
51
- [".svg", "image/svg+xml"],
52
- [".pdf", "application/pdf"],
53
- [".txt", "text/plain"],
54
- [".md", "text/markdown"],
55
- [".json", "application/json"],
56
- [".html", "text/html"],
57
- [".css", "text/css"],
58
- [".js", "text/javascript"],
59
- ]);
60
- /** The raw read's streaming chunk size (1 MiB): bounds reader memory. */
61
- const RAW_READ_CHUNK_BYTES = 1024 * 1024;
62
26
  /** Register the upload/listing/content workspace file routes. */
63
27
  export function registerFileRoutes(app, ctx) {
64
28
  const { opened, config } = ctx;
@@ -85,6 +49,10 @@ export function registerFileRoutes(app, ctx) {
85
49
  }, async (request, reply) => {
86
50
  const conversation = request.conversation;
87
51
  const { name } = request.params;
52
+ // The route pre-validates to keep its exact wire body
53
+ // (`invalid_body`); the shared op's own `invalid_field` check is
54
+ // unreachable here by construction (the gateway C4b ruling: the op
55
+ // is the one validator, the routes keep byte-identical responses).
88
56
  if (!validUploadName(name)) {
89
57
  return await reply.code(400).send({ error: "invalid_body" });
90
58
  }
@@ -93,16 +61,7 @@ export function registerFileRoutes(app, ctx) {
93
61
  const bytes = request.body === undefined
94
62
  ? new Uint8Array(0)
95
63
  : new Uint8Array(request.body);
96
- // Same namespace as the base64 route: v1 namespaces uploads per
97
- // conversation, and the returned path is exactly what a content
98
- // part takes.
99
- const path = `uploads/${conversation.id}/${name}`;
100
- const env = await conversationEnv(opened, conversation);
101
- const written = await env.writeFile(path, bytes, BACKGROUND_CONTEXT);
102
- if (!written.ok) {
103
- // A failed upload is a server-side fault: fail loud.
104
- throw written.error;
105
- }
64
+ const { path } = await uploadOp(ctx, conversation, { name, bytes });
106
65
  return await reply.code(201).send({ path });
107
66
  });
108
67
  // Uploads (base64, one more release): materialize a base64 body into
@@ -135,17 +94,13 @@ export function registerFileRoutes(app, ctx) {
135
94
  if (bytes === undefined) {
136
95
  return await reply.code(400).send({ error: "invalid_body" });
137
96
  }
138
- // v1 namespaces uploads per conversation: workspaces are static and
139
- // shared in v1, so un-namespaced names would clobber across
140
- // conversations. The returned path is exactly what a content part
141
- // takes.
142
- const path = `uploads/${conversation.id}/${name}`;
143
- const env = await conversationEnv(opened, conversation);
144
- const written = await env.writeFile(path, new Uint8Array(bytes), BACKGROUND_CONTEXT);
145
- if (!written.ok) {
146
- // A failed upload is a server-side fault: fail loud.
147
- throw written.error;
148
- }
97
+ // The shared op owns the pathing + write (same namespace as the
98
+ // raw route: `uploads/<conversationId>/<name>`, exactly what a
99
+ // content part takes).
100
+ const { path } = await uploadOp(ctx, conversation, {
101
+ name,
102
+ bytes: new Uint8Array(bytes),
103
+ });
149
104
  return await reply.code(201).send({ path });
150
105
  });
151
106
  // Files: read-only workspace listing through the conversation env.
@@ -245,27 +200,28 @@ export function registerFileRoutes(app, ctx) {
245
200
  const conversation = request.conversation;
246
201
  const query = request.query;
247
202
  const path = query.path ?? "";
248
- if (!isConfinedPath(path) || path.length === 0 || path === ".") {
249
- return await reply.code(400).send({ error: "invalid_path" });
203
+ // The shared raw-read pre-checks (gateway C4b): confinement,
204
+ // not_found, directory, and the cap — the SAME vocabulary this
205
+ // route served inline; only the cap's BODY differs (the route's
206
+ // `file_too_large` + `maxBytes`, not the op's `too_large`), mapped
207
+ // here so the wire stays byte-identical.
208
+ let meta;
209
+ try {
210
+ meta = await readFileMetaOp(ctx, conversation, path);
250
211
  }
251
- const env = await conversationEnv(opened, conversation);
252
- const info = await env.fileInfo(path, BACKGROUND_CONTEXT);
253
- if (!info.ok) {
254
- if (info.error.code === "not_found") {
255
- return await reply.code(404).send({ error: "not_found" });
212
+ catch (error) {
213
+ if (error instanceof ConversationOpError) {
214
+ if (error.status === 413 && error.body.error === "too_large") {
215
+ return await reply.code(413).send({
216
+ error: "file_too_large",
217
+ maxBytes: config.maxFileReadBytes,
218
+ });
219
+ }
220
+ return await reply.code(error.status).send(error.body);
256
221
  }
257
- throw info.error;
258
- }
259
- if (info.value.kind === "directory") {
260
- return await reply.code(400).send({ error: "invalid_path" });
261
- }
262
- const maxBytes = config.maxFileReadBytes;
263
- if (info.value.size > maxBytes) {
264
- return await reply
265
- .code(413)
266
- .send({ error: "file_too_large", maxBytes });
222
+ throw error;
267
223
  }
268
- const readerOpened = await env.openBinaryReader(path, undefined, BACKGROUND_CONTEXT);
224
+ const readerOpened = await meta.env.openBinaryReader(path, undefined, BACKGROUND_CONTEXT);
269
225
  if (!readerOpened.ok) {
270
226
  if (readerOpened.error.code === "not_found") {
271
227
  return await reply.code(404).send({ error: "not_found" });
@@ -281,12 +237,11 @@ export function registerFileRoutes(app, ctx) {
281
237
  raw.on("error", () => { });
282
238
  try {
283
239
  raw.writeHead(200, {
284
- "content-type": MIME_BY_EXTENSION.get(extname(path).toLowerCase()) ??
285
- "application/octet-stream",
286
- "content-length": String(info.value.size),
240
+ "content-type": meta.mime,
241
+ "content-length": String(meta.size),
287
242
  });
288
- for (let offset = 0; offset < info.value.size; offset += RAW_READ_CHUNK_BYTES) {
289
- const chunk = await reader.read(offset, Math.min(RAW_READ_CHUNK_BYTES, info.value.size - offset), BACKGROUND_CONTEXT);
243
+ for (let offset = 0; offset < meta.size; offset += RAW_READ_CHUNK_BYTES) {
244
+ const chunk = await reader.read(offset, Math.min(RAW_READ_CHUNK_BYTES, meta.size - offset), BACKGROUND_CONTEXT);
290
245
  if (!chunk.ok)
291
246
  throw chunk.error;
292
247
  raw.write(Buffer.from(chunk.value));
@@ -41,6 +41,7 @@ export function registerMetaRoutes(app, ctx) {
41
41
  schemaVersion: SCHEMA_VERSION,
42
42
  extensions: opened.extensionOutcomes.map((outcome) => {
43
43
  const workers = workerStatuses.get(outcome.name);
44
+ const surfaces = opened.extensionSurfaces.find((binding) => binding.extension === outcome.name);
44
45
  return {
45
46
  name: outcome.name,
46
47
  status: outcome.status,
@@ -48,6 +49,28 @@ export function registerMetaRoutes(app, ctx) {
48
49
  ...(workers === undefined || workers.length === 0
49
50
  ? {}
50
51
  : { workers }),
52
+ // The declared surfaces (UI-blocks U3 Task 5): the channels'
53
+ // rail/panel registration reads the DECLARATION here (never a
54
+ // second fetch); access-none filtering stays render-time.
55
+ ...(surfaces === undefined
56
+ ? {}
57
+ : {
58
+ surfaces: surfaces.defs.map((def) => ({
59
+ id: def.id,
60
+ title: def.title,
61
+ icon: def.icon,
62
+ placement: def.placement,
63
+ // The refresh cadence (§7.1): the channels'
64
+ // visible-surface refetch intervals read it here
65
+ // (never a second fetch).
66
+ ...(def.refreshMs === undefined
67
+ ? {}
68
+ : { refreshMs: def.refreshMs }),
69
+ // The §7.3 shared-data flag: channels refetch
70
+ // fully when a since fetch answers ops-only.
71
+ ...(def.sharedData === true ? { sharedData: true } : {}),
72
+ })),
73
+ }),
51
74
  };
52
75
  }),
53
76
  // §5 health: pure aggregation over boot-retained §3 producers —
@@ -56,11 +79,19 @@ export function registerMetaRoutes(app, ctx) {
56
79
  // double-reported here), providers aggregated across
57
80
  // contributors (empty → "ok"), and backup from the §6.8 marker
58
81
  // derivation above ("configured" only while a fresh success
59
- // exists — the one storage read this route makes).
82
+ // exists — the one storage read this route makes). Gateway C2 adds
83
+ // `gateway`: present ONLY when an engine loaded (the meta route
84
+ // keys on `opened.gatewayEngine`, never on the handle — a
85
+ // no-engine deploy reports NO gateway health, degenerate "ok" by
86
+ // absence), its value the HANDLE's wrapped health (throw →
87
+ // "degraded"; a start rejection latches degraded until restart).
60
88
  health: {
61
89
  backup: backupHealth,
62
90
  memory: opened.memoryHealth?.() ?? "ok",
63
91
  providers: aggregateHealth(opened.providerHealth),
92
+ ...(opened.gatewayEngine === undefined || ctx.gateway === undefined
93
+ ? {}
94
+ : { gateway: ctx.gateway.health() }),
64
95
  },
65
96
  // The auth block (W1 Task 6, spec §5.7): the effective method list
66
97
  // (resolved once in `createServer`) + the onboarding state — both
@@ -86,6 +117,9 @@ export function registerMetaRoutes(app, ctx) {
86
117
  // deploy (gate off or absent default) reports [].
87
118
  channels: webuiBundle === undefined ? [] : [webuiBundle.channel],
88
119
  },
120
+ // §14.2/§14.4: the development flag — gates the gallery and the
121
+ // development error bodies (a boot constant, cached like the rest).
122
+ dev: config.dev,
89
123
  // The seam inventory: declaration-derived, no doc reads.
90
124
  seam: {
91
125
  interactions: [...opened.seamKinds]
@@ -19,7 +19,7 @@
19
19
  * new key up on the next refresh — no provider restart, no re-boot.
20
20
  */
21
21
  import { DEFAULT_AUTH_KEY } from "../../harness/auth.js";
22
- import { providerAuthStatus } from "../helpers.js";
22
+ import { listModels, providerAuthStatus } from "../helpers.js";
23
23
  /**
24
24
  * The per-provider readiness report (the shared derivation BOTH routes
25
25
  * serve — one body, never two). The response carries ONLY a per-provider
@@ -173,26 +173,10 @@ export function registerProviderRoutes(app, ctx) {
173
173
  return readinessEntry(opened, id, producersIndex(opened));
174
174
  });
175
175
  // Models: the merged chat-model catalog as picker entries (no provider
176
- // internals — baseUrl/headers/auth stay server-side).
176
+ // internals — baseUrl/headers/auth stay server-side). The shared
177
+ // construction (gateway C4b): the host's `models.list()` serves the
178
+ // SAME list through helpers.listModels.
177
179
  app.get("/v1/models", async () => {
178
- const models = opened.models.getModels().map((model) => ({
179
- id: `${model.provider}/${model.id}`,
180
- provider: model.provider,
181
- modelId: model.id,
182
- name: model.name,
183
- contextWindow: model.contextWindow,
184
- maxTokens: model.maxTokens,
185
- reasoning: model.reasoning,
186
- input: model.input,
187
- // Project the four rate fields explicitly (ModelCostRates); `tiers` is
188
- // dropped for v1 (the picker doesn't use them) — no unsound cast.
189
- cost: {
190
- input: model.cost.input,
191
- output: model.cost.output,
192
- cacheRead: model.cost.cacheRead,
193
- cacheWrite: model.cost.cacheWrite,
194
- },
195
- }));
196
- return { models };
180
+ return { models: listModels(opened) };
197
181
  });
198
182
  }
@@ -8,9 +8,9 @@
8
8
  */
9
9
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
10
10
  import { UnservedModelError } from "../../harness/providers/index.js";
11
- import { ConversationOpError, submitOp, } from "../conversation-ops.js";
11
+ import { abortOp, compactOp, configureOp, ConversationOpError, submitOp, } from "../conversation-ops.js";
12
12
  import { loadConversation } from "../conversation-handler.js";
13
- import { ID_PARAMS_SCHEMA, THINKING_LEVELS, submissionId, } from "../helpers.js";
13
+ import { ID_PARAMS_SCHEMA, submissionId } from "../helpers.js";
14
14
  /** Integer path params for the submission status route (§7.1). */
15
15
  const SUBMISSION_PARAMS_SCHEMA = {
16
16
  type: "object",
@@ -93,110 +93,37 @@ export function registerSubmissionRoutes(app, ctx) {
93
93
  return { submission: record };
94
94
  });
95
95
  // Configure: change the conversation's model and/or thinking level.
96
+ // The shared configure op (gateway C4a) is the SAME code path the
97
+ // extension host bridge drives — the validation order and error bodies
98
+ // moved verbatim into the op; this route owns the HTTP mapping only.
96
99
  // v1 accepts EXACTLY `model` + `thinking` — any other key is a fail-loud
97
100
  // 400 (the agent's other knobs are not wire surface yet).
98
101
  app.post("/v1/conversations/:id/configure", { schema: { params: ID_PARAMS_SCHEMA }, preHandler: loadConversation(ctx, "write") }, async (request, reply) => {
99
- const conversation = request.conversation;
100
- const body = request.body;
101
- if (body === undefined ||
102
- body === null ||
103
- typeof body !== "object" ||
104
- Array.isArray(body)) {
105
- return await reply.code(400).send({ error: "invalid_body" });
106
- }
107
- const unknownKey = Object.keys(body).find((key) => key !== "model" && key !== "thinking");
108
- if (unknownKey !== undefined) {
109
- return await reply
110
- .code(400)
111
- .send({ error: "invalid_field", field: unknownKey });
112
- }
113
- const model = body.model;
114
- const thinking = body.thinking;
115
- if (model === undefined && thinking === undefined) {
116
- // A configure that changes nothing is a client error, not a no-op.
117
- return await reply.code(400).send({ error: "invalid_body" });
102
+ try {
103
+ return await configureOp(ctx, request.conversation, request.body);
118
104
  }
119
- // `model` is `"provider/modelId"`, split on the FIRST `/` (a modelId
120
- // may itself contain slashes); both parts must be non-empty.
121
- let parsedModel;
122
- if (model !== undefined) {
123
- if (typeof model !== "string" || !model.includes("/")) {
124
- return await reply
125
- .code(400)
126
- .send({ error: "invalid_field", field: "model" });
127
- }
128
- const slash = model.indexOf("/");
129
- const provider = model.slice(0, slash);
130
- const modelId = model.slice(slash + 1);
131
- if (provider.length === 0 || modelId.length === 0) {
132
- return await reply
133
- .code(400)
134
- .send({ error: "invalid_field", field: "model" });
105
+ catch (error) {
106
+ if (error instanceof ConversationOpError) {
107
+ return await reply.code(error.status).send(error.body);
135
108
  }
136
- parsedModel = { provider, modelId };
137
- }
138
- if (thinking !== undefined &&
139
- (typeof thinking !== "string" ||
140
- !THINKING_LEVELS.includes(thinking))) {
141
- return await reply
142
- .code(400)
143
- .send({ error: "invalid_field", field: "thinking" });
109
+ throw error;
144
110
  }
145
- // perModelThinking: a model change re-applies the store's
146
- // override for the NEW model unless the request pins `thinking` —
147
- // the map is the deployment default, the explicit field the caller's
148
- // win. Read from the store here, so a PATCH reaches later configures.
149
- const storeThinking = parsedModel === undefined
150
- ? undefined
151
- : opened.settings.read().perModelThinking?.[`${parsedModel.provider}/${parsedModel.modelId}`];
152
- await conversation.configure({
153
- ...(parsedModel === undefined ? {} : { model: parsedModel }),
154
- ...(thinking !== undefined
155
- ? { thinkingLevel: thinking }
156
- : storeThinking === undefined
157
- ? {}
158
- : { thinkingLevel: storeThinking }),
159
- }, BACKGROUND_CONTEXT);
160
- // Read the agent back and project the configured surface only —
161
- // resolved extensions/tools/sections stay server-side.
162
- const agent = await conversation.agent(BACKGROUND_CONTEXT);
163
- return {
164
- ...(agent.model === undefined ? {} : { model: agent.model }),
165
- thinkingLevel: agent.thinkingLevel,
166
- };
167
111
  });
168
112
  // Abort: withdraw/settle the conversation's active submission.
169
- // `inspect()` is the read-only union of placed run inputs and queued inbox
170
- // items (all conversations); filtered to this conversation, the LOWEST id
171
- // is the active one — anything queued behind a busy conversation was
172
- // admitted later and has a higher id.
113
+ // The shared abort op (gateway C4a) is the SAME code path the host
114
+ // bridge drives — the inspect/reduce targeting of the conversation's
115
+ // lowest live submission and the 404 `no_active_submission` live in the
116
+ // op; this route owns the HTTP mapping only.
173
117
  app.post("/v1/conversations/:id/abort", { schema: { params: ID_PARAMS_SCHEMA }, preHandler: loadConversation(ctx, "write") }, async (request, reply) => {
174
- const conversation = request.conversation;
175
- const inspection = await opened.harness.inspect(BACKGROUND_CONTEXT);
176
- // Lowest live id = oldest admitted = the placed run input over later-
177
- // queued inbox items. reduce() with a first-element seed is ordering-
178
- // independent (a future pi-durable inspect() ordering change cannot
179
- // silently target the wrong submission); empty → undefined.
180
- const active = inspection.submissions
181
- .filter((s) => s.conversationId === conversation.id)
182
- .reduce((min, s) => (min === undefined || s.id < min.id ? s : min), undefined);
183
- if (active === undefined) {
184
- return await reply
185
- .code(404)
186
- .send({ error: "no_active_submission" });
118
+ try {
119
+ return await abortOp(ctx, request.conversation);
187
120
  }
188
- const result = await opened.harness.abortSubmission(active.id, BACKGROUND_CONTEXT, conversation.id);
189
- // `not_found` means the submission's record vanished between `inspect`
190
- // and `abort` — the conversation is no longer busy, so the honest answer
191
- // is the same as nothing-active.
192
- if (result === "not_found") {
193
- return await reply
194
- .code(404)
195
- .send({ error: "no_active_submission" });
121
+ catch (error) {
122
+ if (error instanceof ConversationOpError) {
123
+ return await reply.code(error.status).send(error.body);
124
+ }
125
+ throw error;
196
126
  }
197
- // `already_placed`/`settled` are still 200: abort is idempotent and
198
- // the body reports which state the submission was found in.
199
- return { result, submissionId: active.id };
200
127
  });
201
128
  // Manual compaction: `POST .../compact` (pi parity).
202
129
  // `Conversation.compact(instructions)` (pi-durable's own manual-
@@ -208,12 +135,14 @@ export function registerSubmissionRoutes(app, ctx) {
208
135
  // `enabled` (upstream doc: "manual compaction ignores `enabled`");
209
136
  // `compact()` resumes the scheduler itself. The task id is handed back —
210
137
  // the SSE stream carries the `pi.compaction` entry when the summary
211
- // lands.
138
+ // lands. The shared compact op (gateway C4a) is the SAME code path the
139
+ // host bridge drives; this route owns the body validation and the 202.
212
140
  app.post("/v1/conversations/:id/compact", { schema: { params: ID_PARAMS_SCHEMA }, preHandler: loadConversation(ctx, "write") }, async (request, reply) => {
213
141
  const conversation = request.conversation;
214
142
  // Absent body is valid (no instructions); an object may carry
215
143
  // ONLY `instructions` — any other key is a fail-loud 400, the
216
- // configure convention.
144
+ // configure convention. The instructions value itself is the op's
145
+ // validation (same shape for HTTP and host callers).
217
146
  const body = request.body;
218
147
  let instructions;
219
148
  if (body !== undefined && body !== null) {
@@ -227,22 +156,18 @@ export function registerSubmissionRoutes(app, ctx) {
227
156
  .code(400)
228
157
  .send({ error: "invalid_field", field: unknownKey });
229
158
  }
230
- if (record.instructions !== undefined &&
231
- typeof record.instructions !== "string") {
232
- return await reply
233
- .code(400)
234
- .send({ error: "invalid_field", field: "instructions" });
159
+ instructions = record.instructions;
160
+ }
161
+ try {
162
+ const { taskId } = await compactOp(ctx, conversation, instructions);
163
+ return await reply.code(202).send({ taskId });
164
+ }
165
+ catch (error) {
166
+ if (error instanceof ConversationOpError) {
167
+ return await reply.code(error.status).send(error.body);
235
168
  }
236
- instructions =
237
- record.instructions === undefined || record.instructions === ""
238
- ? undefined
239
- : record.instructions;
169
+ throw error;
240
170
  }
241
- const taskId = await conversation.compact(instructions, BACKGROUND_CONTEXT);
242
- // 202: like submissions, the compaction is admitted, not settled —
243
- // the task runs asynchronously and its summary lands as a view
244
- // revision (the SSE stream).
245
- return await reply.code(202).send({ taskId });
246
171
  });
247
172
  // Extension-command invocation (core hardening W5 Task 3, spec §9.3):
248
173
  // `POST /v1/conversations/:id/commands/:name {args?}` runs the winning