@mono-agent/web 0.20.11 → 0.21.0
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/README.md +397 -68
- package/dist/contracts.d.ts +437 -8
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +2 -0
- package/dist/contracts.js.map +1 -1
- package/dist/cron-reply-context.d.ts +27 -0
- package/dist/cron-reply-context.d.ts.map +1 -0
- package/dist/cron-reply-context.js +241 -0
- package/dist/cron-reply-context.js.map +1 -0
- package/dist/discovery.d.ts +1 -0
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +8 -6
- package/dist/discovery.js.map +1 -1
- package/dist/effort-ladder.d.ts +111 -0
- package/dist/effort-ladder.d.ts.map +1 -0
- package/dist/effort-ladder.js +155 -0
- package/dist/effort-ladder.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/long-lived-fetch.d.ts +5 -0
- package/dist/long-lived-fetch.d.ts.map +1 -0
- package/dist/long-lived-fetch.js +22 -0
- package/dist/long-lived-fetch.js.map +1 -0
- package/dist/monitor-reply.d.ts +10 -0
- package/dist/monitor-reply.d.ts.map +1 -0
- package/dist/monitor-reply.js +67 -0
- package/dist/monitor-reply.js.map +1 -0
- package/dist/notification-client.d.ts +11 -2
- package/dist/notification-client.d.ts.map +1 -1
- package/dist/notification-client.js +5 -4
- package/dist/notification-client.js.map +1 -1
- package/dist/notification-ingress.d.ts +8 -1
- package/dist/notification-ingress.d.ts.map +1 -1
- package/dist/notification-ingress.js +28 -6
- package/dist/notification-ingress.js.map +1 -1
- package/dist/operator-client.d.ts +35 -2
- package/dist/operator-client.d.ts.map +1 -1
- package/dist/operator-client.js +276 -9
- package/dist/operator-client.js.map +1 -1
- package/dist/server.d.ts +42 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +679 -52
- package/dist/server.js.map +1 -1
- package/dist/service.d.ts +299 -18
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +1915 -160
- package/dist/service.js.map +1 -1
- package/dist/store-migrations.d.ts +23 -0
- package/dist/store-migrations.d.ts.map +1 -0
- package/dist/store-migrations.js +212 -0
- package/dist/store-migrations.js.map +1 -0
- package/dist/store.d.ts +389 -21
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +2087 -245
- package/dist/store.js.map +1 -1
- package/package.json +9 -8
- package/webapp/dist/assets/{assistant-ui-BzN2E6n6.js → assistant-ui-pZmGxIp2.js} +16 -16
- package/webapp/dist/assets/index-6uQ7TVQe.css +1 -0
- package/webapp/dist/assets/index-CsSMjSgW.js +156 -0
- package/webapp/dist/assets/{markdown-Vq23xgh7.js → markdown-Du5t10ja.js} +1 -1
- package/webapp/dist/badge-96.png +0 -0
- package/webapp/dist/index.html +26 -6
- package/webapp/dist/manifest.webmanifest +1 -1
- package/webapp/dist/notification-sw.js +3 -1
- package/webapp/dist/sw.js +1 -1
- package/webapp/dist/{workbox-9c191d2f.js → workbox-2fbc6a65.js} +1 -1
- package/webapp/dist/assets/index-BT463dRM.css +0 -1
- package/webapp/dist/assets/index-Co-qDQPq.js +0 -155
package/dist/server.js
CHANGED
|
@@ -4,15 +4,17 @@ import { chmod, open, readFile, rename, unlink } from "node:fs/promises";
|
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
5
|
import { isIP } from "node:net";
|
|
6
6
|
import { hostname as systemHostname } from "node:os";
|
|
7
|
-
import { dirname, resolve } from "node:path";
|
|
7
|
+
import { dirname, join, resolve } from "node:path";
|
|
8
8
|
import { Readable } from "node:stream";
|
|
9
9
|
import { pipeline } from "node:stream/promises";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
|
-
import {
|
|
11
|
+
import { constants as zlibConstants } from "node:zlib";
|
|
12
|
+
import { AGENT_LIVE_INPUT_MAX_CHARACTERS, closeServerBounded, hostForUrl, listen, MAX_PROVIDER_AUTH_BODY_BYTES, normalizeHostForBind, parseProviderAuthSessionInput, parseProviderAuthSessionStartInput, parseProviderAuthCheckStartInput, } from "@mono-agent/agent-contracts";
|
|
13
|
+
import compression from "compression";
|
|
12
14
|
import express, {} from "express";
|
|
13
|
-
import { DEFAULT_WEB_THEME, WEB_API_VERSION, WEB_MAX_TURN_TEXT_CHARACTERS, WEB_THEMES, } from "./contracts.js";
|
|
15
|
+
import { DEFAULT_WEB_THEME, WEB_API_VERSION, WEB_CONSOLE_NAME_MAX_CHARACTERS, WEB_MAX_TURN_TEXT_CHARACTERS, WEB_THEMES, } from "./contracts.js";
|
|
14
16
|
import { errorMessage, WebConsoleError } from "./errors.js";
|
|
15
|
-
import { WEB_THREAD_SEARCH_MAX } from "./store.js";
|
|
17
|
+
import { WEB_MESSAGE_PAGE_DEFAULT, WEB_MESSAGE_PAGE_MAX, WEB_THREAD_PAGE_DEFAULT, WEB_THREAD_PAGE_MAX, WEB_THREAD_SEARCH_MAX, } from "./store.js";
|
|
16
18
|
import { MCP_APP_PROXY_CONTENT_SECURITY_POLICY, MCP_APP_PROXY_DOCUMENT, MCP_APP_PROXY_PATH, } from "./mcp-app-proxy.js";
|
|
17
19
|
import { startWebNotificationIngress, } from "./notification-ingress.js";
|
|
18
20
|
import { WebService } from "./service.js";
|
|
@@ -20,20 +22,49 @@ export const DEFAULT_WEB_HOST = "0.0.0.0";
|
|
|
20
22
|
export const DEFAULT_WEB_PORT = 5050;
|
|
21
23
|
const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
22
24
|
const MAX_SSE_CLIENTS = 64;
|
|
25
|
+
/**
|
|
26
|
+
* How often one connection may be told that one conversation's message moved,
|
|
27
|
+
* when it is not subscribed to that conversation's content.
|
|
28
|
+
*
|
|
29
|
+
* A hint costs its reader a re-read, and a streaming turn produces one write
|
|
30
|
+
* every 50 ms. A console that is not looking at the conversation has no reason
|
|
31
|
+
* to follow it at that rate -- it needs the sidebar row to be right, which the
|
|
32
|
+
* conversation events already carry.
|
|
33
|
+
*/
|
|
34
|
+
const DELTA_HINT_INTERVAL_MS = 1_000;
|
|
35
|
+
/** Bound on `?thread=`, matching the other bounded query strings. */
|
|
36
|
+
const MAX_SUBSCRIPTION_LENGTH = 512;
|
|
37
|
+
/**
|
|
38
|
+
* How many conversations one connection remembers having hinted about.
|
|
39
|
+
*
|
|
40
|
+
* A hard bound, evicting the least recently hinted: a burst wider than this
|
|
41
|
+
* inside one second would otherwise grow the map faster than the window can
|
|
42
|
+
* retire it. Evicting an entry that is still inside its window costs one extra
|
|
43
|
+
* hint for that conversation; an unbounded map on a server that runs for weeks
|
|
44
|
+
* costs the server.
|
|
45
|
+
*/
|
|
46
|
+
const MAX_THROTTLED_CONVERSATIONS = 256;
|
|
23
47
|
const MAX_MCP_APP_BRIDGE_REQUEST_BYTES = 64 * 1024;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Content-addressed build output and write-once upload bytes never change under
|
|
50
|
+
* their URL, so the browser may hold them for a year and skip the request.
|
|
51
|
+
*/
|
|
52
|
+
const IMMUTABLE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
|
|
53
|
+
const WEB_THEME_MANIFEST_COLORS = {
|
|
54
|
+
evergreen: { themeColor: "#191c1a", backgroundColor: "#0f1110" },
|
|
55
|
+
ocean: { themeColor: "#191c1a", backgroundColor: "#0d1115" },
|
|
56
|
+
plum: { themeColor: "#191c1a", backgroundColor: "#120f14" },
|
|
57
|
+
terracotta: { themeColor: "#191c1a", backgroundColor: "#130f0d" },
|
|
29
58
|
};
|
|
30
59
|
export async function startWebServer(options = {}) {
|
|
31
60
|
const host = normalizeHostForBind(options.host ?? DEFAULT_WEB_HOST);
|
|
32
61
|
const port = normalizePort(options.port ?? DEFAULT_WEB_PORT);
|
|
33
62
|
const staticDir = options.staticDir ?? defaultStaticDir();
|
|
34
63
|
const theme = resolveWebTheme(options.theme);
|
|
64
|
+
const hostName = systemHostname().trim() || "localhost";
|
|
35
65
|
const consoleIdentity = {
|
|
36
|
-
hostName
|
|
66
|
+
hostName,
|
|
67
|
+
displayName: resolveWebConsoleName(options.name) ?? hostName,
|
|
37
68
|
theme,
|
|
38
69
|
};
|
|
39
70
|
const webManifest = await loadWebManifest(staticDir, consoleIdentity);
|
|
@@ -52,11 +83,30 @@ export async function startWebServer(options = {}) {
|
|
|
52
83
|
let notificationIngress;
|
|
53
84
|
let stopPromise;
|
|
54
85
|
app.disable("x-powered-by");
|
|
86
|
+
// Mounted first so every response body, error JSON included, is negotiated.
|
|
87
|
+
// Brotli quality 4 keeps a phone-sized payload under a few milliseconds of
|
|
88
|
+
// CPU; the default filter already declines anything the response marked
|
|
89
|
+
// `no-transform` (SSE, attachment bytes) or that is not compressible.
|
|
90
|
+
app.use(compression({
|
|
91
|
+
threshold: 1024,
|
|
92
|
+
level: 6,
|
|
93
|
+
brotli: { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 } },
|
|
94
|
+
}));
|
|
55
95
|
app.use(securityHeaders);
|
|
56
96
|
app.use(validateLocalRequest(host, allowedHosts));
|
|
97
|
+
// Console state is private to this operator but not secret from their own
|
|
98
|
+
// browser: `no-cache` still forces revalidation on every read, and lets
|
|
99
|
+
// Express answer an unchanged payload with a 304 instead of resending it.
|
|
57
100
|
app.use("/api", (_req, res, next) => {
|
|
58
|
-
res.setHeader("Cache-Control", "no-
|
|
59
|
-
|
|
101
|
+
res.setHeader("Cache-Control", "private, no-cache");
|
|
102
|
+
next();
|
|
103
|
+
});
|
|
104
|
+
app.use("/api/v1/threads/:id/submissions", (_req, res, next) => {
|
|
105
|
+
res.setHeader("Cache-Control", "private, no-store, max-age=0");
|
|
106
|
+
next();
|
|
107
|
+
});
|
|
108
|
+
app.use("/api/v1/agents/:id/cron/jobs/:jobId/runs/:runId/reply-threads", (_req, res, next) => {
|
|
109
|
+
res.setHeader("Cache-Control", "private, no-store, max-age=0");
|
|
60
110
|
next();
|
|
61
111
|
});
|
|
62
112
|
app.use("/api/v1", express.json({ limit: "256kb", strict: true }));
|
|
@@ -67,10 +117,26 @@ export async function startWebServer(options = {}) {
|
|
|
67
117
|
push: service.webPushDegraded() ? "degraded" : "ok",
|
|
68
118
|
});
|
|
69
119
|
});
|
|
70
|
-
app.get("/api/v1/bootstrap", (
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
120
|
+
app.get("/api/v1/bootstrap", (req, res, next) => {
|
|
121
|
+
try {
|
|
122
|
+
// One bucket, not every agent's conversations. An unknown or absent
|
|
123
|
+
// `sourceId` falls back inside the service rather than failing: the
|
|
124
|
+
// first request a fresh console makes has no selection to name yet.
|
|
125
|
+
// An empty `sourceId` is a console that has not resolved an agent yet,
|
|
126
|
+
// which is the same thing as omitting it -- not a malformed request.
|
|
127
|
+
const requested = req.query.sourceId === "" ? undefined : req.query.sourceId;
|
|
128
|
+
const sourceId = optionalQueryString(requested, 512);
|
|
129
|
+
void service.bootstrap({
|
|
130
|
+
...(sourceId === undefined ? {} : { sourceId }),
|
|
131
|
+
archived: optionalArchivedQuery(req.query.archived) ?? false,
|
|
132
|
+
limit: boundedQueryLimit(req.query.limit, WEB_THREAD_PAGE_MAX, WEB_THREAD_PAGE_DEFAULT),
|
|
133
|
+
})
|
|
134
|
+
.then((bootstrap) => res.status(200).json({ ...bootstrap, console: consoleIdentity }))
|
|
135
|
+
.catch(next);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
next(error);
|
|
139
|
+
}
|
|
74
140
|
});
|
|
75
141
|
app.get(MCP_APP_PROXY_PATH, (_req, res) => {
|
|
76
142
|
setMcpAppProxyHeaders(res);
|
|
@@ -91,6 +157,115 @@ export async function startWebServer(options = {}) {
|
|
|
91
157
|
next(error);
|
|
92
158
|
}
|
|
93
159
|
});
|
|
160
|
+
app.put("/api/v1/agents/:id/run-defaults", (req, res, next) => {
|
|
161
|
+
try {
|
|
162
|
+
const input = parsePutAgentRunSettings(req.body);
|
|
163
|
+
res.status(200).json({ agent: service.setAgentRunDefaults(pathParam(req.params.id), input) });
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
next(error);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
app.delete("/api/v1/agents/:id/run-defaults", (req, res, next) => {
|
|
170
|
+
try {
|
|
171
|
+
res.status(200).json({ agent: service.clearAgentRunDefaults(pathParam(req.params.id)) });
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
next(error);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
const sendProviderAuth = (res, status, body) => {
|
|
178
|
+
res.setHeader("Cache-Control", "private, no-store, max-age=0");
|
|
179
|
+
if (status === 204)
|
|
180
|
+
res.status(status).end();
|
|
181
|
+
else
|
|
182
|
+
res.status(status).json(body);
|
|
183
|
+
};
|
|
184
|
+
app.use("/api/v1/agents/:id/provider-auth", (_req, res, next) => {
|
|
185
|
+
res.setHeader("Cache-Control", "private, no-store, max-age=0");
|
|
186
|
+
next();
|
|
187
|
+
});
|
|
188
|
+
app.get("/api/v1/agents/:id/provider-auth", (req, res, next) => {
|
|
189
|
+
try {
|
|
190
|
+
exactRequestOrigin(req);
|
|
191
|
+
void trackOperation(service.providerAuthStatus(pathParam(req.params.id)), activeOperations)
|
|
192
|
+
.then((snapshot) => sendProviderAuth(res, 200, snapshot)).catch(next);
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
next(error);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
app.post("/api/v1/agents/:id/provider-auth/sessions", (req, res, next) => {
|
|
199
|
+
try {
|
|
200
|
+
exactRequestOrigin(req);
|
|
201
|
+
assertProviderAuthBody(req.body);
|
|
202
|
+
const input = parseProviderAuthSessionStartInput(req.body);
|
|
203
|
+
void trackOperation(service.startProviderAuth(pathParam(req.params.id), input), activeOperations)
|
|
204
|
+
.then((snapshot) => sendProviderAuth(res, 201, snapshot)).catch(next);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
next(error);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
app.get("/api/v1/agents/:id/provider-auth/sessions/:sessionId", (req, res, next) => {
|
|
211
|
+
try {
|
|
212
|
+
exactRequestOrigin(req);
|
|
213
|
+
void trackOperation(service.providerAuthSession(pathParam(req.params.id), pathParam(req.params.sessionId)), activeOperations).then((snapshot) => sendProviderAuth(res, 200, snapshot)).catch(next);
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
next(error);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
app.post("/api/v1/agents/:id/provider-auth/sessions/:sessionId/input", (req, res, next) => {
|
|
220
|
+
try {
|
|
221
|
+
exactRequestOrigin(req);
|
|
222
|
+
assertProviderAuthBody(req.body);
|
|
223
|
+
const input = parseProviderAuthSessionInput(req.body);
|
|
224
|
+
void trackOperation(service.submitProviderAuth(pathParam(req.params.id), pathParam(req.params.sessionId), input), activeOperations).then((snapshot) => sendProviderAuth(res, 200, snapshot)).catch(next);
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
next(error);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
app.delete("/api/v1/agents/:id/provider-auth/sessions/:sessionId", (req, res, next) => {
|
|
231
|
+
try {
|
|
232
|
+
exactRequestOrigin(req);
|
|
233
|
+
void trackOperation(service.cancelProviderAuth(pathParam(req.params.id), pathParam(req.params.sessionId)), activeOperations).then(() => sendProviderAuth(res, 204)).catch(next);
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
next(error);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
app.post("/api/v1/agents/:id/provider-auth/checks", (req, res, next) => {
|
|
240
|
+
try {
|
|
241
|
+
exactRequestOrigin(req);
|
|
242
|
+
assertProviderAuthBody(req.body);
|
|
243
|
+
const input = parseProviderAuthCheckStartInput(req.body);
|
|
244
|
+
void trackOperation(service.startProviderAuthCheck(pathParam(req.params.id), input), activeOperations)
|
|
245
|
+
.then((snapshot) => sendProviderAuth(res, 201, snapshot)).catch(next);
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
next(error);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
app.get("/api/v1/agents/:id/provider-auth/checks/:checkId", (req, res, next) => {
|
|
252
|
+
try {
|
|
253
|
+
exactRequestOrigin(req);
|
|
254
|
+
void trackOperation(service.providerAuthCheck(pathParam(req.params.id), pathParam(req.params.checkId)), activeOperations).then((snapshot) => sendProviderAuth(res, 200, snapshot)).catch(next);
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
next(error);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
app.delete("/api/v1/agents/:id/provider-auth/checks/:checkId", (req, res, next) => {
|
|
261
|
+
try {
|
|
262
|
+
exactRequestOrigin(req);
|
|
263
|
+
void trackOperation(service.cancelProviderAuthCheck(pathParam(req.params.id), pathParam(req.params.checkId)), activeOperations).then(() => sendProviderAuth(res, 204)).catch(next);
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
next(error);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
94
269
|
app.get("/api/v1/agents/:id/skills", (req, res, next) => {
|
|
95
270
|
try {
|
|
96
271
|
res.status(200).json(service.agentSkills(pathParam(req.params.id)));
|
|
@@ -119,9 +294,49 @@ export async function startWebServer(options = {}) {
|
|
|
119
294
|
next(error);
|
|
120
295
|
}
|
|
121
296
|
});
|
|
297
|
+
app.get("/api/v1/agents/:id/models", (req, res, next) => {
|
|
298
|
+
try {
|
|
299
|
+
// The operator clamps to the same 200-model page ceiling; mirror it
|
|
300
|
+
// server-side rather than trusting the client's requested size.
|
|
301
|
+
const limit = boundedQueryLimit(req.query.limit, 200, 50);
|
|
302
|
+
const provider = optionalSearchQuery(req.query.provider, 256);
|
|
303
|
+
const q = optionalSearchQuery(req.query.q, 512);
|
|
304
|
+
// The agent's `/v1/models` contract treats the two modes as mutually
|
|
305
|
+
// exclusive: a supplier services `provider` and ignores the query, so a
|
|
306
|
+
// request carrying both comes back looking like an answered search. The
|
|
307
|
+
// agent rejects it, but the operator client re-reports any agent 4xx as
|
|
308
|
+
// a 502 `agent_http_error` -- which blames the agent for the caller's
|
|
309
|
+
// mistake. Keep this one local and legible.
|
|
310
|
+
if (provider.length > 0 && q.length > 0) {
|
|
311
|
+
throw new WebConsoleError("invalid_page", "provider and q are mutually exclusive for the model catalog.", 400);
|
|
312
|
+
}
|
|
313
|
+
const cursor = optionalQueryString(req.query.cursor, 4_096);
|
|
314
|
+
void trackOperation(service.agentModels(pathParam(req.params.id), {
|
|
315
|
+
limit,
|
|
316
|
+
...(provider.length === 0 ? {} : { provider }),
|
|
317
|
+
...(q.length === 0 ? {} : { q }),
|
|
318
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
319
|
+
}), activeOperations).then((page) => res.status(200).json(page)).catch(next);
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
next(error);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
122
325
|
app.get("/api/v1/agents/:id/cron/jobs/:jobId/runs/:runId", (req, res, next) => {
|
|
123
326
|
void trackOperation(service.cronRun(pathParam(req.params.id), pathParam(req.params.jobId), pathParam(req.params.runId)), activeOperations).then((message) => res.status(200).json({ message })).catch(next);
|
|
124
327
|
});
|
|
328
|
+
app.post("/api/v1/agents/:id/cron/jobs/:jobId/runs/:runId/reply-threads", (req, res, next) => {
|
|
329
|
+
try {
|
|
330
|
+
exactRequestOrigin(req);
|
|
331
|
+
const input = parseCronReply(req.body);
|
|
332
|
+
void trackOperation(service.createCronReplyThread(pathParam(req.params.id), pathParam(req.params.jobId), pathParam(req.params.runId), input), activeOperations).then((receipt) => {
|
|
333
|
+
res.status(receipt.duplicate ? 200 : 201).json(receipt);
|
|
334
|
+
}).catch(next);
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
next(error);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
125
340
|
app.post("/api/v1/agents/:id/cron/jobs/:jobId/run", (req, res, next) => {
|
|
126
341
|
try {
|
|
127
342
|
exactRequestOrigin(req);
|
|
@@ -149,7 +364,10 @@ export async function startWebServer(options = {}) {
|
|
|
149
364
|
app.post("/api/v1/threads", (req, res, next) => {
|
|
150
365
|
try {
|
|
151
366
|
const input = parseCreateThread(req.body);
|
|
152
|
-
res.status(201).json({ thread: service.createThread(input.sourceId
|
|
367
|
+
res.status(201).json({ thread: service.createThread(input.sourceId, {
|
|
368
|
+
...(input.model === undefined ? {} : { model: input.model }),
|
|
369
|
+
...(input.effort === undefined ? {} : { effort: input.effort }),
|
|
370
|
+
}) });
|
|
153
371
|
}
|
|
154
372
|
catch (error) {
|
|
155
373
|
next(error);
|
|
@@ -158,16 +376,17 @@ export async function startWebServer(options = {}) {
|
|
|
158
376
|
app.get("/api/v1/threads", (req, res, next) => {
|
|
159
377
|
try {
|
|
160
378
|
const sourceId = requiredQueryString(req.query.sourceId, "sourceId", 512);
|
|
161
|
-
const archived = req.query.archived
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
: (() => { throw new WebConsoleError("invalid_page", "archived must be true or false.", 400); })();
|
|
379
|
+
const archived = optionalArchivedQuery(req.query.archived);
|
|
380
|
+
if (archived === undefined) {
|
|
381
|
+
throw new WebConsoleError("invalid_page", "archived must be true or false.", 400);
|
|
382
|
+
}
|
|
166
383
|
const before = optionalQueryString(req.query.before, 4_096);
|
|
167
384
|
res.status(200).json(service.threadsPage({
|
|
168
385
|
sourceId,
|
|
169
386
|
archived,
|
|
170
|
-
|
|
387
|
+
// A sidebar shows a handful of rows and pages from there. This used to
|
|
388
|
+
// answer with the whole per-bucket cap by default.
|
|
389
|
+
limit: boundedQueryLimit(req.query.limit, WEB_THREAD_PAGE_MAX, WEB_THREAD_PAGE_DEFAULT),
|
|
171
390
|
...(before === undefined ? {} : { before }),
|
|
172
391
|
}));
|
|
173
392
|
}
|
|
@@ -190,9 +409,35 @@ export async function startWebServer(options = {}) {
|
|
|
190
409
|
next(error);
|
|
191
410
|
}
|
|
192
411
|
});
|
|
412
|
+
// Registered above `/threads/:id` so the conversation reads and the read that
|
|
413
|
+
// repairs one of their truncated tool calls stay together.
|
|
414
|
+
app.get("/api/v1/threads/:threadId/messages/:messageId/tool-calls/:toolCallId", (req, res, next) => {
|
|
415
|
+
try {
|
|
416
|
+
res.status(200).json({
|
|
417
|
+
part: service.toolCallPart(pathParam(req.params.threadId), pathParam(req.params.messageId), pathParam(req.params.toolCallId)),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
next(error);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
// Registered above `/threads/:id` and above the message page for the same
|
|
425
|
+
// reason the tool-call read is: this is how a console whose delta stream
|
|
426
|
+
// skipped a version repairs ONE message instead of re-reading the whole
|
|
427
|
+
// conversation around it.
|
|
428
|
+
app.get("/api/v1/threads/:threadId/messages/:messageId", (req, res, next) => {
|
|
429
|
+
try {
|
|
430
|
+
res.status(200).json({
|
|
431
|
+
message: service.message(pathParam(req.params.threadId), pathParam(req.params.messageId), fullTranscriptQuery(req.query.full)),
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
next(error);
|
|
436
|
+
}
|
|
437
|
+
});
|
|
193
438
|
app.get("/api/v1/threads/:id", (req, res, next) => {
|
|
194
439
|
try {
|
|
195
|
-
res.status(200).json(service.thread(pathParam(req.params.id)));
|
|
440
|
+
res.status(200).json(service.thread(pathParam(req.params.id), fullTranscriptQuery(req.query.full)));
|
|
196
441
|
}
|
|
197
442
|
catch (error) {
|
|
198
443
|
next(error);
|
|
@@ -202,8 +447,11 @@ export async function startWebServer(options = {}) {
|
|
|
202
447
|
try {
|
|
203
448
|
const before = optionalQueryString(req.query.before, 4_096);
|
|
204
449
|
res.status(200).json(service.messagePage(pathParam(req.params.id), {
|
|
205
|
-
|
|
450
|
+
// A screenful, not the ceiling: the console renders the tail and pages
|
|
451
|
+
// backwards from the cursor.
|
|
452
|
+
limit: boundedQueryLimit(req.query.limit, WEB_MESSAGE_PAGE_MAX, WEB_MESSAGE_PAGE_DEFAULT),
|
|
206
453
|
...(before === undefined ? {} : { before }),
|
|
454
|
+
...fullTranscriptQuery(req.query.full),
|
|
207
455
|
}));
|
|
208
456
|
}
|
|
209
457
|
catch (error) {
|
|
@@ -227,10 +475,10 @@ export async function startWebServer(options = {}) {
|
|
|
227
475
|
if (!res.writableEnded)
|
|
228
476
|
controller.abort(new Error("Reply attachment client disconnected."));
|
|
229
477
|
});
|
|
230
|
-
void trackOperation(service.replyAttachment(pathParam(req.params.threadId), pathParam(req.params.messageId), pathParam(req.params.partId), access.expires, access.token, controller.signal).then(async ({ part, response }) => {
|
|
478
|
+
void trackOperation(service.replyAttachment(pathParam(req.params.threadId), pathParam(req.params.messageId), pathParam(req.params.partId), access.expires, access.token, controller.signal).then(async ({ part, response, remainingSeconds }) => {
|
|
231
479
|
if (response.body === null)
|
|
232
480
|
throw new WebConsoleError("reply_attachment_unavailable", "Attachment stream is unavailable.", 502);
|
|
233
|
-
setReplyDownloadHeaders(res, part);
|
|
481
|
+
setReplyDownloadHeaders(res, part, remainingSeconds);
|
|
234
482
|
await pipeline(Readable.fromWeb(response.body), res);
|
|
235
483
|
}), activeOperations).catch((error) => {
|
|
236
484
|
if (res.headersSent) {
|
|
@@ -304,9 +552,15 @@ export async function startWebServer(options = {}) {
|
|
|
304
552
|
}
|
|
305
553
|
});
|
|
306
554
|
app.delete("/api/v1/threads/:id", (req, res, next) => {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
.
|
|
555
|
+
try {
|
|
556
|
+
const emptyOnly = optionalEmptyOnlyQuery(req.query.emptyOnly);
|
|
557
|
+
void trackOperation(service.deleteThread(pathParam(req.params.id), { emptyOnly }), activeOperations)
|
|
558
|
+
.then(() => res.status(204).end())
|
|
559
|
+
.catch(next);
|
|
560
|
+
}
|
|
561
|
+
catch (error) {
|
|
562
|
+
next(error);
|
|
563
|
+
}
|
|
310
564
|
});
|
|
311
565
|
app.post("/api/v1/threads/:id/turns", (req, res, next) => {
|
|
312
566
|
let input;
|
|
@@ -323,6 +577,25 @@ export async function startWebServer(options = {}) {
|
|
|
323
577
|
.then((started) => res.status(202).json(started))
|
|
324
578
|
.catch(next);
|
|
325
579
|
});
|
|
580
|
+
app.post("/api/v1/threads/:id/submissions", (req, res, next) => {
|
|
581
|
+
try {
|
|
582
|
+
const input = parseSubmission(req.body);
|
|
583
|
+
const receipt = service.submit(pathParam(req.params.id), input);
|
|
584
|
+
res.status(receipt.outcome === "rejected" ? 409 : 202).json(receipt);
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
next(error);
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
app.get("/api/v1/threads/:id/submissions/:submissionId", (req, res, next) => {
|
|
591
|
+
try {
|
|
592
|
+
const receipt = service.submission(pathParam(req.params.id), parseSubmissionId(pathParam(req.params.submissionId)));
|
|
593
|
+
res.status(200).json(receipt);
|
|
594
|
+
}
|
|
595
|
+
catch (error) {
|
|
596
|
+
next(error);
|
|
597
|
+
}
|
|
598
|
+
});
|
|
326
599
|
app.post("/api/v1/threads/:id/live-input", (req, res, next) => {
|
|
327
600
|
try {
|
|
328
601
|
const input = parseLiveInput(req.body);
|
|
@@ -430,11 +703,40 @@ export async function startWebServer(options = {}) {
|
|
|
430
703
|
app.get("/api/v1/uploads/:id/content", (req, res, next) => {
|
|
431
704
|
void trackOperation(handleDownloadContent(pathParam(req.params.id), res, service), activeOperations).catch(next);
|
|
432
705
|
});
|
|
433
|
-
|
|
706
|
+
/**
|
|
707
|
+
* The console's live channel. {@link createWebEventDispatch} decides what each
|
|
708
|
+
* connection is served; this owns the socket, the capacity cap and the
|
|
709
|
+
* heartbeat.
|
|
710
|
+
*/
|
|
711
|
+
app.get("/api/v1/events", (req, res, next) => {
|
|
712
|
+
let named;
|
|
713
|
+
try {
|
|
714
|
+
named = optionalThreadSubscription(req.query.thread);
|
|
715
|
+
}
|
|
716
|
+
catch (error) {
|
|
717
|
+
next(error);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
434
720
|
if (activeStreams.size >= MAX_SSE_CLIENTS) {
|
|
435
721
|
res.status(503).json({ error: { code: "sse_capacity", message: "Too many event streams are connected." } });
|
|
436
722
|
return;
|
|
437
723
|
}
|
|
724
|
+
let subscribed;
|
|
725
|
+
try {
|
|
726
|
+
// The CANONICAL id, because that is what every event carries. A console
|
|
727
|
+
// restores a selection it stored before a cron-channel adoption merged
|
|
728
|
+
// that conversation away, or opens a push link minted against the old id;
|
|
729
|
+
// matched by string equality, both subscribed to nothing and were served
|
|
730
|
+
// throttled hints for the conversation they had open.
|
|
731
|
+
//
|
|
732
|
+
// AFTER the capacity check: this reads the database, and a console the
|
|
733
|
+
// server is about to refuse must not be able to make it work.
|
|
734
|
+
subscribed = named === undefined ? undefined : service.resolveThreadId(named);
|
|
735
|
+
}
|
|
736
|
+
catch (error) {
|
|
737
|
+
next(error);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
438
740
|
let closed = false;
|
|
439
741
|
res.status(200);
|
|
440
742
|
res.setHeader("Content-Type", "text/event-stream");
|
|
@@ -451,18 +753,23 @@ export async function startWebServer(options = {}) {
|
|
|
451
753
|
activeStreams.delete(closeStream);
|
|
452
754
|
res.end();
|
|
453
755
|
};
|
|
454
|
-
const send = (
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
756
|
+
const send = createWebEventDispatch({
|
|
757
|
+
...(subscribed === undefined ? {} : { subscribed }),
|
|
758
|
+
write: (event) => {
|
|
759
|
+
if (closed || res.writableEnded)
|
|
760
|
+
return false;
|
|
761
|
+
if (res.write(formatSse(event)))
|
|
762
|
+
return true;
|
|
459
763
|
// Events are state-invalidation hints, not an unbounded replay log. A
|
|
460
764
|
// client that cannot drain one frame must reconnect and bootstrap.
|
|
461
765
|
closeStream();
|
|
462
766
|
return false;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
|
|
767
|
+
},
|
|
768
|
+
close: closeStream,
|
|
769
|
+
onFailure: (error) => {
|
|
770
|
+
logger?.error?.("Web console event stream failed.", { error: errorMessage(error) });
|
|
771
|
+
},
|
|
772
|
+
});
|
|
466
773
|
const unsubscribe = service.subscribe(send);
|
|
467
774
|
const heartbeat = setInterval(() => {
|
|
468
775
|
if (!res.write(`: heartbeat ${Date.now()}\n\n`))
|
|
@@ -476,12 +783,28 @@ export async function startWebServer(options = {}) {
|
|
|
476
783
|
app.use("/api", (_req, res) => {
|
|
477
784
|
res.status(404).json({ error: { code: "not_found", message: "Not found." } });
|
|
478
785
|
});
|
|
479
|
-
|
|
786
|
+
// Vite fingerprints everything under assets/, so those URLs are immutable and
|
|
787
|
+
// must be mounted ahead of the revalidating root. The root then covers the
|
|
788
|
+
// service workers, icons, and the shell, which keep their names across builds
|
|
789
|
+
// and so have to be revalidated (cheaply, against their ETag) every load.
|
|
790
|
+
app.use("/assets", express.static(join(staticDir, "assets"), {
|
|
791
|
+
fallthrough: true,
|
|
792
|
+
index: false,
|
|
793
|
+
redirect: false,
|
|
794
|
+
immutable: true,
|
|
795
|
+
maxAge: IMMUTABLE_MAX_AGE_SECONDS * 1000,
|
|
796
|
+
}));
|
|
797
|
+
app.use(express.static(staticDir, {
|
|
798
|
+
fallthrough: true,
|
|
799
|
+
index: false,
|
|
800
|
+
redirect: false,
|
|
801
|
+
setHeaders: (res) => res.setHeader("Cache-Control", "no-cache"),
|
|
802
|
+
}));
|
|
480
803
|
app.get("/{*splat}", (_req, res, next) => {
|
|
481
804
|
// Keep the managed runtime's hidden ~/.mono-agent parent out of the
|
|
482
805
|
// request-relative path. Express otherwise applies its dotfile policy to
|
|
483
806
|
// the absolute path and rejects an existing index.html as Not Found.
|
|
484
|
-
res.sendFile("index.html", { root: staticDir }, (error) => {
|
|
807
|
+
res.sendFile("index.html", { root: staticDir, headers: { "Cache-Control": "no-cache" } }, (error) => {
|
|
485
808
|
if (error !== undefined && error !== null)
|
|
486
809
|
next(error);
|
|
487
810
|
});
|
|
@@ -497,6 +820,12 @@ export async function startWebServer(options = {}) {
|
|
|
497
820
|
&& (error.status === 413 || error.type === "entity.too.large");
|
|
498
821
|
const status = known ? error.status : tooLarge ? 413 : syntax ? 400 : 500;
|
|
499
822
|
const code = known ? error.code : tooLarge ? "request_too_large" : syntax ? "invalid_json" : "internal_error";
|
|
823
|
+
const retryAfterSeconds = known && typeof error.details?.retryAfterSeconds === "number"
|
|
824
|
+
? error.details.retryAfterSeconds
|
|
825
|
+
: undefined;
|
|
826
|
+
if (status === 429 && Number.isSafeInteger(retryAfterSeconds) && retryAfterSeconds > 0) {
|
|
827
|
+
res.setHeader("Retry-After", String(retryAfterSeconds));
|
|
828
|
+
}
|
|
500
829
|
if (status >= 500)
|
|
501
830
|
logger?.error?.("Web console request failed.", { error: errorMessage(error) });
|
|
502
831
|
res.status(status).json({
|
|
@@ -555,6 +884,28 @@ export async function startWebServer(options = {}) {
|
|
|
555
884
|
throw error;
|
|
556
885
|
}
|
|
557
886
|
}
|
|
887
|
+
/**
|
|
888
|
+
* Normalize the operator-chosen console label. Control characters are rejected rather
|
|
889
|
+
* than stripped so a typo cannot silently install under a different name than requested.
|
|
890
|
+
*/
|
|
891
|
+
function resolveWebConsoleName(value) {
|
|
892
|
+
if (value === undefined)
|
|
893
|
+
return undefined;
|
|
894
|
+
if (typeof value !== "string") {
|
|
895
|
+
throw new WebConsoleError("invalid_console_name", "Web console name must be a string.", 400);
|
|
896
|
+
}
|
|
897
|
+
const name = value.trim();
|
|
898
|
+
if (name.length === 0) {
|
|
899
|
+
throw new WebConsoleError("invalid_console_name", "Web console name must not be empty.", 400);
|
|
900
|
+
}
|
|
901
|
+
if (/[\u0000-\u001f\u007f-\u009f\u2028-\u202e]/u.test(name)) {
|
|
902
|
+
throw new WebConsoleError("invalid_console_name", "Web console name must not contain control characters, line separators, or bidirectional overrides.", 400);
|
|
903
|
+
}
|
|
904
|
+
if ([...name].length > WEB_CONSOLE_NAME_MAX_CHARACTERS) {
|
|
905
|
+
throw new WebConsoleError("invalid_console_name", `Web console name must be at most ${String(WEB_CONSOLE_NAME_MAX_CHARACTERS)} characters.`, 400);
|
|
906
|
+
}
|
|
907
|
+
return name;
|
|
908
|
+
}
|
|
558
909
|
function resolveWebTheme(value) {
|
|
559
910
|
if (value === undefined)
|
|
560
911
|
return DEFAULT_WEB_THEME;
|
|
@@ -584,13 +935,13 @@ async function loadWebManifest(staticDir, identity) {
|
|
|
584
935
|
if (template === null || typeof template !== "object" || Array.isArray(template)) {
|
|
585
936
|
throw new WebConsoleError("invalid_static_manifest", "The web console manifest must be a JSON object.", 500);
|
|
586
937
|
}
|
|
587
|
-
const
|
|
938
|
+
const colors = WEB_THEME_MANIFEST_COLORS[identity.theme];
|
|
588
939
|
return {
|
|
589
940
|
...template,
|
|
590
|
-
name: `${identity.
|
|
591
|
-
short_name: identity.
|
|
592
|
-
theme_color:
|
|
593
|
-
background_color:
|
|
941
|
+
name: `${identity.displayName} · mono-agent Console`,
|
|
942
|
+
short_name: identity.displayName,
|
|
943
|
+
theme_color: colors.themeColor,
|
|
944
|
+
background_color: colors.backgroundColor,
|
|
594
945
|
};
|
|
595
946
|
}
|
|
596
947
|
function trackOperation(operation, active) {
|
|
@@ -690,6 +1041,10 @@ async function handleDownloadContent(id, res, service) {
|
|
|
690
1041
|
res.status(200);
|
|
691
1042
|
res.setHeader("Content-Type", image ? attachment.contentType : "application/octet-stream");
|
|
692
1043
|
res.setHeader("Content-Length", String(info.size));
|
|
1044
|
+
// An upload id is a fresh UUID whose bytes are written exactly once, so this
|
|
1045
|
+
// URL can never change meaning. `no-transform` keeps the declared length
|
|
1046
|
+
// honest for the client that streams these bytes back into a Blob.
|
|
1047
|
+
res.setHeader("Cache-Control", `private, max-age=${IMMUTABLE_MAX_AGE_SECONDS}, immutable, no-transform`);
|
|
693
1048
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
694
1049
|
res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
|
|
695
1050
|
res.setHeader("Content-Disposition", contentDisposition(attachment.name, image ? "inline" : "attachment"));
|
|
@@ -707,7 +1062,9 @@ function replyAccessQuery(req) {
|
|
|
707
1062
|
}
|
|
708
1063
|
return { expires, token };
|
|
709
1064
|
}
|
|
710
|
-
function setReplyDownloadHeaders(res, part
|
|
1065
|
+
function setReplyDownloadHeaders(res, part,
|
|
1066
|
+
/** Seconds this response's own capability is still good for. */
|
|
1067
|
+
maxAgeSeconds) {
|
|
711
1068
|
const risky = /^(?:text\/(?:html|javascript|xml)|application\/(?:javascript|xhtml\+xml|xml)|image\/svg\+xml)$/iu
|
|
712
1069
|
.test(part.mediaType);
|
|
713
1070
|
res.status(200);
|
|
@@ -717,7 +1074,16 @@ function setReplyDownloadHeaders(res, part) {
|
|
|
717
1074
|
res.setHeader("Content-Length", String(part.sizeBytes));
|
|
718
1075
|
res.setHeader("Accept-Ranges", "none");
|
|
719
1076
|
res.setHeader("Content-Disposition", contentDisposition(part.name, "attachment"));
|
|
720
|
-
|
|
1077
|
+
// The client refuses a part whose Content-Length disagrees with the declared
|
|
1078
|
+
// size, so this response must reach it byte-for-byte -- hence `no-transform`.
|
|
1079
|
+
//
|
|
1080
|
+
// Cacheable for exactly as long as the signed key in the URL is: the mint
|
|
1081
|
+
// quantises its expiry to a five-minute bucket, so the same picture asked for
|
|
1082
|
+
// twice inside one bucket is the same URL and the browser answers the second
|
|
1083
|
+
// read itself. `private`, because the URL is a capability and no shared cache
|
|
1084
|
+
// may keep it; never past the key, because the response is only servable while
|
|
1085
|
+
// the key is.
|
|
1086
|
+
res.setHeader("Cache-Control", `private, max-age=${String(maxAgeSeconds)}, no-transform`);
|
|
721
1087
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
722
1088
|
res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'");
|
|
723
1089
|
res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
|
@@ -845,7 +1211,31 @@ function securityHeaders(_req, res, next) {
|
|
|
845
1211
|
}
|
|
846
1212
|
function parseCreateThread(value) {
|
|
847
1213
|
const body = requireRecord(value);
|
|
848
|
-
|
|
1214
|
+
const model = optionalNullableString(body.model, "model", 120);
|
|
1215
|
+
const effort = optionalNullableString(body.effort, "effort", 120);
|
|
1216
|
+
return {
|
|
1217
|
+
sourceId: requireString(body.sourceId, "sourceId", 256),
|
|
1218
|
+
...(model === undefined ? {} : { model }),
|
|
1219
|
+
...(effort === undefined ? {} : { effort }),
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
function parsePutAgentRunSettings(value) {
|
|
1223
|
+
const body = requireRecord(value);
|
|
1224
|
+
if (!("model" in body) || !("effort" in body)) {
|
|
1225
|
+
throw invalidBody("model and effort are required and must be strings or null.");
|
|
1226
|
+
}
|
|
1227
|
+
const unknown = Object.keys(body).filter((key) => key !== "model" && key !== "effort");
|
|
1228
|
+
if (unknown.length > 0)
|
|
1229
|
+
throw invalidBody(`Unknown run-defaults field: ${unknown[0]}.`);
|
|
1230
|
+
const model = optionalNullableString(body.model, "model", 120);
|
|
1231
|
+
const effort = optionalNullableString(body.effort, "effort", 120);
|
|
1232
|
+
if (model === undefined || effort === undefined) {
|
|
1233
|
+
throw invalidBody("model and effort are required and must be strings or null.");
|
|
1234
|
+
}
|
|
1235
|
+
if (model === null && effort === null) {
|
|
1236
|
+
throw invalidBody("Choose a model or effort override, or use Revert to config.");
|
|
1237
|
+
}
|
|
1238
|
+
return { model, effort };
|
|
849
1239
|
}
|
|
850
1240
|
function parsePatchAgent(value) {
|
|
851
1241
|
const body = requireRecord(value);
|
|
@@ -856,12 +1246,25 @@ function parsePatchAgent(value) {
|
|
|
856
1246
|
function parsePatchThread(value) {
|
|
857
1247
|
const body = requireRecord(value);
|
|
858
1248
|
const title = optionalString(body.title, "title", 120);
|
|
1249
|
+
const model = optionalNullableString(body.model, "model", 120);
|
|
1250
|
+
const effort = optionalNullableString(body.effort, "effort", 120);
|
|
859
1251
|
const archived = body.archived;
|
|
860
1252
|
if (archived !== undefined && typeof archived !== "boolean")
|
|
861
1253
|
throw invalidBody("archived must be boolean.");
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1254
|
+
const ifRunConfigUnset = body.ifRunConfigUnset;
|
|
1255
|
+
if (ifRunConfigUnset !== undefined && typeof ifRunConfigUnset !== "boolean") {
|
|
1256
|
+
throw invalidBody("ifRunConfigUnset must be boolean.");
|
|
1257
|
+
}
|
|
1258
|
+
if (title === undefined && archived === undefined && model === undefined && effort === undefined) {
|
|
1259
|
+
throw invalidBody("Provide title, archived, model, or effort.");
|
|
1260
|
+
}
|
|
1261
|
+
return {
|
|
1262
|
+
...(title === undefined ? {} : { title }),
|
|
1263
|
+
...(archived === undefined ? {} : { archived }),
|
|
1264
|
+
...(model === undefined ? {} : { model }),
|
|
1265
|
+
...(effort === undefined ? {} : { effort }),
|
|
1266
|
+
...(ifRunConfigUnset === undefined ? {} : { ifRunConfigUnset }),
|
|
1267
|
+
};
|
|
865
1268
|
}
|
|
866
1269
|
function parseTurn(value) {
|
|
867
1270
|
const body = requireRecord(value);
|
|
@@ -889,6 +1292,29 @@ function parseTurn(value) {
|
|
|
889
1292
|
...(effort === undefined ? {} : { effort }),
|
|
890
1293
|
};
|
|
891
1294
|
}
|
|
1295
|
+
function parseSubmission(value) {
|
|
1296
|
+
const body = requireRecord(value);
|
|
1297
|
+
const submissionId = parseSubmissionId(body.submissionId);
|
|
1298
|
+
return { ...parseTurn(body), submissionId };
|
|
1299
|
+
}
|
|
1300
|
+
function parseSubmissionId(value) {
|
|
1301
|
+
const submissionId = requireString(value, "submissionId", 36);
|
|
1302
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(submissionId)) {
|
|
1303
|
+
throw invalidBody("submissionId must be a canonical UUID.");
|
|
1304
|
+
}
|
|
1305
|
+
return submissionId.toLowerCase();
|
|
1306
|
+
}
|
|
1307
|
+
function parseCronReply(value) {
|
|
1308
|
+
const body = requireRecord(value);
|
|
1309
|
+
if (Object.keys(body).sort().join("\0") !== ["operationId", "snapshotKind"].join("\0")) {
|
|
1310
|
+
throw invalidBody("Cron Reply requires only operationId and snapshotKind.");
|
|
1311
|
+
}
|
|
1312
|
+
const operationId = parseSubmissionId(body.operationId);
|
|
1313
|
+
if (body.snapshotKind !== "summary" && body.snapshotKind !== "detail") {
|
|
1314
|
+
throw invalidBody("snapshotKind must be summary or detail.");
|
|
1315
|
+
}
|
|
1316
|
+
return { operationId, snapshotKind: body.snapshotKind };
|
|
1317
|
+
}
|
|
892
1318
|
function parseLiveInput(value) {
|
|
893
1319
|
const body = requireRecord(value);
|
|
894
1320
|
return {
|
|
@@ -977,6 +1403,142 @@ function requireRecord(value) {
|
|
|
977
1403
|
throw invalidBody("JSON body must be an object.");
|
|
978
1404
|
return value;
|
|
979
1405
|
}
|
|
1406
|
+
function assertProviderAuthBody(value) {
|
|
1407
|
+
let bytes;
|
|
1408
|
+
try {
|
|
1409
|
+
bytes = Buffer.byteLength(JSON.stringify(value) ?? "null", "utf8");
|
|
1410
|
+
}
|
|
1411
|
+
catch {
|
|
1412
|
+
throw invalidBody("Provider authentication body must be JSON.");
|
|
1413
|
+
}
|
|
1414
|
+
if (bytes > MAX_PROVIDER_AUTH_BODY_BYTES) {
|
|
1415
|
+
throw new WebConsoleError("provider_auth_too_large", "Provider authentication body is too large.", 413);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* One connection's view of the event stream: which frames it is served, and in
|
|
1420
|
+
* what form.
|
|
1421
|
+
*
|
|
1422
|
+
* A console names the conversation it is looking at with `?thread=`. It is
|
|
1423
|
+
* served EVERYTHING about that conversation unthrottled -- `message.delta`
|
|
1424
|
+
* frames carrying what each write changed, and the `message.changed` hints the
|
|
1425
|
+
* delta path emits when it declines (a delta bigger than the message, a
|
|
1426
|
+
* reconciliation that describes nothing), because both are things it must act
|
|
1427
|
+
* on. Anything about any OTHER conversation is reduced to a
|
|
1428
|
+
* `message.changed { messageId, updatedAt }` and rate-limited to one per
|
|
1429
|
+
* conversation per {@link DELTA_HINT_INTERVAL_MS}, dropped rather than queued:
|
|
1430
|
+
* a console is not rendering it.
|
|
1431
|
+
*
|
|
1432
|
+
* A console that named NO conversation is not telling this stream which
|
|
1433
|
+
* conversation it is looking at, so it keeps every reconciliation hint and is
|
|
1434
|
+
* rate-limited only on what the delta path produces -- downgraded deltas and
|
|
1435
|
+
* the declines above -- which is the write-rate traffic the limit exists for. A
|
|
1436
|
+
* turn's `thread.changed` still follows every finish unthrottled, so a dropped
|
|
1437
|
+
* frame never leaves a settled conversation stale.
|
|
1438
|
+
*
|
|
1439
|
+
* Every other event type passes through untouched. `Last-Event-ID` is ignored
|
|
1440
|
+
* and nothing is replayed: this is state invalidation, not a log. A
|
|
1441
|
+
* reconnecting console bootstraps, and `ready` means resync the conversation it
|
|
1442
|
+
* has open.
|
|
1443
|
+
*
|
|
1444
|
+
* Lives outside the route because neither of the two things that matter is
|
|
1445
|
+
* observable through an HTTP fixture: the rate limit is a clock decision, and a
|
|
1446
|
+
* frame this connection cannot serialize or write must CLOSE it rather than
|
|
1447
|
+
* leave a socket that reads live and receives nothing.
|
|
1448
|
+
*/
|
|
1449
|
+
export function createWebEventDispatch(options) {
|
|
1450
|
+
const { subscribed, write, close, onFailure } = options;
|
|
1451
|
+
const now = options.now ?? (() => Date.now());
|
|
1452
|
+
// Per connection, and cleared with it: what one console has already been told
|
|
1453
|
+
// about is no reason to keep another quiet.
|
|
1454
|
+
const hintedAt = new Map();
|
|
1455
|
+
/** How many frames this connection has minted an id for. */
|
|
1456
|
+
let downgrades = 0;
|
|
1457
|
+
const hint = (event, payload) => {
|
|
1458
|
+
const downgraded = event.type !== "message.changed" || payload.deltaDeclined === true;
|
|
1459
|
+
if (downgraded)
|
|
1460
|
+
downgrades += 1;
|
|
1461
|
+
return write({
|
|
1462
|
+
...event,
|
|
1463
|
+
// A frame this layer rewrote is a DIFFERENT frame -- another type, another
|
|
1464
|
+
// payload -- and `id:` is what names one on the wire. Sending it under the
|
|
1465
|
+
// source event's id had one id naming two frames, which a console that
|
|
1466
|
+
// deduplicates by id would read as one. Derived from the source so it
|
|
1467
|
+
// stays traceable, and unique per connection because that is the scope the
|
|
1468
|
+
// id is read in.
|
|
1469
|
+
...(downgraded ? { id: `${event.id}#${String(downgrades)}` } : {}),
|
|
1470
|
+
type: "message.changed",
|
|
1471
|
+
// `deltaDeclined` is this layer's own signal and stops here.
|
|
1472
|
+
payload: { messageId: payload.messageId, updatedAt: payload.updatedAt },
|
|
1473
|
+
});
|
|
1474
|
+
};
|
|
1475
|
+
const rateLimited = (event, payload) => {
|
|
1476
|
+
const key = event.threadId ?? "";
|
|
1477
|
+
const at = now();
|
|
1478
|
+
const last = hintedAt.get(key);
|
|
1479
|
+
// Dropped, not queued: a hint says only that the message moved, so the one
|
|
1480
|
+
// that was suppressed is answered by the one that follows it.
|
|
1481
|
+
if (last !== undefined && at - last < DELTA_HINT_INTERVAL_MS)
|
|
1482
|
+
return true;
|
|
1483
|
+
// Delete before set, so insertion order IS recency order and the eviction
|
|
1484
|
+
// below drops the conversation this connection heard about longest ago.
|
|
1485
|
+
hintedAt.delete(key);
|
|
1486
|
+
hintedAt.set(key, at);
|
|
1487
|
+
while (hintedAt.size > MAX_THROTTLED_CONVERSATIONS) {
|
|
1488
|
+
const oldest = hintedAt.keys().next();
|
|
1489
|
+
if (oldest.done === true)
|
|
1490
|
+
break;
|
|
1491
|
+
hintedAt.delete(oldest.value);
|
|
1492
|
+
}
|
|
1493
|
+
return hint(event, payload);
|
|
1494
|
+
};
|
|
1495
|
+
return (event) => {
|
|
1496
|
+
try {
|
|
1497
|
+
if (event.type !== "message.delta" && event.type !== "message.changed")
|
|
1498
|
+
return write(event);
|
|
1499
|
+
const own = event.threadId !== undefined && event.threadId === subscribed;
|
|
1500
|
+
if (event.type === "message.delta") {
|
|
1501
|
+
const delta = requireMessageDelta(event.payload);
|
|
1502
|
+
return own ? write(event) : rateLimited(event, delta);
|
|
1503
|
+
}
|
|
1504
|
+
const payload = requireMessageChanged(event.payload);
|
|
1505
|
+
if (own)
|
|
1506
|
+
return hint(event, payload);
|
|
1507
|
+
if (subscribed === undefined && payload.deltaDeclined !== true)
|
|
1508
|
+
return hint(event, payload);
|
|
1509
|
+
return rateLimited(event, payload);
|
|
1510
|
+
}
|
|
1511
|
+
catch (error) {
|
|
1512
|
+
// A frame this connection cannot make sense of is a server bug, and the
|
|
1513
|
+
// honest answer is to end the stream so the console reconnects and
|
|
1514
|
+
// bootstraps -- never to drop the frame and keep a live-looking socket.
|
|
1515
|
+
onFailure?.(error);
|
|
1516
|
+
close();
|
|
1517
|
+
return false;
|
|
1518
|
+
}
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
function requireMessageDelta(payload) {
|
|
1522
|
+
const delta = payload;
|
|
1523
|
+
if (delta === null || delta === undefined || typeof delta !== "object"
|
|
1524
|
+
|| typeof delta.messageId !== "string"
|
|
1525
|
+
|| typeof delta.updatedAt !== "string"
|
|
1526
|
+
|| typeof delta.baseSeq !== "number"
|
|
1527
|
+
|| typeof delta.seq !== "number"
|
|
1528
|
+
|| !Array.isArray(delta.ops)) {
|
|
1529
|
+
throw new TypeError("A message.delta event carried no delta.");
|
|
1530
|
+
}
|
|
1531
|
+
return delta;
|
|
1532
|
+
}
|
|
1533
|
+
function requireMessageChanged(payload) {
|
|
1534
|
+
const changed = payload;
|
|
1535
|
+
if (changed === null || changed === undefined || typeof changed !== "object"
|
|
1536
|
+
|| typeof changed.messageId !== "string"
|
|
1537
|
+
|| typeof changed.updatedAt !== "string") {
|
|
1538
|
+
throw new TypeError("A message.changed event named no message.");
|
|
1539
|
+
}
|
|
1540
|
+
return changed;
|
|
1541
|
+
}
|
|
980
1542
|
function requiredQueryString(value, field, max) {
|
|
981
1543
|
if (typeof value !== "string" || value.trim().length === 0 || value.length > max) {
|
|
982
1544
|
throw new WebConsoleError("invalid_page", `${field} is required.`, 400);
|
|
@@ -996,6 +1558,34 @@ function optionalSearchQuery(value, max) {
|
|
|
996
1558
|
}
|
|
997
1559
|
return value;
|
|
998
1560
|
}
|
|
1561
|
+
/**
|
|
1562
|
+
* The conversation a console subscribes its event stream to.
|
|
1563
|
+
*
|
|
1564
|
+
* Its own code, not the pagination one: an invalid `?thread=` is a bad
|
|
1565
|
+
* subscription, and answering it with "Pagination cursor is invalid." sent a
|
|
1566
|
+
* console looking for a cursor it never sent.
|
|
1567
|
+
*/
|
|
1568
|
+
function optionalThreadSubscription(value) {
|
|
1569
|
+
if (value === undefined)
|
|
1570
|
+
return undefined;
|
|
1571
|
+
if (typeof value !== "string") {
|
|
1572
|
+
throw invalidSubscription();
|
|
1573
|
+
}
|
|
1574
|
+
// The TRIMMED id is the subject of every rule here: it is what the dispatch
|
|
1575
|
+
// compares against `event.threadId`, so it is what the length must bound and
|
|
1576
|
+
// what is returned. Returning the padded string matched nothing and served
|
|
1577
|
+
// hints for the conversation on screen.
|
|
1578
|
+
const named = value.trim();
|
|
1579
|
+
// Blank is refused rather than read as "no subscription": `?thread=` and
|
|
1580
|
+
// `?thread=%20` are a console asking for a conversation and getting one it
|
|
1581
|
+
// never named, which would look like the delta stream had simply stopped.
|
|
1582
|
+
if (named.length === 0 || named.length > MAX_SUBSCRIPTION_LENGTH)
|
|
1583
|
+
throw invalidSubscription();
|
|
1584
|
+
return named;
|
|
1585
|
+
}
|
|
1586
|
+
function invalidSubscription() {
|
|
1587
|
+
return new WebConsoleError("invalid_subscription", `thread must name one conversation, in at most ${String(MAX_SUBSCRIPTION_LENGTH)} characters.`, 400);
|
|
1588
|
+
}
|
|
999
1589
|
function optionalQueryString(value, max) {
|
|
1000
1590
|
if (value === undefined)
|
|
1001
1591
|
return undefined;
|
|
@@ -1004,6 +1594,35 @@ function optionalQueryString(value, max) {
|
|
|
1004
1594
|
}
|
|
1005
1595
|
return value;
|
|
1006
1596
|
}
|
|
1597
|
+
/** `true`/`false`, or `undefined` for a query that said neither. */
|
|
1598
|
+
function optionalArchivedQuery(value) {
|
|
1599
|
+
if (value === "true")
|
|
1600
|
+
return true;
|
|
1601
|
+
if (value === "false")
|
|
1602
|
+
return false;
|
|
1603
|
+
if (value === undefined)
|
|
1604
|
+
return undefined;
|
|
1605
|
+
throw new WebConsoleError("invalid_page", "archived must be true or false.", 400);
|
|
1606
|
+
}
|
|
1607
|
+
function optionalEmptyOnlyQuery(value) {
|
|
1608
|
+
if (value === undefined)
|
|
1609
|
+
return false;
|
|
1610
|
+
if (value === "true")
|
|
1611
|
+
return true;
|
|
1612
|
+
throw new WebConsoleError("invalid_delete", "emptyOnly must be true when provided.", 400);
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* `?full=1` (or `full=true`) turns the transcript diet off for one read: no
|
|
1616
|
+
* truncated tool payloads, no stripped telemetry. Anything else, including an
|
|
1617
|
+
* absent parameter, keeps the shaped transcript.
|
|
1618
|
+
*
|
|
1619
|
+
* It changes the SHAPE of the messages a read answers with, never how many: a
|
|
1620
|
+
* full conversation read still answers with one page and the rest still comes
|
|
1621
|
+
* from `messagesNextCursor` (or an explicit `limit`).
|
|
1622
|
+
*/
|
|
1623
|
+
function fullTranscriptQuery(value) {
|
|
1624
|
+
return value === "1" || value === "true" ? { full: true } : {};
|
|
1625
|
+
}
|
|
1007
1626
|
function boundedQueryLimit(value, maximum, fallback) {
|
|
1008
1627
|
if (value === undefined)
|
|
1009
1628
|
return fallback;
|
|
@@ -1031,6 +1650,14 @@ function requireString(value, field, max, allowEmpty = false) {
|
|
|
1031
1650
|
function optionalString(value, field, max) {
|
|
1032
1651
|
return value === undefined ? undefined : requireString(value, field, max);
|
|
1033
1652
|
}
|
|
1653
|
+
/** Optional override field: absent (`undefined`), cleared (`null`), or a bounded string. */
|
|
1654
|
+
function optionalNullableString(value, field, max) {
|
|
1655
|
+
if (value === undefined)
|
|
1656
|
+
return undefined;
|
|
1657
|
+
if (value === null)
|
|
1658
|
+
return null;
|
|
1659
|
+
return requireString(value, field, max);
|
|
1660
|
+
}
|
|
1034
1661
|
function invalidBody(message) {
|
|
1035
1662
|
return new WebConsoleError("invalid_request", message, 400);
|
|
1036
1663
|
}
|