@mono-agent/web 0.20.14 → 0.21.1
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 +388 -70
- package/dist/contracts.d.ts +364 -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 +14 -0
- package/dist/effort-ladder.d.ts.map +1 -1
- package/dist/effort-ladder.js +41 -0
- package/dist/effort-ladder.js.map +1 -1
- package/dist/index.d.ts +2 -2
- 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.map +1 -1
- package/dist/notification-client.js +5 -4
- package/dist/notification-client.js.map +1 -1
- package/dist/operator-client.d.ts +25 -1
- package/dist/operator-client.d.ts.map +1 -1
- package/dist/operator-client.js +167 -8
- 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 +627 -49
- package/dist/server.js.map +1 -1
- package/dist/service.d.ts +201 -8
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +1425 -136
- 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 +294 -17
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +1637 -263
- package/dist/store.js.map +1 -1
- package/package.json +8 -5
- package/webapp/dist/assets/{assistant-ui-BzN2E6n6.js → assistant-ui-pZmGxIp2.js} +16 -16
- package/webapp/dist/assets/index-0v07DD8D.css +1 -0
- package/webapp/dist/assets/index-DJXY5RPz.js +155 -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-C4a2Dv1W.js +0 -155
- package/webapp/dist/assets/index-mhMBLGB0.css +0 -1
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)));
|
|
@@ -150,6 +325,18 @@ export async function startWebServer(options = {}) {
|
|
|
150
325
|
app.get("/api/v1/agents/:id/cron/jobs/:jobId/runs/:runId", (req, res, next) => {
|
|
151
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);
|
|
152
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
|
+
});
|
|
153
340
|
app.post("/api/v1/agents/:id/cron/jobs/:jobId/run", (req, res, next) => {
|
|
154
341
|
try {
|
|
155
342
|
exactRequestOrigin(req);
|
|
@@ -177,7 +364,10 @@ export async function startWebServer(options = {}) {
|
|
|
177
364
|
app.post("/api/v1/threads", (req, res, next) => {
|
|
178
365
|
try {
|
|
179
366
|
const input = parseCreateThread(req.body);
|
|
180
|
-
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
|
+
}) });
|
|
181
371
|
}
|
|
182
372
|
catch (error) {
|
|
183
373
|
next(error);
|
|
@@ -186,16 +376,17 @@ export async function startWebServer(options = {}) {
|
|
|
186
376
|
app.get("/api/v1/threads", (req, res, next) => {
|
|
187
377
|
try {
|
|
188
378
|
const sourceId = requiredQueryString(req.query.sourceId, "sourceId", 512);
|
|
189
|
-
const archived = req.query.archived
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
: (() => { 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
|
+
}
|
|
194
383
|
const before = optionalQueryString(req.query.before, 4_096);
|
|
195
384
|
res.status(200).json(service.threadsPage({
|
|
196
385
|
sourceId,
|
|
197
386
|
archived,
|
|
198
|
-
|
|
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),
|
|
199
390
|
...(before === undefined ? {} : { before }),
|
|
200
391
|
}));
|
|
201
392
|
}
|
|
@@ -218,9 +409,35 @@ export async function startWebServer(options = {}) {
|
|
|
218
409
|
next(error);
|
|
219
410
|
}
|
|
220
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
|
+
});
|
|
221
438
|
app.get("/api/v1/threads/:id", (req, res, next) => {
|
|
222
439
|
try {
|
|
223
|
-
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)));
|
|
224
441
|
}
|
|
225
442
|
catch (error) {
|
|
226
443
|
next(error);
|
|
@@ -230,8 +447,11 @@ export async function startWebServer(options = {}) {
|
|
|
230
447
|
try {
|
|
231
448
|
const before = optionalQueryString(req.query.before, 4_096);
|
|
232
449
|
res.status(200).json(service.messagePage(pathParam(req.params.id), {
|
|
233
|
-
|
|
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),
|
|
234
453
|
...(before === undefined ? {} : { before }),
|
|
454
|
+
...fullTranscriptQuery(req.query.full),
|
|
235
455
|
}));
|
|
236
456
|
}
|
|
237
457
|
catch (error) {
|
|
@@ -255,10 +475,10 @@ export async function startWebServer(options = {}) {
|
|
|
255
475
|
if (!res.writableEnded)
|
|
256
476
|
controller.abort(new Error("Reply attachment client disconnected."));
|
|
257
477
|
});
|
|
258
|
-
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 }) => {
|
|
259
479
|
if (response.body === null)
|
|
260
480
|
throw new WebConsoleError("reply_attachment_unavailable", "Attachment stream is unavailable.", 502);
|
|
261
|
-
setReplyDownloadHeaders(res, part);
|
|
481
|
+
setReplyDownloadHeaders(res, part, remainingSeconds);
|
|
262
482
|
await pipeline(Readable.fromWeb(response.body), res);
|
|
263
483
|
}), activeOperations).catch((error) => {
|
|
264
484
|
if (res.headersSent) {
|
|
@@ -332,9 +552,15 @@ export async function startWebServer(options = {}) {
|
|
|
332
552
|
}
|
|
333
553
|
});
|
|
334
554
|
app.delete("/api/v1/threads/:id", (req, res, next) => {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
.
|
|
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
|
+
}
|
|
338
564
|
});
|
|
339
565
|
app.post("/api/v1/threads/:id/turns", (req, res, next) => {
|
|
340
566
|
let input;
|
|
@@ -351,6 +577,25 @@ export async function startWebServer(options = {}) {
|
|
|
351
577
|
.then((started) => res.status(202).json(started))
|
|
352
578
|
.catch(next);
|
|
353
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
|
+
});
|
|
354
599
|
app.post("/api/v1/threads/:id/live-input", (req, res, next) => {
|
|
355
600
|
try {
|
|
356
601
|
const input = parseLiveInput(req.body);
|
|
@@ -458,11 +703,40 @@ export async function startWebServer(options = {}) {
|
|
|
458
703
|
app.get("/api/v1/uploads/:id/content", (req, res, next) => {
|
|
459
704
|
void trackOperation(handleDownloadContent(pathParam(req.params.id), res, service), activeOperations).catch(next);
|
|
460
705
|
});
|
|
461
|
-
|
|
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
|
+
}
|
|
462
720
|
if (activeStreams.size >= MAX_SSE_CLIENTS) {
|
|
463
721
|
res.status(503).json({ error: { code: "sse_capacity", message: "Too many event streams are connected." } });
|
|
464
722
|
return;
|
|
465
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
|
+
}
|
|
466
740
|
let closed = false;
|
|
467
741
|
res.status(200);
|
|
468
742
|
res.setHeader("Content-Type", "text/event-stream");
|
|
@@ -479,18 +753,23 @@ export async function startWebServer(options = {}) {
|
|
|
479
753
|
activeStreams.delete(closeStream);
|
|
480
754
|
res.end();
|
|
481
755
|
};
|
|
482
|
-
const send = (
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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;
|
|
487
763
|
// Events are state-invalidation hints, not an unbounded replay log. A
|
|
488
764
|
// client that cannot drain one frame must reconnect and bootstrap.
|
|
489
765
|
closeStream();
|
|
490
766
|
return false;
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
|
|
767
|
+
},
|
|
768
|
+
close: closeStream,
|
|
769
|
+
onFailure: (error) => {
|
|
770
|
+
logger?.error?.("Web console event stream failed.", { error: errorMessage(error) });
|
|
771
|
+
},
|
|
772
|
+
});
|
|
494
773
|
const unsubscribe = service.subscribe(send);
|
|
495
774
|
const heartbeat = setInterval(() => {
|
|
496
775
|
if (!res.write(`: heartbeat ${Date.now()}\n\n`))
|
|
@@ -504,12 +783,28 @@ export async function startWebServer(options = {}) {
|
|
|
504
783
|
app.use("/api", (_req, res) => {
|
|
505
784
|
res.status(404).json({ error: { code: "not_found", message: "Not found." } });
|
|
506
785
|
});
|
|
507
|
-
|
|
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
|
+
}));
|
|
508
803
|
app.get("/{*splat}", (_req, res, next) => {
|
|
509
804
|
// Keep the managed runtime's hidden ~/.mono-agent parent out of the
|
|
510
805
|
// request-relative path. Express otherwise applies its dotfile policy to
|
|
511
806
|
// the absolute path and rejects an existing index.html as Not Found.
|
|
512
|
-
res.sendFile("index.html", { root: staticDir }, (error) => {
|
|
807
|
+
res.sendFile("index.html", { root: staticDir, headers: { "Cache-Control": "no-cache" } }, (error) => {
|
|
513
808
|
if (error !== undefined && error !== null)
|
|
514
809
|
next(error);
|
|
515
810
|
});
|
|
@@ -525,6 +820,12 @@ export async function startWebServer(options = {}) {
|
|
|
525
820
|
&& (error.status === 413 || error.type === "entity.too.large");
|
|
526
821
|
const status = known ? error.status : tooLarge ? 413 : syntax ? 400 : 500;
|
|
527
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
|
+
}
|
|
528
829
|
if (status >= 500)
|
|
529
830
|
logger?.error?.("Web console request failed.", { error: errorMessage(error) });
|
|
530
831
|
res.status(status).json({
|
|
@@ -583,6 +884,28 @@ export async function startWebServer(options = {}) {
|
|
|
583
884
|
throw error;
|
|
584
885
|
}
|
|
585
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
|
+
}
|
|
586
909
|
function resolveWebTheme(value) {
|
|
587
910
|
if (value === undefined)
|
|
588
911
|
return DEFAULT_WEB_THEME;
|
|
@@ -612,13 +935,13 @@ async function loadWebManifest(staticDir, identity) {
|
|
|
612
935
|
if (template === null || typeof template !== "object" || Array.isArray(template)) {
|
|
613
936
|
throw new WebConsoleError("invalid_static_manifest", "The web console manifest must be a JSON object.", 500);
|
|
614
937
|
}
|
|
615
|
-
const
|
|
938
|
+
const colors = WEB_THEME_MANIFEST_COLORS[identity.theme];
|
|
616
939
|
return {
|
|
617
940
|
...template,
|
|
618
|
-
name: `${identity.
|
|
619
|
-
short_name: identity.
|
|
620
|
-
theme_color:
|
|
621
|
-
background_color:
|
|
941
|
+
name: `${identity.displayName} · mono-agent Console`,
|
|
942
|
+
short_name: identity.displayName,
|
|
943
|
+
theme_color: colors.themeColor,
|
|
944
|
+
background_color: colors.backgroundColor,
|
|
622
945
|
};
|
|
623
946
|
}
|
|
624
947
|
function trackOperation(operation, active) {
|
|
@@ -718,6 +1041,10 @@ async function handleDownloadContent(id, res, service) {
|
|
|
718
1041
|
res.status(200);
|
|
719
1042
|
res.setHeader("Content-Type", image ? attachment.contentType : "application/octet-stream");
|
|
720
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`);
|
|
721
1048
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
722
1049
|
res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
|
|
723
1050
|
res.setHeader("Content-Disposition", contentDisposition(attachment.name, image ? "inline" : "attachment"));
|
|
@@ -735,7 +1062,9 @@ function replyAccessQuery(req) {
|
|
|
735
1062
|
}
|
|
736
1063
|
return { expires, token };
|
|
737
1064
|
}
|
|
738
|
-
function setReplyDownloadHeaders(res, part
|
|
1065
|
+
function setReplyDownloadHeaders(res, part,
|
|
1066
|
+
/** Seconds this response's own capability is still good for. */
|
|
1067
|
+
maxAgeSeconds) {
|
|
739
1068
|
const risky = /^(?:text\/(?:html|javascript|xml)|application\/(?:javascript|xhtml\+xml|xml)|image\/svg\+xml)$/iu
|
|
740
1069
|
.test(part.mediaType);
|
|
741
1070
|
res.status(200);
|
|
@@ -745,7 +1074,16 @@ function setReplyDownloadHeaders(res, part) {
|
|
|
745
1074
|
res.setHeader("Content-Length", String(part.sizeBytes));
|
|
746
1075
|
res.setHeader("Accept-Ranges", "none");
|
|
747
1076
|
res.setHeader("Content-Disposition", contentDisposition(part.name, "attachment"));
|
|
748
|
-
|
|
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`);
|
|
749
1087
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
750
1088
|
res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'");
|
|
751
1089
|
res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
|
@@ -873,7 +1211,31 @@ function securityHeaders(_req, res, next) {
|
|
|
873
1211
|
}
|
|
874
1212
|
function parseCreateThread(value) {
|
|
875
1213
|
const body = requireRecord(value);
|
|
876
|
-
|
|
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 };
|
|
877
1239
|
}
|
|
878
1240
|
function parsePatchAgent(value) {
|
|
879
1241
|
const body = requireRecord(value);
|
|
@@ -930,6 +1292,29 @@ function parseTurn(value) {
|
|
|
930
1292
|
...(effort === undefined ? {} : { effort }),
|
|
931
1293
|
};
|
|
932
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
|
+
}
|
|
933
1318
|
function parseLiveInput(value) {
|
|
934
1319
|
const body = requireRecord(value);
|
|
935
1320
|
return {
|
|
@@ -1018,6 +1403,142 @@ function requireRecord(value) {
|
|
|
1018
1403
|
throw invalidBody("JSON body must be an object.");
|
|
1019
1404
|
return value;
|
|
1020
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
|
+
}
|
|
1021
1542
|
function requiredQueryString(value, field, max) {
|
|
1022
1543
|
if (typeof value !== "string" || value.trim().length === 0 || value.length > max) {
|
|
1023
1544
|
throw new WebConsoleError("invalid_page", `${field} is required.`, 400);
|
|
@@ -1037,6 +1558,34 @@ function optionalSearchQuery(value, max) {
|
|
|
1037
1558
|
}
|
|
1038
1559
|
return value;
|
|
1039
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
|
+
}
|
|
1040
1589
|
function optionalQueryString(value, max) {
|
|
1041
1590
|
if (value === undefined)
|
|
1042
1591
|
return undefined;
|
|
@@ -1045,6 +1594,35 @@ function optionalQueryString(value, max) {
|
|
|
1045
1594
|
}
|
|
1046
1595
|
return value;
|
|
1047
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
|
+
}
|
|
1048
1626
|
function boundedQueryLimit(value, maximum, fallback) {
|
|
1049
1627
|
if (value === undefined)
|
|
1050
1628
|
return fallback;
|