@mono-agent/web 0.19.1 → 0.20.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 +123 -21
- package/dist/contracts.d.ts +115 -4
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +1 -1
- package/dist/contracts.js.map +1 -1
- package/dist/discovery.d.ts +1 -0
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +43 -1
- package/dist/discovery.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-app-document.d.ts +15 -0
- package/dist/mcp-app-document.d.ts.map +1 -0
- package/dist/mcp-app-document.js +42 -0
- package/dist/mcp-app-document.js.map +1 -0
- package/dist/mcp-app-proxy.d.ts +20 -0
- package/dist/mcp-app-proxy.d.ts.map +1 -0
- package/dist/mcp-app-proxy.js +321 -0
- package/dist/mcp-app-proxy.js.map +1 -0
- package/dist/notification-client.d.ts +18 -4
- package/dist/notification-client.d.ts.map +1 -1
- package/dist/notification-client.js +13 -4
- package/dist/notification-client.js.map +1 -1
- package/dist/notification-ingress.d.ts +19 -0
- package/dist/notification-ingress.d.ts.map +1 -1
- package/dist/notification-ingress.js +68 -6
- package/dist/notification-ingress.js.map +1 -1
- package/dist/operator-client.d.ts +51 -2
- package/dist/operator-client.d.ts.map +1 -1
- package/dist/operator-client.js +349 -2
- package/dist/operator-client.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +244 -0
- package/dist/server.js.map +1 -1
- package/dist/service.d.ts +81 -5
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +401 -30
- package/dist/service.js.map +1 -1
- package/dist/store.d.ts +79 -7
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +2046 -162
- package/dist/store.js.map +1 -1
- package/package.json +4 -4
- package/webapp/dist/assets/index-CNY5_en5.css +1 -0
- package/webapp/dist/assets/index-Dl-iLQzF.js +152 -0
- package/webapp/dist/index.html +2 -2
- package/webapp/dist/sw.js +1 -1
- package/webapp/dist/assets/index-DBk-VjNE.css +0 -1
- package/webapp/dist/assets/index-DntvgDjy.js +0 -51
package/dist/service.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
|
-
import { DEFAULT_AGENT_ATTACHMENT_MAX_BYTES, DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST, toolNameLeaf, } from "@mono-agent/agent-contracts";
|
|
3
|
+
import { DEFAULT_AGENT_ATTACHMENT_MAX_BYTES, DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST, createChannelUserCancelReason, isChannelUserCancelReason, toolNameLeaf, } from "@mono-agent/agent-contracts";
|
|
4
4
|
import { EFFORT_LEVELS } from "@mono-agent/config";
|
|
5
5
|
import { WEB_API_VERSION, WEB_MAX_CONCURRENT_UPLOADS, WEB_MAX_ACTIVE_ATTACHMENT_TURN_BYTES, WEB_MAX_FILES_PER_TURN, WEB_MAX_STAGED_UPLOAD_BYTES, WEB_MAX_STAGED_UPLOADS, WEB_MAX_QUEUED_ATTACHMENT_TURNS, WEB_MAX_TURN_TEXT_CHARACTERS, WEB_MAX_TURN_ATTACHMENT_BYTES, WEB_STAGED_UPLOAD_TTL_MS, } from "./contracts.js";
|
|
6
6
|
import { discoverOperatorAgents, } from "./discovery.js";
|
|
@@ -8,11 +8,12 @@ import { errorCode, errorMessage, WebConsoleError } from "./errors.js";
|
|
|
8
8
|
import { OperatorClient } from "./operator-client.js";
|
|
9
9
|
import { generateWebPushIdentity, normalizeWebPushEndpoint, resolveWebPushSubject, validateWebPushEndpoint, validateWebPushKeys, WebPushDispatcher, WEB_PUSH_SERVICE_WORKER_VERSION, } from "./push.js";
|
|
10
10
|
import { acquireWebStateLease, prepareWebStatePaths } from "./state-paths.js";
|
|
11
|
-
import { toWebAttachment, WebStore, } from "./store.js";
|
|
11
|
+
import { cronChannelReadOnlyError, toWebAttachment, WebStore, notificationPushLogicalKey, } from "./store.js";
|
|
12
12
|
const DEFAULT_DISCOVERY_INTERVAL_MS = 5_000;
|
|
13
13
|
const DEFAULT_PURGE_INTERVAL_MS = 60 * 60 * 1_000;
|
|
14
14
|
const INFO_TIMEOUT_MS = 2_500;
|
|
15
15
|
const ASK_DISCOVERY_TIMEOUT_MS = 120_000;
|
|
16
|
+
const REPLY_ACCESS_TTL_MS = 10 * 60 * 1_000;
|
|
16
17
|
function formatQuotedTurn(quote, text) {
|
|
17
18
|
const blockquote = quote
|
|
18
19
|
.trim()
|
|
@@ -36,6 +37,7 @@ export class WebService {
|
|
|
36
37
|
pushIdentity;
|
|
37
38
|
pushDispatcher;
|
|
38
39
|
pushAckKey = randomBytes(32);
|
|
40
|
+
replyAccessKey;
|
|
39
41
|
askWatches = new Map();
|
|
40
42
|
connections = new Map();
|
|
41
43
|
discoveryTimer;
|
|
@@ -45,11 +47,12 @@ export class WebService {
|
|
|
45
47
|
refreshController;
|
|
46
48
|
eventSequence = 0;
|
|
47
49
|
stopped = false;
|
|
48
|
-
constructor(store, lease, options, pushIdentity, pushSubject) {
|
|
50
|
+
constructor(store, lease, options, pushIdentity, pushSubject, replyAccessKey) {
|
|
49
51
|
this.store = store;
|
|
50
52
|
this.lease = lease;
|
|
51
53
|
this.options = options;
|
|
52
54
|
this.pushIdentity = pushIdentity;
|
|
55
|
+
this.replyAccessKey = replyAccessKey;
|
|
53
56
|
this.attachmentTurnBudget = new WeightedTurnBudget(options.maxActiveAttachmentTurnBytes ?? WEB_MAX_ACTIVE_ATTACHMENT_TURN_BYTES, options.maxQueuedAttachmentTurns ?? WEB_MAX_QUEUED_ATTACHMENT_TURNS);
|
|
54
57
|
this.pushDispatcher = new WebPushDispatcher(store, pushIdentity, pushSubject, {
|
|
55
58
|
...(options.pushSendImpl === undefined ? {} : { send: options.pushSendImpl }),
|
|
@@ -83,8 +86,9 @@ export class WebService {
|
|
|
83
86
|
let service;
|
|
84
87
|
try {
|
|
85
88
|
const pushIdentity = store.ensureWebPushIdentity(generateWebPushIdentity);
|
|
89
|
+
const replyAccessKey = Buffer.from(store.ensureReplyAccessKey(() => randomBytes(32).toString("base64url")), "base64url");
|
|
86
90
|
const pushSubject = resolveWebPushSubject(options.env?.MONO_AGENT_WEB_PUSH_SUBJECT);
|
|
87
|
-
service = new WebService(store, lease, options, pushIdentity, pushSubject);
|
|
91
|
+
service = new WebService(store, lease, options, pushIdentity, pushSubject, replyAccessKey);
|
|
88
92
|
await store.purgePartialUploadFiles();
|
|
89
93
|
await service.purgeOrphans();
|
|
90
94
|
await service.refreshAgents();
|
|
@@ -132,7 +136,78 @@ export class WebService {
|
|
|
132
136
|
const detail = this.store.getThreadDetail(id);
|
|
133
137
|
if (detail === undefined)
|
|
134
138
|
throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
|
|
135
|
-
return detail;
|
|
139
|
+
return this.decorateThreadDetail(detail);
|
|
140
|
+
}
|
|
141
|
+
threadsPage(input) {
|
|
142
|
+
return this.store.listThreadsPage(input);
|
|
143
|
+
}
|
|
144
|
+
messagePage(threadId, input) {
|
|
145
|
+
const page = this.store.listMessagesPage(threadId, input);
|
|
146
|
+
return { ...page, messages: page.messages.map((message) => this.decorateMessage(message)) };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Re-mint a browser capability from the authoritative durable message. The
|
|
150
|
+
* HTTP route supplies exact-origin console authority; stale access tokens are
|
|
151
|
+
* deliberately not accepted as renewable credentials.
|
|
152
|
+
*/
|
|
153
|
+
replyPartAccess(threadId, messageId, partId, type) {
|
|
154
|
+
const { message, part } = this.requireReplyPart(threadId, messageId, partId, type);
|
|
155
|
+
this.assertReplyPartRetained(part);
|
|
156
|
+
return this.decorateReplyPart(message, part);
|
|
157
|
+
}
|
|
158
|
+
async replyAttachment(threadId, messageId, partId, expires, token, signal) {
|
|
159
|
+
const { thread, part } = this.authorizeReplyPart(threadId, messageId, partId, "attachment", expires, token);
|
|
160
|
+
const connection = this.connections.get(thread.sourceId);
|
|
161
|
+
if (connection === undefined || connection.info.replyAttachments?.version !== 1) {
|
|
162
|
+
throw new WebConsoleError("reply_attachment_unavailable", "The attachment source is offline or incompatible.", 409);
|
|
163
|
+
}
|
|
164
|
+
const attachment = {
|
|
165
|
+
type: "attachment",
|
|
166
|
+
id: part.id,
|
|
167
|
+
reference: { scheme: "mono-agent-artifact", id: part.artifactId },
|
|
168
|
+
name: part.name,
|
|
169
|
+
mediaType: part.mediaType,
|
|
170
|
+
sizeBytes: part.sizeBytes,
|
|
171
|
+
integrityId: part.integrityId,
|
|
172
|
+
...(part.expiresAt === undefined ? {} : { expiresAt: part.expiresAt }),
|
|
173
|
+
};
|
|
174
|
+
const response = await connection.client.replyArtifact(this.conversationIdForThread(thread.id), attachment, signal);
|
|
175
|
+
return { part, response };
|
|
176
|
+
}
|
|
177
|
+
async mcpAppResource(threadId, messageId, partId, expires, token, signal) {
|
|
178
|
+
const { thread, part } = this.authorizeReplyPart(threadId, messageId, partId, "mcp_app", expires, token);
|
|
179
|
+
const connection = this.connections.get(thread.sourceId);
|
|
180
|
+
if (connection === undefined || connection.info.mcpApps?.bridgeVersion !== 1) {
|
|
181
|
+
throw new WebConsoleError("mcp_app_unavailable", "The MCP App source is offline or incompatible.", 409);
|
|
182
|
+
}
|
|
183
|
+
const resource = await connection.client.mcpAppResource(this.conversationIdForThread(thread.id), part.invocationId, part.connectionId, signal);
|
|
184
|
+
if (resource.app.invocationId !== part.invocationId
|
|
185
|
+
|| resource.app.connectionId !== part.connectionId
|
|
186
|
+
|| resource.app.resourceUri !== part.resourceUri
|
|
187
|
+
|| resource.app.protocolVersion !== part.protocolVersion) {
|
|
188
|
+
throw new WebConsoleError("mcp_app_identity_mismatch", "The MCP App identity changed after publication.", 409);
|
|
189
|
+
}
|
|
190
|
+
return resource;
|
|
191
|
+
}
|
|
192
|
+
async mcpAppRequest(threadId, messageId, partId, expires, token, input, signal) {
|
|
193
|
+
const { thread, part } = this.authorizeReplyPart(threadId, messageId, partId, "mcp_app", expires, token);
|
|
194
|
+
const confirmationRequired = input.method === "tools/call"
|
|
195
|
+
|| input.method === "ui/open-link"
|
|
196
|
+
|| input.method === "ui/update-model-context";
|
|
197
|
+
if (confirmationRequired && input.confirmed !== true) {
|
|
198
|
+
throw new WebConsoleError("mcp_app_confirmation_required", "Confirm this MCP App action before continuing.", 409);
|
|
199
|
+
}
|
|
200
|
+
const connection = this.connections.get(thread.sourceId);
|
|
201
|
+
if (connection === undefined || connection.info.mcpApps?.bridgeVersion !== 1) {
|
|
202
|
+
throw new WebConsoleError("mcp_app_unavailable", "The MCP App source is offline or incompatible.", 409);
|
|
203
|
+
}
|
|
204
|
+
return await connection.client.mcpAppRequest(this.conversationIdForThread(thread.id), {
|
|
205
|
+
invocationId: part.invocationId,
|
|
206
|
+
connectionId: part.connectionId,
|
|
207
|
+
method: input.method,
|
|
208
|
+
...(input.params === undefined ? {} : { params: input.params }),
|
|
209
|
+
...(input.confirmed === undefined ? {} : { confirmed: input.confirmed }),
|
|
210
|
+
}, signal);
|
|
136
211
|
}
|
|
137
212
|
async registerWebPushSubscription(input) {
|
|
138
213
|
if (input.previousSubscriptionId !== undefined && input.previousEndpoint !== undefined) {
|
|
@@ -185,17 +260,29 @@ export class WebService {
|
|
|
185
260
|
const thread = this.store.getThread(threadId);
|
|
186
261
|
if (thread === undefined)
|
|
187
262
|
throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
|
|
263
|
+
threadId = thread.id;
|
|
188
264
|
const connection = this.connections.get(thread.sourceId);
|
|
189
265
|
if (connection === undefined)
|
|
190
266
|
throw new WebConsoleError("agent_offline", "This agent is offline.", 409);
|
|
191
267
|
if (!connection.info.supportsAskUser)
|
|
192
268
|
return undefined;
|
|
193
|
-
const snapshot = await connection.client.pendingAsk(`web:${
|
|
269
|
+
const snapshot = await connection.client.pendingAsk(this.store.cronConversationIdForThread(thread.id) ?? `web:${thread.id}`, AbortSignal.timeout(INFO_TIMEOUT_MS));
|
|
194
270
|
if (!this.stopped && snapshot !== undefined && isFuturePendingAsk(snapshot, this.currentDate())) {
|
|
195
|
-
this.enqueueAskPush(
|
|
271
|
+
this.enqueueAskPush(thread.id, snapshot);
|
|
196
272
|
}
|
|
197
273
|
return snapshot;
|
|
198
274
|
}
|
|
275
|
+
async ask(threadId, interactionId) {
|
|
276
|
+
const thread = this.store.getThread(threadId);
|
|
277
|
+
if (thread === undefined)
|
|
278
|
+
throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
|
|
279
|
+
const connection = this.connections.get(thread.sourceId);
|
|
280
|
+
if (connection === undefined)
|
|
281
|
+
throw new WebConsoleError("agent_offline", "This agent is offline.", 409);
|
|
282
|
+
if (!connection.info.supportsAskById)
|
|
283
|
+
return undefined;
|
|
284
|
+
return await connection.client.ask(interactionId, AbortSignal.timeout(INFO_TIMEOUT_MS));
|
|
285
|
+
}
|
|
199
286
|
async submitAsk(threadId, interactionId, answers) {
|
|
200
287
|
const thread = this.store.getThread(threadId);
|
|
201
288
|
if (thread === undefined)
|
|
@@ -204,30 +291,34 @@ export class WebService {
|
|
|
204
291
|
if (connection === undefined || !connection.info.supportsAskUser) {
|
|
205
292
|
throw new WebConsoleError("ask_user_unavailable", "This agent does not support interactive questions.", 409);
|
|
206
293
|
}
|
|
207
|
-
const
|
|
294
|
+
const conversationId = this.store.cronConversationIdForThread(thread.id) ?? `web:${thread.id}`;
|
|
295
|
+
const result = await connection.client.submitAsk(conversationId, interactionId, answers);
|
|
208
296
|
if (result.accepted)
|
|
209
297
|
this.store.staleWebPushEvent(`ask:${interactionId}`, "answered");
|
|
210
298
|
return result;
|
|
211
299
|
}
|
|
212
300
|
patchThread(id, patch) {
|
|
213
301
|
const thread = this.store.patchThread(id, patch);
|
|
214
|
-
this.emit("thread.changed", id, { thread });
|
|
215
|
-
this.emit("threads.changed", id);
|
|
302
|
+
this.emit("thread.changed", thread.id, { thread });
|
|
303
|
+
this.emit("threads.changed", thread.id);
|
|
216
304
|
return thread;
|
|
217
305
|
}
|
|
218
306
|
async deleteThread(id) {
|
|
219
|
-
|
|
307
|
+
const resolved = this.store.getThread(id)?.id;
|
|
308
|
+
if (resolved === undefined)
|
|
309
|
+
throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
|
|
310
|
+
if (this.activeTurns.has(resolved)) {
|
|
220
311
|
throw new WebConsoleError("turn_active", "Cancel the active turn before deleting this conversation.", 409);
|
|
221
312
|
}
|
|
222
|
-
const result = await this.store.deleteArchivedThread(
|
|
313
|
+
const result = await this.store.deleteArchivedThread(resolved);
|
|
223
314
|
if (result.orphanedFiles > 0) {
|
|
224
315
|
this.options.logger?.warn?.("Deleted a web conversation with attachment files deferred to orphan cleanup.", {
|
|
225
|
-
threadId:
|
|
316
|
+
threadId: resolved,
|
|
226
317
|
count: result.orphanedFiles,
|
|
227
318
|
});
|
|
228
319
|
}
|
|
229
|
-
this.emit("thread.changed",
|
|
230
|
-
this.emit("threads.changed",
|
|
320
|
+
this.emit("thread.changed", resolved, { threadId: resolved, removed: true });
|
|
321
|
+
this.emit("threads.changed", resolved);
|
|
231
322
|
}
|
|
232
323
|
patchAgent(sourceId, patch) {
|
|
233
324
|
const agent = this.store.setAgentPinned(sourceId, patch.pinned);
|
|
@@ -244,12 +335,126 @@ export class WebService {
|
|
|
244
335
|
}
|
|
245
336
|
return connection.info.skills ?? { status: "unsupported", items: [] };
|
|
246
337
|
}
|
|
338
|
+
async cronOverview(sourceId) {
|
|
339
|
+
const agent = this.store.getAgent(sourceId);
|
|
340
|
+
if (agent === undefined)
|
|
341
|
+
throw new WebConsoleError("agent_not_found", "Agent not found.", 404);
|
|
342
|
+
const connection = this.connections.get(sourceId);
|
|
343
|
+
if (connection?.info.cron?.read === true) {
|
|
344
|
+
const overview = await connection.client.cronOverview(AbortSignal.timeout(INFO_TIMEOUT_MS));
|
|
345
|
+
const synced = this.store.syncCronOverviewResult({ sourceId, ...overview });
|
|
346
|
+
if (synced.changed) {
|
|
347
|
+
this.emit("cron.changed", undefined, { sourceId });
|
|
348
|
+
this.emit("threads.changed");
|
|
349
|
+
}
|
|
350
|
+
return synced.overview;
|
|
351
|
+
}
|
|
352
|
+
const stored = this.store.storedCronOverview(sourceId);
|
|
353
|
+
if (stored !== undefined)
|
|
354
|
+
return { ...stored, actionsEnabled: false };
|
|
355
|
+
throw new WebConsoleError("cron_unavailable", "This agent does not expose first-class cron operator state.", 404);
|
|
356
|
+
}
|
|
357
|
+
async cronRuns(sourceId, jobId, input) {
|
|
358
|
+
const agent = this.store.getAgent(sourceId);
|
|
359
|
+
if (agent === undefined)
|
|
360
|
+
throw new WebConsoleError("agent_not_found", "Agent not found.", 404);
|
|
361
|
+
const connection = this.connections.get(sourceId);
|
|
362
|
+
if (connection?.info.cron?.read !== true) {
|
|
363
|
+
if (this.store.cronThread(sourceId, jobId) === undefined) {
|
|
364
|
+
throw new WebConsoleError("cron_job_not_found", "Cron job not found for this agent.", 404);
|
|
365
|
+
}
|
|
366
|
+
if (input.before !== undefined)
|
|
367
|
+
return { runs: [] };
|
|
368
|
+
const stored = this.store.storedCronRuns(sourceId, jobId, input.limit);
|
|
369
|
+
return stored.messages === undefined
|
|
370
|
+
? stored
|
|
371
|
+
: { ...stored, messages: stored.messages.map((message) => this.decorateMessage(message)) };
|
|
372
|
+
}
|
|
373
|
+
const page = await connection.client.cronRuns(jobId, {
|
|
374
|
+
limit: input.limit,
|
|
375
|
+
...(input.before === undefined ? {} : { before: input.before }),
|
|
376
|
+
signal: AbortSignal.timeout(INFO_TIMEOUT_MS),
|
|
377
|
+
});
|
|
378
|
+
const reconciled = this.store.reconcileCronRunsResult(sourceId, jobId, page.runs);
|
|
379
|
+
if (reconciled.changed) {
|
|
380
|
+
const threadId = this.store.cronThread(sourceId, jobId)?.id;
|
|
381
|
+
this.emit("thread.changed", threadId);
|
|
382
|
+
this.emit("threads.changed", threadId);
|
|
383
|
+
}
|
|
384
|
+
return { ...page, messages: reconciled.messages.map((message) => this.decorateMessage(message)) };
|
|
385
|
+
}
|
|
386
|
+
async cronRun(sourceId, jobId, runId) {
|
|
387
|
+
const connection = this.requireCronConnection(sourceId, false);
|
|
388
|
+
const run = await connection.client.cronRun(jobId, runId, AbortSignal.timeout(INFO_TIMEOUT_MS));
|
|
389
|
+
const reconciled = this.store.reconcileCronRunsResult(sourceId, jobId, [run]);
|
|
390
|
+
const message = reconciled.messages[0];
|
|
391
|
+
if (message === undefined) {
|
|
392
|
+
throw new WebConsoleError("invalid_operator_cron", "Cron detail did not reconcile a message.", 502);
|
|
393
|
+
}
|
|
394
|
+
if (reconciled.changed) {
|
|
395
|
+
this.emit("thread.changed", message.threadId, { messageId: message.id });
|
|
396
|
+
this.emit("threads.changed", message.threadId);
|
|
397
|
+
}
|
|
398
|
+
return this.decorateMessage(message);
|
|
399
|
+
}
|
|
400
|
+
async cronConfigView(sourceId) {
|
|
401
|
+
const connection = this.requireCronConnection(sourceId, false);
|
|
402
|
+
return await connection.client.cronConfigView(AbortSignal.timeout(INFO_TIMEOUT_MS));
|
|
403
|
+
}
|
|
404
|
+
async cronRunNow(sourceId, jobId, input) {
|
|
405
|
+
const connection = this.requireCronConnection(sourceId, true);
|
|
406
|
+
const result = await connection.client.cronRunNow(jobId, input, AbortSignal.timeout(INFO_TIMEOUT_MS));
|
|
407
|
+
if (result.kind === "completed") {
|
|
408
|
+
const reconciled = this.store.reconcileCronRunsResult(sourceId, jobId, [result.value.run]);
|
|
409
|
+
if (reconciled.changed) {
|
|
410
|
+
const threadId = this.store.cronThread(sourceId, jobId)?.id;
|
|
411
|
+
this.emit("thread.changed", threadId);
|
|
412
|
+
this.emit("threads.changed", threadId);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return result;
|
|
416
|
+
}
|
|
417
|
+
async cronSetEffectiveEnabled(sourceId, jobId, enabled, input) {
|
|
418
|
+
const connection = this.requireCronConnection(sourceId, true);
|
|
419
|
+
const result = await connection.client.cronSetEffectiveEnabled(jobId, enabled, input, AbortSignal.timeout(INFO_TIMEOUT_MS));
|
|
420
|
+
if (result.kind === "confirmation_required")
|
|
421
|
+
return result;
|
|
422
|
+
const refreshed = await connection.client.cronOverview(AbortSignal.timeout(INFO_TIMEOUT_MS));
|
|
423
|
+
const synced = this.store.syncCronOverviewResult({ sourceId, ...refreshed });
|
|
424
|
+
const overview = synced.overview;
|
|
425
|
+
const job = overview.jobs.find((candidate) => candidate.jobId === jobId);
|
|
426
|
+
if (job === undefined)
|
|
427
|
+
throw new WebConsoleError("invalid_operator_cron", "Updated cron job disappeared.", 502);
|
|
428
|
+
if (synced.changed) {
|
|
429
|
+
this.emit("cron.changed", job.threadId, { sourceId, jobId });
|
|
430
|
+
this.emit("thread.changed", job.threadId, { thread: this.store.getThread(job.threadId) });
|
|
431
|
+
this.emit("threads.changed", job.threadId);
|
|
432
|
+
}
|
|
433
|
+
return { ...result, value: { job } };
|
|
434
|
+
}
|
|
247
435
|
async deliverNotification(input) {
|
|
248
436
|
if (this.stopped) {
|
|
249
437
|
throw new WebConsoleError("web_service_stopping", "The web service is stopping.", 409);
|
|
250
438
|
}
|
|
251
439
|
if (this.store.getAgent(input.sourceId) === undefined)
|
|
252
440
|
await this.refreshAgents();
|
|
441
|
+
if (input.triggerKind === "job") {
|
|
442
|
+
const completed = this.store.upsertProcessJobCard({
|
|
443
|
+
sourceId: input.sourceId,
|
|
444
|
+
threadId: input.threadId,
|
|
445
|
+
deliveryKey: input.deliveryKey,
|
|
446
|
+
processJob: input.processJob,
|
|
447
|
+
...(input.text === undefined ? {} : { responseText: input.text }),
|
|
448
|
+
...(input.parts === undefined ? {} : { replyParts: input.parts }),
|
|
449
|
+
});
|
|
450
|
+
const message = this.store.getThreadDetail(input.threadId)?.messages.find((candidate) => candidate.parts.some((part) => part.type === "process-job" && part.job.jobId === input.processJob.jobId));
|
|
451
|
+
if (!completed.duplicate && message !== undefined) {
|
|
452
|
+
this.emit("message.changed", input.threadId, { messageId: message.id, updatedAt: message.updatedAt });
|
|
453
|
+
}
|
|
454
|
+
this.emit("threads.changed", input.threadId, { thread: completed.thread });
|
|
455
|
+
this.emit("thread.changed", input.threadId, { thread: completed.thread });
|
|
456
|
+
return completed;
|
|
457
|
+
}
|
|
253
458
|
const reservation = this.store.reserveNotification(input);
|
|
254
459
|
if (reservation.duplicate)
|
|
255
460
|
return this.store.completeNotification(reservation);
|
|
@@ -263,6 +468,26 @@ export class WebService {
|
|
|
263
468
|
this.activeNotifications.set(activeKey, delivery);
|
|
264
469
|
return delivery;
|
|
265
470
|
}
|
|
471
|
+
/** Proxy one authenticated retained card to its exact agent/thread owner. */
|
|
472
|
+
async threadJob(threadId, jobId) {
|
|
473
|
+
const thread = this.store.getThread(threadId);
|
|
474
|
+
if (thread === undefined)
|
|
475
|
+
throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
|
|
476
|
+
if (!this.store.processJobCardBelongsToThread(thread.sourceId, threadId, jobId)) {
|
|
477
|
+
throw new WebConsoleError("process_job_not_found", "Process job was not found for this conversation.", 404);
|
|
478
|
+
}
|
|
479
|
+
const connection = this.connections.get(thread.sourceId);
|
|
480
|
+
if (connection === undefined || connection.info.supportsJobs !== true) {
|
|
481
|
+
throw new WebConsoleError("process_jobs_unavailable", "Process jobs are unavailable for this agent.", 409);
|
|
482
|
+
}
|
|
483
|
+
const job = await connection.client.getJob(jobId, AbortSignal.timeout(INFO_TIMEOUT_MS));
|
|
484
|
+
if (job.jobId !== jobId
|
|
485
|
+
|| job.origin.channel !== "web"
|
|
486
|
+
|| job.origin.conversationId.split("#", 1)[0] !== `web:${threadId}`) {
|
|
487
|
+
throw new WebConsoleError("process_job_not_found", "Process job was not found for this conversation.", 404);
|
|
488
|
+
}
|
|
489
|
+
return job;
|
|
490
|
+
}
|
|
266
491
|
async startTurn(threadId, input) {
|
|
267
492
|
const text = input.text ?? "";
|
|
268
493
|
const operatorText = input.quote === undefined ? text : formatQuotedTurn(input.quote.text, text);
|
|
@@ -273,8 +498,11 @@ export class WebService {
|
|
|
273
498
|
const thread = this.store.getThread(threadId);
|
|
274
499
|
if (thread === undefined)
|
|
275
500
|
throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
|
|
501
|
+
threadId = thread.id;
|
|
276
502
|
const agent = this.store.getAgent(thread.sourceId);
|
|
277
503
|
const connection = this.connections.get(thread.sourceId);
|
|
504
|
+
if (thread.trigger?.kind === "cron")
|
|
505
|
+
throw cronChannelReadOnlyError();
|
|
278
506
|
if (agent === undefined || connection === undefined || !thread.canSend) {
|
|
279
507
|
throw new WebConsoleError("agent_offline", "This agent is offline. The conversation remains available read-only.", 409);
|
|
280
508
|
}
|
|
@@ -292,6 +520,7 @@ export class WebService {
|
|
|
292
520
|
const thread = this.store.getThread(threadId);
|
|
293
521
|
if (thread === undefined)
|
|
294
522
|
throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
|
|
523
|
+
threadId = thread.id;
|
|
295
524
|
const connection = this.connections.get(thread.sourceId);
|
|
296
525
|
const active = this.activeTurns.get(threadId);
|
|
297
526
|
const reserved = this.store.reserveLiveInput(threadId, text);
|
|
@@ -316,10 +545,15 @@ export class WebService {
|
|
|
316
545
|
return { message: reserved.message, disposition: "pending" };
|
|
317
546
|
}
|
|
318
547
|
async cancelTurn(threadId) {
|
|
548
|
+
const resolved = this.store.getThread(threadId)?.id;
|
|
549
|
+
if (resolved === undefined)
|
|
550
|
+
throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
|
|
551
|
+
threadId = resolved;
|
|
319
552
|
const active = this.activeTurns.get(threadId);
|
|
320
553
|
const stored = this.store.activeTurn(threadId);
|
|
321
554
|
if (stored === undefined)
|
|
322
555
|
throw new WebConsoleError("no_active_turn", "This conversation has no active turn.", 409);
|
|
556
|
+
const reason = createChannelUserCancelReason("Web");
|
|
323
557
|
const liveInputs = [...this.activeLiveInputs.entries()]
|
|
324
558
|
.filter(([, input]) => input.threadId === threadId);
|
|
325
559
|
const cancelledInputs = this.store.cancelLiveInputs(threadId);
|
|
@@ -327,13 +561,13 @@ export class WebService {
|
|
|
327
561
|
this.emit("message.changed", threadId, { messageId: message.id, updatedAt: message.updatedAt });
|
|
328
562
|
}
|
|
329
563
|
for (const [, input] of liveInputs) {
|
|
330
|
-
input.controller.abort(
|
|
564
|
+
input.controller.abort(reason);
|
|
331
565
|
}
|
|
332
566
|
if (active !== undefined) {
|
|
333
|
-
|
|
567
|
+
await active.client.cancel(stored.conversationId).catch((error) => {
|
|
334
568
|
this.options.logger?.debug?.("Web turn cancel request failed.", { error: errorMessage(error) });
|
|
335
569
|
});
|
|
336
|
-
active.controller.abort(
|
|
570
|
+
active.controller.abort(reason);
|
|
337
571
|
}
|
|
338
572
|
const thread = this.store.getThread(threadId);
|
|
339
573
|
if (thread === undefined)
|
|
@@ -506,7 +740,7 @@ export class WebService {
|
|
|
506
740
|
},
|
|
507
741
|
});
|
|
508
742
|
await coalescer.flush();
|
|
509
|
-
const detail = this.store.completeTurn(started.turnId, response.finalText, response.metadata);
|
|
743
|
+
const detail = this.store.completeTurn(started.turnId, response.finalText, response.metadata, response.parts);
|
|
510
744
|
this.emit("turn.changed", started.thread.id, { turn: detail.thread.runState });
|
|
511
745
|
this.emit("thread.changed", started.thread.id, { revision: detail.thread.revision });
|
|
512
746
|
this.emit("threads.changed", started.thread.id);
|
|
@@ -520,7 +754,8 @@ export class WebService {
|
|
|
520
754
|
catch (flushError) {
|
|
521
755
|
failure = flushError;
|
|
522
756
|
}
|
|
523
|
-
const cancelled = controller.signal.reason
|
|
757
|
+
const cancelled = isChannelUserCancelReason(controller.signal.reason)
|
|
758
|
+
|| controller.signal.reason instanceof WebTurnCancellation
|
|
524
759
|
|| error.cancelled === true;
|
|
525
760
|
const code = errorCode(failure);
|
|
526
761
|
const detail = this.store.failTurn(started.turnId, {
|
|
@@ -624,12 +859,18 @@ export class WebService {
|
|
|
624
859
|
if (!connection.info.supportsHistoryAppend) {
|
|
625
860
|
throw new WebConsoleError("history_record_unavailable", "The notification agent does not support durable history append.", 409);
|
|
626
861
|
}
|
|
627
|
-
await connection.client.recordVerbatim(
|
|
862
|
+
await connection.client.recordVerbatim(this.store.notificationConversationId(reservation), reservation.text, reservation.deliveryKey);
|
|
628
863
|
const completed = this.store.completeNotification(reservation);
|
|
864
|
+
if (completed.thread === undefined) {
|
|
865
|
+
if (completed.tombstoned === true)
|
|
866
|
+
return completed;
|
|
867
|
+
throw new WebConsoleError("storage_corrupt", "A new notification completed without a conversation.", 500);
|
|
868
|
+
}
|
|
629
869
|
this.emit("threads.changed", completed.thread.id, { thread: completed.thread });
|
|
630
870
|
this.emit("thread.changed", completed.thread.id, { thread: completed.thread });
|
|
631
|
-
if (!completed.duplicate)
|
|
632
|
-
this.announcePushEvent(
|
|
871
|
+
if (!completed.duplicate) {
|
|
872
|
+
this.announcePushEvent(notificationPushLogicalKey(reservation.sourceId, reservation.deliveryKey));
|
|
873
|
+
}
|
|
633
874
|
return completed;
|
|
634
875
|
}
|
|
635
876
|
async toAgentAttachment(attachment) {
|
|
@@ -657,9 +898,10 @@ export class WebService {
|
|
|
657
898
|
}
|
|
658
899
|
catch (error) {
|
|
659
900
|
this.options.logger?.warn?.("Web agent discovery failed.", { error: errorMessage(error) });
|
|
660
|
-
this.store.replaceAgents([]);
|
|
901
|
+
const changed = this.store.replaceAgents([]);
|
|
661
902
|
this.connections = new Map();
|
|
662
|
-
|
|
903
|
+
if (changed)
|
|
904
|
+
this.emit("agents.changed", undefined, { agents: this.store.listAgents() });
|
|
663
905
|
return;
|
|
664
906
|
}
|
|
665
907
|
const nextConnections = new Map();
|
|
@@ -669,6 +911,7 @@ export class WebService {
|
|
|
669
911
|
const client = new OperatorClient({
|
|
670
912
|
baseUrl: agent.baseUrl,
|
|
671
913
|
...(agent.apiKey === undefined ? {} : { apiKey: agent.apiKey }),
|
|
914
|
+
...(agent.processJobsBearer === undefined ? {} : { processJobsBearer: agent.processJobsBearer }),
|
|
672
915
|
...(this.options.fetchImpl === undefined ? {} : { fetchImpl: this.options.fetchImpl }),
|
|
673
916
|
});
|
|
674
917
|
try {
|
|
@@ -687,6 +930,8 @@ export class WebService {
|
|
|
687
930
|
...(info.effort === undefined ? {} : { defaultEffort: info.effort }),
|
|
688
931
|
...(efforts.length === 0 ? {} : { efforts }),
|
|
689
932
|
...(info.modelOptions === undefined ? {} : { modelOptions: info.modelOptions }),
|
|
933
|
+
...(info.cron === undefined ? {} : { cron: info.cron }),
|
|
934
|
+
...(info.supportsAskById ? { supportsAskById: true } : {}),
|
|
690
935
|
updatedAt: agent.source.updatedAt,
|
|
691
936
|
};
|
|
692
937
|
}
|
|
@@ -699,9 +944,30 @@ export class WebService {
|
|
|
699
944
|
}
|
|
700
945
|
}));
|
|
701
946
|
this.connections = nextConnections;
|
|
702
|
-
this.store.replaceAgents(summaries);
|
|
703
|
-
|
|
704
|
-
|
|
947
|
+
const agentsChanged = this.store.replaceAgents(summaries);
|
|
948
|
+
const cronChangedSources = new Set();
|
|
949
|
+
await Promise.all([...nextConnections.entries()].map(async ([sourceId, connection]) => {
|
|
950
|
+
if (connection.info.cron?.read !== true)
|
|
951
|
+
return;
|
|
952
|
+
try {
|
|
953
|
+
const overview = await connection.client.cronOverview(AbortSignal.any([signal, AbortSignal.timeout(INFO_TIMEOUT_MS)]));
|
|
954
|
+
const synced = this.store.syncCronOverviewResult({ sourceId, ...overview });
|
|
955
|
+
if (synced.changed)
|
|
956
|
+
cronChangedSources.add(sourceId);
|
|
957
|
+
}
|
|
958
|
+
catch (error) {
|
|
959
|
+
this.options.logger?.debug?.("Cron operator refresh failed; retaining the last authoritative snapshot.", {
|
|
960
|
+
sourceId,
|
|
961
|
+
error: errorMessage(error),
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
}));
|
|
965
|
+
if (agentsChanged)
|
|
966
|
+
this.emit("agents.changed", undefined, { agents: this.store.listAgents() });
|
|
967
|
+
for (const sourceId of cronChangedSources)
|
|
968
|
+
this.emit("cron.changed", undefined, { sourceId });
|
|
969
|
+
if (cronChangedSources.size > 0)
|
|
970
|
+
this.emit("threads.changed");
|
|
705
971
|
for (const threadId of this.store.queuedLiveInputThreadIds()) {
|
|
706
972
|
void this.drainQueuedLiveInputs(threadId);
|
|
707
973
|
}
|
|
@@ -794,7 +1060,7 @@ export class WebService {
|
|
|
794
1060
|
const connection = thread === undefined ? undefined : this.connections.get(thread.sourceId);
|
|
795
1061
|
if (connection !== undefined && connection.info.supportsAskUser) {
|
|
796
1062
|
try {
|
|
797
|
-
const snapshot = await connection.client.pendingAsk(`web:${threadId}`, AbortSignal.any([signal, AbortSignal.timeout(INFO_TIMEOUT_MS)]));
|
|
1063
|
+
const snapshot = await connection.client.pendingAsk(this.store.cronConversationIdForThread(threadId) ?? `web:${threadId}`, AbortSignal.any([signal, AbortSignal.timeout(INFO_TIMEOUT_MS)]));
|
|
798
1064
|
if (snapshot !== undefined) {
|
|
799
1065
|
if (isFuturePendingAsk(snapshot, this.currentDate())) {
|
|
800
1066
|
if (this.stopped || signal.aborted)
|
|
@@ -848,7 +1114,10 @@ export class WebService {
|
|
|
848
1114
|
if (connection === undefined || !connection.info.supportsAskUser)
|
|
849
1115
|
return "unknown";
|
|
850
1116
|
try {
|
|
851
|
-
const
|
|
1117
|
+
const boundedSignal = AbortSignal.any([signal, AbortSignal.timeout(INFO_TIMEOUT_MS)]);
|
|
1118
|
+
const snapshot = connection.info.supportsAskById
|
|
1119
|
+
? await connection.client.ask(interactionId, boundedSignal)
|
|
1120
|
+
: await connection.client.pendingAsk(this.store.cronConversationIdForThread(event.threadId) ?? `web:${event.threadId}`, boundedSignal);
|
|
852
1121
|
return snapshot?.interactionId === interactionId
|
|
853
1122
|
&& snapshot.status === "pending"
|
|
854
1123
|
&& new Date(snapshot.expiresAt).getTime() > this.currentDate().getTime()
|
|
@@ -859,6 +1128,108 @@ export class WebService {
|
|
|
859
1128
|
return "unknown";
|
|
860
1129
|
}
|
|
861
1130
|
}
|
|
1131
|
+
requireCronConnection(sourceId, actions) {
|
|
1132
|
+
if (this.store.getAgent(sourceId) === undefined) {
|
|
1133
|
+
throw new WebConsoleError("agent_not_found", "Agent not found.", 404);
|
|
1134
|
+
}
|
|
1135
|
+
const connection = this.connections.get(sourceId);
|
|
1136
|
+
if (connection === undefined || connection.info.cron?.read !== true) {
|
|
1137
|
+
throw new WebConsoleError("cron_unavailable", "Cron operator state is unavailable for this agent.", 409);
|
|
1138
|
+
}
|
|
1139
|
+
if (actions && connection.info.cron.actions !== true) {
|
|
1140
|
+
throw new WebConsoleError("cron_actions_disabled", "Cron actions require this agent's authenticated operator capability.", 403);
|
|
1141
|
+
}
|
|
1142
|
+
return connection;
|
|
1143
|
+
}
|
|
1144
|
+
authorizeReplyPart(threadId, messageId, partId, type, expires, token) {
|
|
1145
|
+
const access = this.replyAccessTokenStatus(threadId, messageId, type, partId, expires, token);
|
|
1146
|
+
if (access === "invalid") {
|
|
1147
|
+
throw new WebConsoleError("reply_part_not_found", "The reply part is unavailable.", 404);
|
|
1148
|
+
}
|
|
1149
|
+
const { thread, part } = this.requireReplyPart(threadId, messageId, partId, type);
|
|
1150
|
+
this.assertReplyPartRetained(part);
|
|
1151
|
+
if (access === "expired") {
|
|
1152
|
+
throw new WebConsoleError("reply_access_expired", "Reply access expired. Refresh this reply part and try again.", 410);
|
|
1153
|
+
}
|
|
1154
|
+
return { thread, part };
|
|
1155
|
+
}
|
|
1156
|
+
requireReplyPart(threadId, messageId, partId, type) {
|
|
1157
|
+
const thread = this.store.getThread(threadId);
|
|
1158
|
+
const message = this.store.getMessage(messageId);
|
|
1159
|
+
if (thread === undefined || message === undefined || message.threadId !== thread.id) {
|
|
1160
|
+
throw new WebConsoleError("reply_part_not_found", "The reply part is unavailable.", 404);
|
|
1161
|
+
}
|
|
1162
|
+
const matches = message.parts.filter((part) => part.type === type && part.id === partId);
|
|
1163
|
+
if (matches.length !== 1) {
|
|
1164
|
+
throw new WebConsoleError("reply_part_not_found", "The reply part is unavailable.", 404);
|
|
1165
|
+
}
|
|
1166
|
+
const part = matches[0];
|
|
1167
|
+
return { thread, message, part };
|
|
1168
|
+
}
|
|
1169
|
+
assertReplyPartRetained(part) {
|
|
1170
|
+
const expiresAt = part.expiresAt;
|
|
1171
|
+
if (expiresAt !== undefined && Date.parse(expiresAt) <= this.currentDate().getTime()) {
|
|
1172
|
+
throw new WebConsoleError("reply_part_expired", "The reply part has expired.", 410);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
decorateThreadDetail(detail) {
|
|
1176
|
+
return { ...detail, messages: detail.messages.map((message) => this.decorateMessage(message)) };
|
|
1177
|
+
}
|
|
1178
|
+
decorateMessage(message) {
|
|
1179
|
+
const parts = message.parts.map((part) => {
|
|
1180
|
+
if (part.type !== "attachment" && part.type !== "mcp_app")
|
|
1181
|
+
return part;
|
|
1182
|
+
return this.decorateReplyPart(message, part);
|
|
1183
|
+
});
|
|
1184
|
+
return { ...message, parts };
|
|
1185
|
+
}
|
|
1186
|
+
decorateReplyPart(message, part) {
|
|
1187
|
+
const now = this.currentDate().getTime();
|
|
1188
|
+
const retentionDeadline = part.expiresAt === undefined
|
|
1189
|
+
? Number.POSITIVE_INFINITY
|
|
1190
|
+
: Date.parse(part.expiresAt);
|
|
1191
|
+
const expiresAt = Math.min(now + REPLY_ACCESS_TTL_MS, retentionDeadline);
|
|
1192
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= now)
|
|
1193
|
+
return part;
|
|
1194
|
+
const expires = String(Math.floor(expiresAt / 1_000));
|
|
1195
|
+
const token = this.replyAccessToken(message.threadId, message.id, part.type, part.id, expires);
|
|
1196
|
+
const base = `/api/v1/threads/${encodeURIComponent(message.threadId)}`
|
|
1197
|
+
+ `/messages/${encodeURIComponent(message.id)}`;
|
|
1198
|
+
const query = new URLSearchParams({ expires, token }).toString();
|
|
1199
|
+
return part.type === "attachment"
|
|
1200
|
+
? {
|
|
1201
|
+
...part,
|
|
1202
|
+
contentUrl: `${base}/reply-attachments/${encodeURIComponent(part.id)}/content?${query}`,
|
|
1203
|
+
}
|
|
1204
|
+
: {
|
|
1205
|
+
...part,
|
|
1206
|
+
resourceUrl: `${base}/mcp-apps/${encodeURIComponent(part.id)}?${query}`,
|
|
1207
|
+
bridgeUrl: `${base}/mcp-apps/${encodeURIComponent(part.id)}/requests?${query}`,
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
replyAccessToken(threadId, messageId, type, partId, expires) {
|
|
1211
|
+
return createHmac("sha256", this.replyAccessKey)
|
|
1212
|
+
.update(["v1", threadId, messageId, type, partId, expires].join("\0"))
|
|
1213
|
+
.digest("base64url");
|
|
1214
|
+
}
|
|
1215
|
+
replyAccessTokenStatus(threadId, messageId, type, partId, expires, supplied) {
|
|
1216
|
+
if (!/^\d{10,13}$/u.test(expires) || !/^[A-Za-z0-9_-]{43}$/u.test(supplied))
|
|
1217
|
+
return "invalid";
|
|
1218
|
+
const expiresMs = Number(expires) * 1_000;
|
|
1219
|
+
if (!Number.isSafeInteger(expiresMs))
|
|
1220
|
+
return "invalid";
|
|
1221
|
+
const expected = Buffer.from(this.replyAccessToken(threadId, messageId, type, partId, expires), "utf8");
|
|
1222
|
+
const candidate = Buffer.from(supplied, "utf8");
|
|
1223
|
+
if (candidate.byteLength !== expected.byteLength || !timingSafeEqual(candidate, expected))
|
|
1224
|
+
return "invalid";
|
|
1225
|
+
const now = this.currentDate().getTime();
|
|
1226
|
+
if (expiresMs > now + REPLY_ACCESS_TTL_MS + 1_000)
|
|
1227
|
+
return "invalid";
|
|
1228
|
+
return expiresMs <= now ? "expired" : "valid";
|
|
1229
|
+
}
|
|
1230
|
+
conversationIdForThread(threadId) {
|
|
1231
|
+
return this.store.cronConversationIdForThread(threadId) ?? `web:${threadId}`;
|
|
1232
|
+
}
|
|
862
1233
|
currentDate() {
|
|
863
1234
|
return this.options.clock?.() ?? new Date();
|
|
864
1235
|
}
|