@sigrun/sigrun 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +24 -2
- package/THIRD_PARTY_NOTICES.md +23 -0
- package/dist/blocks/builders.js +125 -0
- package/dist/blocks/images.js +57 -0
- package/dist/blocks/index.js +20 -0
- package/dist/blocks/limits.js +26 -0
- package/dist/blocks/placements.js +99 -0
- package/dist/blocks/schema.js +559 -0
- package/dist/blocks/validate.js +602 -0
- package/dist/config.js +31 -0
- package/dist/extension.d.ts +476 -9
- package/dist/harness/agent-env.js +62 -0
- package/dist/harness/boot.js +8 -1
- package/dist/harness/event-bus.js +33 -0
- package/dist/harness/identity/users.js +3 -2
- package/dist/harness/loader.js +369 -13
- package/dist/harness/packages.js +71 -2
- package/dist/harness/seam-host.js +69 -2
- package/dist/harness/settings.js +5 -0
- package/dist/harness/workspace.js +6 -4
- package/dist/jobs/fire.js +11 -0
- package/dist/jobs/spec.js +19 -0
- package/dist/serve.js +29 -39
- package/dist/server/auth/pipeline.js +16 -1
- package/dist/server/conversation-ops.js +482 -10
- package/dist/server/gateway.js +122 -0
- package/dist/server/helpers.js +26 -0
- package/dist/server/host-bridge.js +141 -4
- package/dist/server/index.js +27 -0
- package/dist/server/notifications.js +6 -2
- package/dist/server/routes/conversations.js +37 -93
- package/dist/server/routes/events.js +39 -0
- package/dist/server/routes/ext-assets.js +84 -0
- package/dist/server/routes/extensions.js +31 -1
- package/dist/server/routes/files.js +38 -83
- package/dist/server/routes/meta.js +35 -1
- package/dist/server/routes/providers.js +5 -21
- package/dist/server/routes/submissions.js +36 -111
- package/dist/server/routes/surfaces.js +387 -0
- package/dist/server/routes/users.js +6 -1
- package/dist/server/session-meta.js +12 -6
- package/dist/server/shutdown.js +85 -0
- package/dist/server/surfaces/access-cache.js +43 -0
- package/dist/server/surfaces/dedup.js +67 -0
- package/dist/server/surfaces/instance.js +42 -0
- package/dist/server/surfaces/mime.js +73 -0
- package/dist/server/surfaces/rate-limit.js +63 -0
- package/dist/server/surfaces/service.js +493 -0
- package/dist/server/surfaces/watchers.js +94 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +9 -0
- package/node_modules/@sigrun/shared/dist/blocks/format.d.ts +28 -0
- package/node_modules/@sigrun/shared/dist/blocks/format.js +199 -0
- package/node_modules/@sigrun/shared/dist/blocks/icons.d.ts +12 -0
- package/node_modules/@sigrun/shared/dist/blocks/icons.js +64 -0
- package/node_modules/@sigrun/shared/dist/blocks/index.d.ts +25 -0
- package/node_modules/@sigrun/shared/dist/blocks/index.js +21 -0
- package/node_modules/@sigrun/shared/dist/blocks/instance.d.ts +29 -0
- package/node_modules/@sigrun/shared/dist/blocks/instance.js +51 -0
- package/node_modules/@sigrun/shared/dist/blocks/lttb.d.ts +16 -0
- package/node_modules/@sigrun/shared/dist/blocks/lttb.js +67 -0
- package/node_modules/@sigrun/shared/dist/blocks/ops.d.ts +44 -0
- package/node_modules/@sigrun/shared/dist/blocks/ops.js +294 -0
- package/node_modules/@sigrun/shared/dist/blocks/text.d.ts +43 -0
- package/node_modules/@sigrun/shared/dist/blocks/text.js +790 -0
- package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +77 -0
- package/node_modules/@sigrun/shared/dist/blocks/transport.js +11 -0
- package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +541 -0
- package/node_modules/@sigrun/shared/dist/blocks/types.js +17 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +9 -0
- package/node_modules/@sigrun/shared/dist/live.d.ts +71 -0
- package/node_modules/@sigrun/shared/dist/live.js +82 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +19 -3
- package/node_modules/@sigrun/shared/dist/settings.d.ts +7 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +81 -3
- package/node_modules/@sigrun/shared/package.json +10 -2
- package/package.json +11 -5
- package/webui/dist/assets/index-CiYInlLx.css +1 -0
- package/webui/dist/assets/index-JDfOQV9z.js +52 -0
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-BiSMljer.css +0 -1
- package/webui/dist/assets/index-hVLvsizV.js +0 -50
|
@@ -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`,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
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":
|
|
285
|
-
|
|
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 <
|
|
289
|
-
const chunk = await reader.read(offset, Math.min(RAW_READ_CHUNK_BYTES,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|