@mono-agent/web 0.19.0 → 0.20.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 +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/push.d.ts +3 -0
- package/dist/push.d.ts.map +1 -1
- package/dist/push.js +11 -3
- package/dist/push.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/store.js
CHANGED
|
@@ -2,15 +2,22 @@ import { createECDH, createHash, randomUUID, timingSafeEqual } from "node:crypto
|
|
|
2
2
|
import { chmod, lstat, readdir, unlink } from "node:fs/promises";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { DatabaseSync } from "node:sqlite";
|
|
5
|
-
import {
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
6
|
+
import { AGENT_LIVE_INPUT_MAX_CHARACTERS, MAX_AGENT_REPLY_PARTS, classifyNotifySuppression, parseProcessJobProjection, } from "@mono-agent/agent-contracts";
|
|
6
7
|
import { WEB_MAX_FILES_PER_TURN, WEB_MAX_LIVE_INPUTS_PER_THREAD, WEB_MAX_TURN_ATTACHMENT_BYTES, WEB_MAX_TURN_TEXT_CHARACTERS, } from "./contracts.js";
|
|
7
8
|
import { WebConsoleError } from "./errors.js";
|
|
8
9
|
import { webPushPreview } from "./push-preview.js";
|
|
9
10
|
import { prepareWebStatePaths } from "./state-paths.js";
|
|
10
|
-
const WEB_STORAGE_SCHEMA_VERSION =
|
|
11
|
+
const WEB_STORAGE_SCHEMA_VERSION = 7;
|
|
12
|
+
const MAX_REVISIONS_PER_THREAD = 1_000;
|
|
13
|
+
export const WEB_THREAD_PAGE_MAX = 200;
|
|
14
|
+
export const WEB_MESSAGE_PAGE_MAX = 100;
|
|
11
15
|
const MAX_ACTIVE_PUSH_SUBSCRIPTIONS = 32;
|
|
12
16
|
const MAX_PENDING_PUSH_DELIVERIES_PER_SUBSCRIPTION = 200;
|
|
13
17
|
const PUSH_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
|
|
18
|
+
export function cronChannelReadOnlyError() {
|
|
19
|
+
return new WebConsoleError("cron_channel_read_only", "Cron channels are read-only. Scheduled runs and history are managed by the agent.", 409);
|
|
20
|
+
}
|
|
14
21
|
export class WebStore {
|
|
15
22
|
paths;
|
|
16
23
|
database;
|
|
@@ -60,13 +67,23 @@ export class WebStore {
|
|
|
60
67
|
this.database.close();
|
|
61
68
|
}
|
|
62
69
|
replaceAgents(agents) {
|
|
70
|
+
const current = this.listAgents();
|
|
71
|
+
const currentById = new Map(current.map((agent) => [agent.sourceId, agent]));
|
|
72
|
+
const incomingIds = new Set(agents.map((agent) => agent.sourceId));
|
|
73
|
+
const changed = agents.some((agent) => {
|
|
74
|
+
const prior = currentById.get(agent.sourceId);
|
|
75
|
+
return prior === undefined || !isDeepStrictEqual(prior, { ...agent, pinned: prior.pinned });
|
|
76
|
+
}) || current.some((agent) => !incomingIds.has(agent.sourceId) && agent.status !== "offline");
|
|
77
|
+
if (!changed)
|
|
78
|
+
return false;
|
|
63
79
|
this.transaction(() => {
|
|
64
80
|
this.database.prepare("UPDATE agents SET status = 'offline'").run();
|
|
65
81
|
const statement = this.database.prepare(`
|
|
66
82
|
INSERT INTO agents (
|
|
67
83
|
source_id, label, status, health, supports_attachments, models_json,
|
|
68
|
-
default_model, default_effort, efforts_json, model_options_json,
|
|
69
|
-
|
|
84
|
+
default_model, default_effort, efforts_json, model_options_json,
|
|
85
|
+
cron_read, cron_actions, ask_by_id, updated_at
|
|
86
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
70
87
|
ON CONFLICT(source_id) DO UPDATE SET
|
|
71
88
|
label = excluded.label,
|
|
72
89
|
status = excluded.status,
|
|
@@ -77,12 +94,16 @@ export class WebStore {
|
|
|
77
94
|
default_effort = excluded.default_effort,
|
|
78
95
|
efforts_json = excluded.efforts_json,
|
|
79
96
|
model_options_json = excluded.model_options_json,
|
|
97
|
+
cron_read = excluded.cron_read,
|
|
98
|
+
cron_actions = excluded.cron_actions,
|
|
99
|
+
ask_by_id = excluded.ask_by_id,
|
|
80
100
|
updated_at = excluded.updated_at
|
|
81
101
|
`);
|
|
82
102
|
for (const agent of agents) {
|
|
83
|
-
statement.run(agent.sourceId, agent.label, agent.status, agent.health ?? null, agent.supportsAttachments ? 1 : 0, stringifyOptional(agent.models), agent.defaultModel ?? null, agent.defaultEffort ?? null, stringifyOptional(agent.efforts), stringifyOptional(agent.modelOptions), agent.updatedAt);
|
|
103
|
+
statement.run(agent.sourceId, agent.label, agent.status, agent.health ?? null, agent.supportsAttachments ? 1 : 0, stringifyOptional(agent.models), agent.defaultModel ?? null, agent.defaultEffort ?? null, stringifyOptional(agent.efforts), stringifyOptional(agent.modelOptions), agent.cron?.read === true ? 1 : 0, agent.cron?.actions === true ? 1 : 0, agent.supportsAskById === true ? 1 : 0, agent.updatedAt);
|
|
84
104
|
}
|
|
85
105
|
});
|
|
106
|
+
return true;
|
|
86
107
|
}
|
|
87
108
|
listAgents() {
|
|
88
109
|
const rows = this.database.prepare(agentSelectSql("ORDER BY pinned DESC, a.label COLLATE NOCASE, a.source_id")).all();
|
|
@@ -116,81 +137,192 @@ export class WebStore {
|
|
|
116
137
|
if (input.text.trim().length === 0) {
|
|
117
138
|
throw new WebConsoleError("invalid_notification", "Notification text cannot be empty.", 400);
|
|
118
139
|
}
|
|
119
|
-
|
|
140
|
+
if ((input.jobId === undefined) !== (input.runId === undefined)
|
|
141
|
+
|| (input.jobId !== undefined && input.triggerKind !== "cron")) {
|
|
142
|
+
throw new WebConsoleError("invalid_notification", "jobId and runId must be supplied together for cron notifications only.", 400);
|
|
143
|
+
}
|
|
144
|
+
const channel = input.jobId === undefined
|
|
145
|
+
? undefined
|
|
146
|
+
: this.cronChannel(input.sourceId, input.jobId);
|
|
147
|
+
const threadId = input.jobId === undefined
|
|
148
|
+
? notificationThreadId(input.sourceId, input.deliveryKey)
|
|
149
|
+
: channel?.thread_id ?? cronChannelThreadId(input.sourceId, input.jobId);
|
|
150
|
+
// Identity is compared through the structured columns below. Keep the
|
|
151
|
+
// content digest compatible with pre-v5 receipts so a newly-structured
|
|
152
|
+
// replay of an adopted historical delivery remains idempotent.
|
|
120
153
|
const payloadSha256 = notificationPayloadSha256(input.triggerKind, input.text);
|
|
121
154
|
const existing = this.database.prepare(`
|
|
122
155
|
SELECT * FROM notification_deliveries WHERE source_id = ? AND delivery_key = ?
|
|
123
156
|
`).get(input.sourceId, input.deliveryKey);
|
|
124
157
|
if (existing !== undefined) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
158
|
+
const historicalIdentity = input.jobId === undefined
|
|
159
|
+
&& existing.trigger_kind === "cron"
|
|
160
|
+
&& existing.job_id !== null
|
|
161
|
+
&& existing.run_id !== null
|
|
162
|
+
? legacyCronDeliveryIdentity(input.deliveryKey)
|
|
163
|
+
: undefined;
|
|
164
|
+
const expectedJobId = input.jobId ?? historicalIdentity?.jobId ?? null;
|
|
165
|
+
const expectedRunId = input.runId ?? historicalIdentity?.runId ?? null;
|
|
166
|
+
if (existing.trigger_kind !== input.triggerKind
|
|
167
|
+
|| existing.payload_sha256 !== payloadSha256
|
|
168
|
+
|| existing.job_id !== expectedJobId
|
|
169
|
+
|| existing.run_id !== expectedRunId) {
|
|
128
170
|
throw new WebConsoleError("notification_idempotency_conflict", "The notification delivery key was already used with different content.", 409);
|
|
129
171
|
}
|
|
172
|
+
if (existing.completed_at !== null && existing.thread_id === null) {
|
|
173
|
+
return {
|
|
174
|
+
...input,
|
|
175
|
+
...(historicalIdentity === undefined ? {} : historicalIdentity),
|
|
176
|
+
payloadSha256,
|
|
177
|
+
duplicate: true,
|
|
178
|
+
tombstoned: true,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
130
181
|
if (existing.completed_at !== null && this.getThread(existing.thread_id) === undefined) {
|
|
131
182
|
throw new WebConsoleError("storage_corrupt", "A completed notification is missing its conversation.", 500);
|
|
132
183
|
}
|
|
133
|
-
return {
|
|
184
|
+
return {
|
|
185
|
+
...input,
|
|
186
|
+
...(historicalIdentity === undefined ? {} : historicalIdentity),
|
|
187
|
+
...(existing.thread_id === null ? {} : { threadId: existing.thread_id }),
|
|
188
|
+
payloadSha256,
|
|
189
|
+
duplicate: existing.completed_at !== null,
|
|
190
|
+
};
|
|
134
191
|
}
|
|
135
192
|
const now = this.now();
|
|
193
|
+
if (input.jobId !== undefined
|
|
194
|
+
&& input.runId !== undefined
|
|
195
|
+
&& this.database.prepare(`
|
|
196
|
+
SELECT 1 FROM cron_channel_deletions WHERE source_id = ? AND job_id = ?
|
|
197
|
+
`).get(input.sourceId, input.jobId) !== undefined) {
|
|
198
|
+
this.database.prepare(`
|
|
199
|
+
INSERT INTO notification_deliveries (
|
|
200
|
+
source_id, delivery_key, thread_id, trigger_kind, job_id, run_id,
|
|
201
|
+
message_id, payload_sha256, created_at, completed_at
|
|
202
|
+
) VALUES (?, ?, NULL, ?, ?, ?, NULL, ?, ?, ?)
|
|
203
|
+
`).run(input.sourceId, input.deliveryKey, input.triggerKind, input.jobId, input.runId, payloadSha256, now, now);
|
|
204
|
+
return { ...input, payloadSha256, duplicate: true, tombstoned: true };
|
|
205
|
+
}
|
|
136
206
|
this.database.prepare(`
|
|
137
207
|
INSERT INTO notification_deliveries (
|
|
138
|
-
source_id, delivery_key, thread_id, trigger_kind,
|
|
139
|
-
|
|
140
|
-
|
|
208
|
+
source_id, delivery_key, thread_id, trigger_kind, job_id, run_id,
|
|
209
|
+
message_id, payload_sha256, created_at, completed_at
|
|
210
|
+
) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, NULL)
|
|
211
|
+
`).run(input.sourceId, input.deliveryKey, threadId, input.triggerKind, input.jobId ?? null, input.runId ?? null, payloadSha256, now);
|
|
141
212
|
return { ...input, threadId, payloadSha256, duplicate: false };
|
|
142
213
|
}
|
|
214
|
+
/** Agent-history namespace for a reserved console delivery. */
|
|
215
|
+
notificationConversationId(reservation) {
|
|
216
|
+
if (reservation.threadId === undefined) {
|
|
217
|
+
throw new WebConsoleError("notification_reservation_lost", "The notification reservation has no target.", 409);
|
|
218
|
+
}
|
|
219
|
+
return reservation.jobId === undefined
|
|
220
|
+
? `web:${reservation.threadId}`
|
|
221
|
+
: cronConsoleConversationId(reservation.sourceId, reservation.jobId);
|
|
222
|
+
}
|
|
143
223
|
completeNotification(reservation) {
|
|
144
224
|
const existing = this.database.prepare(`
|
|
145
225
|
SELECT * FROM notification_deliveries WHERE source_id = ? AND delivery_key = ?
|
|
146
226
|
`).get(reservation.sourceId, reservation.deliveryKey);
|
|
147
227
|
if (existing === undefined
|
|
148
|
-
|| existing.thread_id !== reservation.threadId
|
|
149
228
|
|| existing.trigger_kind !== reservation.triggerKind
|
|
150
|
-
|| existing.payload_sha256 !== reservation.payloadSha256
|
|
229
|
+
|| existing.payload_sha256 !== reservation.payloadSha256
|
|
230
|
+
|| existing.job_id !== (reservation.jobId ?? null)
|
|
231
|
+
|| existing.run_id !== (reservation.runId ?? null)) {
|
|
151
232
|
throw new WebConsoleError("notification_reservation_lost", "The notification reservation is no longer valid.", 409);
|
|
152
233
|
}
|
|
153
234
|
if (existing.completed_at !== null) {
|
|
235
|
+
if (existing.thread_id === null)
|
|
236
|
+
return { duplicate: true, tombstoned: true };
|
|
154
237
|
return { thread: this.requireThread(existing.thread_id), duplicate: true };
|
|
155
238
|
}
|
|
239
|
+
if (existing.thread_id === null || reservation.threadId === undefined) {
|
|
240
|
+
throw new WebConsoleError("notification_reservation_lost", "The notification reservation lost its target.", 409);
|
|
241
|
+
}
|
|
156
242
|
const now = this.now();
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
243
|
+
let turnId = randomUUID();
|
|
244
|
+
let assistantMessageId = randomUUID();
|
|
245
|
+
let completedThreadId = existing.thread_id;
|
|
160
246
|
this.transaction(() => {
|
|
161
|
-
|
|
247
|
+
const cronChannel = reservation.jobId === undefined
|
|
248
|
+
? undefined
|
|
249
|
+
: this.cronChannel(reservation.sourceId, reservation.jobId);
|
|
250
|
+
completedThreadId = cronChannel?.thread_id ?? existing.thread_id;
|
|
251
|
+
const existingThread = this.getThread(completedThreadId);
|
|
252
|
+
if (existingThread === undefined) {
|
|
253
|
+
const title = reservation.jobId === undefined
|
|
254
|
+
? reservation.triggerKind === "cron" ? "Cron notification" : "Webhook notification"
|
|
255
|
+
: `Cron · ${reservation.jobId}`;
|
|
256
|
+
this.database.prepare(`
|
|
257
|
+
INSERT INTO threads (
|
|
258
|
+
id, source_id, conversation_id, title, title_manual, trigger_kind, archived_at,
|
|
259
|
+
created_at, updated_at, revision
|
|
260
|
+
) VALUES (?, ?, ?, ?, 0, ?, NULL, ?, ?, 1)
|
|
261
|
+
`).run(completedThreadId, reservation.sourceId, reservation.jobId === undefined
|
|
262
|
+
? `web:${completedThreadId}`
|
|
263
|
+
: cronConsoleConversationId(reservation.sourceId, reservation.jobId), title, reservation.triggerKind, now, now);
|
|
264
|
+
}
|
|
265
|
+
else if (reservation.jobId === undefined) {
|
|
162
266
|
throw new WebConsoleError("notification_idempotency_conflict", "The notification conversation already exists.", 409);
|
|
163
267
|
}
|
|
268
|
+
if (reservation.jobId !== undefined) {
|
|
269
|
+
this.database.prepare(`
|
|
270
|
+
INSERT INTO cron_channels (
|
|
271
|
+
source_id, job_id, thread_id, configured, created_at, updated_at
|
|
272
|
+
) VALUES (?, ?, ?, 0, ?, ?)
|
|
273
|
+
ON CONFLICT(source_id, job_id) DO UPDATE SET
|
|
274
|
+
thread_id = excluded.thread_id,
|
|
275
|
+
updated_at = excluded.updated_at
|
|
276
|
+
`).run(reservation.sourceId, reservation.jobId, completedThreadId, now, now);
|
|
277
|
+
}
|
|
278
|
+
const mappedRun = reservation.jobId === undefined || reservation.runId === undefined
|
|
279
|
+
? undefined
|
|
280
|
+
: this.database.prepare(`
|
|
281
|
+
SELECT turn_id, message_id FROM cron_run_messages
|
|
282
|
+
WHERE source_id = ? AND job_id = ? AND run_id = ? AND thread_id = ?
|
|
283
|
+
`).get(reservation.sourceId, reservation.jobId, reservation.runId, completedThreadId);
|
|
284
|
+
if (mappedRun === undefined) {
|
|
285
|
+
this.database.prepare(`
|
|
286
|
+
INSERT INTO turns (
|
|
287
|
+
id, thread_id, status, text, model, effort, assistant_message_id,
|
|
288
|
+
started_at, finished_at, error_code, error_message
|
|
289
|
+
) VALUES (?, ?, 'complete', '', NULL, NULL, ?, ?, ?, NULL, NULL)
|
|
290
|
+
`).run(turnId, completedThreadId, assistantMessageId, now, now);
|
|
291
|
+
this.database.prepare(`
|
|
292
|
+
INSERT INTO messages (
|
|
293
|
+
id, thread_id, turn_id, role, parts_json, created_at, updated_at, status
|
|
294
|
+
) VALUES (?, ?, ?, 'assistant', ?, ?, ?, 'complete')
|
|
295
|
+
`).run(assistantMessageId, completedThreadId, turnId, serializeParts([{ type: "text", text: reservation.text }]), now, now);
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
turnId = mappedRun.turn_id;
|
|
299
|
+
assistantMessageId = mappedRun.message_id;
|
|
300
|
+
const message = this.database.prepare(`
|
|
301
|
+
SELECT parts_json FROM messages WHERE id = ? AND thread_id = ? AND turn_id = ?
|
|
302
|
+
`).get(assistantMessageId, completedThreadId, turnId);
|
|
303
|
+
if (message === undefined) {
|
|
304
|
+
throw new WebConsoleError("storage_corrupt", "A cron run mapping is missing its message.", 500);
|
|
305
|
+
}
|
|
306
|
+
const parts = parseParts(message.parts_json);
|
|
307
|
+
if (!parts.some((part) => part.type === "text" && part.text === reservation.text)) {
|
|
308
|
+
parts.push({ type: "text", text: reservation.text });
|
|
309
|
+
this.database.prepare("UPDATE messages SET parts_json = ?, updated_at = ? WHERE id = ?")
|
|
310
|
+
.run(serializeParts(parts), now, assistantMessageId);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
164
313
|
this.database.prepare(`
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
) VALUES (?, ?, ?, ?, 0, ?, NULL, ?, ?, 1)
|
|
169
|
-
`).run(reservation.threadId, reservation.sourceId, `web:${reservation.threadId}`, title, reservation.triggerKind, now, now);
|
|
170
|
-
this.database.prepare(`
|
|
171
|
-
INSERT INTO turns (
|
|
172
|
-
id, thread_id, status, text, model, effort, assistant_message_id,
|
|
173
|
-
started_at, finished_at, error_code, error_message
|
|
174
|
-
) VALUES (?, ?, 'complete', '', NULL, NULL, ?, ?, ?, NULL, NULL)
|
|
175
|
-
`).run(turnId, reservation.threadId, assistantMessageId, now, now);
|
|
176
|
-
this.database.prepare(`
|
|
177
|
-
INSERT INTO messages (
|
|
178
|
-
id, thread_id, turn_id, role, parts_json, created_at, updated_at, status
|
|
179
|
-
) VALUES (?, ?, ?, 'assistant', ?, ?, ?, 'complete')
|
|
180
|
-
`).run(assistantMessageId, reservation.threadId, turnId, JSON.stringify([{ type: "text", text: reservation.text }]), now, now);
|
|
181
|
-
this.database.prepare(`
|
|
182
|
-
INSERT INTO revisions (entity_kind, entity_id, revision, event, created_at)
|
|
183
|
-
VALUES ('thread', ?, 1, 'notification_created', ?)
|
|
184
|
-
`).run(reservation.threadId, now);
|
|
314
|
+
UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?
|
|
315
|
+
`).run(now, completedThreadId);
|
|
316
|
+
this.recordThreadRevision(completedThreadId, "notification_created", now);
|
|
185
317
|
this.database.prepare(`
|
|
186
|
-
UPDATE notification_deliveries SET completed_at = ?
|
|
318
|
+
UPDATE notification_deliveries SET thread_id = ?, message_id = ?, completed_at = ?
|
|
187
319
|
WHERE source_id = ? AND delivery_key = ? AND completed_at IS NULL
|
|
188
|
-
`).run(now, reservation.sourceId, reservation.deliveryKey);
|
|
320
|
+
`).run(completedThreadId, assistantMessageId, now, reservation.sourceId, reservation.deliveryKey);
|
|
189
321
|
const agent = this.getAgent(reservation.sourceId);
|
|
190
322
|
this.enqueueWebPushEventInTransaction({
|
|
191
|
-
logicalKey:
|
|
323
|
+
logicalKey: notificationPushLogicalKey(reservation.sourceId, reservation.deliveryKey),
|
|
192
324
|
kind: "response.ready",
|
|
193
|
-
threadId:
|
|
325
|
+
threadId: completedThreadId,
|
|
194
326
|
sourceId: reservation.sourceId,
|
|
195
327
|
title: `${agent?.label ?? "mono-agent"} · ${reservation.triggerKind.toUpperCase()}`,
|
|
196
328
|
body: reservation.text,
|
|
@@ -198,7 +330,483 @@ export class WebStore {
|
|
|
198
330
|
notBefore: new Date(new Date(now).getTime() + 3_000).toISOString(),
|
|
199
331
|
});
|
|
200
332
|
});
|
|
201
|
-
return { thread: this.requireThread(
|
|
333
|
+
return { thread: this.requireThread(completedThreadId), duplicate: false };
|
|
334
|
+
}
|
|
335
|
+
/** Persist an agent-authoritative overview without deriving scheduler facts in the console. */
|
|
336
|
+
syncCronOverviewResult(overview) {
|
|
337
|
+
const effectiveJobs = this.effectiveIncomingCronJobs(overview.sourceId, overview.jobs);
|
|
338
|
+
if (this.cronOverviewMatches(overview, effectiveJobs)) {
|
|
339
|
+
const stored = this.storedCronOverview(overview.sourceId);
|
|
340
|
+
if (stored === undefined) {
|
|
341
|
+
throw new WebConsoleError("storage_corrupt", "Matched cron overview disappeared.", 500);
|
|
342
|
+
}
|
|
343
|
+
const threadByJobId = new Map(stored.jobs.map((job) => [job.jobId, job.threadId]));
|
|
344
|
+
return {
|
|
345
|
+
overview: {
|
|
346
|
+
generatedAt: overview.generatedAt,
|
|
347
|
+
actionsEnabled: overview.actionsEnabled,
|
|
348
|
+
jobs: effectiveJobs.map((job) => {
|
|
349
|
+
const threadId = threadByJobId.get(job.jobId);
|
|
350
|
+
if (threadId === undefined) {
|
|
351
|
+
throw new WebConsoleError("storage_corrupt", "Matched cron channel is missing.", 500);
|
|
352
|
+
}
|
|
353
|
+
return { ...job, threadId };
|
|
354
|
+
}),
|
|
355
|
+
...(overview.degradedReason === undefined ? {} : { degradedReason: overview.degradedReason }),
|
|
356
|
+
...(overview.jobsTruncated === true ? { jobsTruncated: true } : {}),
|
|
357
|
+
},
|
|
358
|
+
changed: false,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
return { overview: this.syncCronOverview(overview), changed: true };
|
|
362
|
+
}
|
|
363
|
+
syncCronOverview(overview) {
|
|
364
|
+
if (this.getAgent(overview.sourceId) === undefined) {
|
|
365
|
+
throw new WebConsoleError("agent_not_found", "Agent not found.", 404);
|
|
366
|
+
}
|
|
367
|
+
const effectiveJobs = this.effectiveIncomingCronJobs(overview.sourceId, overview.jobs);
|
|
368
|
+
const now = this.now();
|
|
369
|
+
const jobs = [];
|
|
370
|
+
this.transaction(() => {
|
|
371
|
+
this.database.prepare("UPDATE cron_channels SET configured = 0, updated_at = ? WHERE source_id = ?")
|
|
372
|
+
.run(now, overview.sourceId);
|
|
373
|
+
// Jobs omitted by the new authoritative overview remain historical
|
|
374
|
+
// channels. Reconcile their cached payload too so an offline read cannot
|
|
375
|
+
// resurrect the prior configured:true value after the relational column
|
|
376
|
+
// has already been cleared.
|
|
377
|
+
const removedSnapshots = this.database.prepare(`
|
|
378
|
+
SELECT s.job_id, s.payload_json
|
|
379
|
+
FROM cron_job_snapshots s
|
|
380
|
+
JOIN cron_channels c ON c.source_id = s.source_id AND c.job_id = s.job_id
|
|
381
|
+
WHERE s.source_id = ? AND c.configured = 0
|
|
382
|
+
`).all(overview.sourceId);
|
|
383
|
+
const markSnapshotRemoved = this.database.prepare(`
|
|
384
|
+
UPDATE cron_job_snapshots SET payload_json = ?, updated_at = ?
|
|
385
|
+
WHERE source_id = ? AND job_id = ?
|
|
386
|
+
`);
|
|
387
|
+
for (const snapshot of removedSnapshots) {
|
|
388
|
+
const job = parseStoredCronJob(snapshot.payload_json);
|
|
389
|
+
markSnapshotRemoved.run(JSON.stringify({ ...job, configured: false }), now, overview.sourceId, snapshot.job_id);
|
|
390
|
+
}
|
|
391
|
+
for (const job of effectiveJobs) {
|
|
392
|
+
if (job.configured) {
|
|
393
|
+
this.database.prepare("DELETE FROM cron_channel_deletions WHERE source_id = ? AND job_id = ?")
|
|
394
|
+
.run(overview.sourceId, job.jobId);
|
|
395
|
+
}
|
|
396
|
+
const current = this.cronChannel(overview.sourceId, job.jobId);
|
|
397
|
+
const threadId = current?.thread_id ?? cronChannelThreadId(overview.sourceId, job.jobId);
|
|
398
|
+
const existingThread = this.database.prepare("SELECT source_id, trigger_kind FROM threads WHERE id = ?")
|
|
399
|
+
.get(threadId);
|
|
400
|
+
if (existingThread !== undefined
|
|
401
|
+
&& (existingThread.source_id !== overview.sourceId || existingThread.trigger_kind !== "cron")) {
|
|
402
|
+
throw new WebConsoleError("storage_corrupt", "Cron channel identity collides with another conversation.", 500);
|
|
403
|
+
}
|
|
404
|
+
if (existingThread === undefined) {
|
|
405
|
+
this.database.prepare(`
|
|
406
|
+
INSERT INTO threads (
|
|
407
|
+
id, source_id, conversation_id, title, title_manual, trigger_kind,
|
|
408
|
+
archived_at, created_at, updated_at, revision
|
|
409
|
+
) VALUES (?, ?, ?, ?, 0, 'cron', NULL, ?, ?, 1)
|
|
410
|
+
`).run(threadId, overview.sourceId, cronConsoleConversationId(overview.sourceId, job.jobId), `Cron · ${job.jobId}`, now, now);
|
|
411
|
+
this.database.prepare(`
|
|
412
|
+
INSERT INTO revisions (entity_kind, entity_id, revision, event, created_at)
|
|
413
|
+
VALUES ('thread', ?, 1, 'cron_channel_created', ?)
|
|
414
|
+
`).run(threadId, now);
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
this.database.prepare(`
|
|
418
|
+
UPDATE threads SET trigger_kind = 'cron',
|
|
419
|
+
title = CASE WHEN title_manual = 0 THEN ? ELSE title END
|
|
420
|
+
WHERE id = ?
|
|
421
|
+
`).run(`Cron · ${job.jobId}`, threadId);
|
|
422
|
+
}
|
|
423
|
+
this.database.prepare(`
|
|
424
|
+
INSERT INTO cron_channels (source_id, job_id, thread_id, configured, created_at, updated_at)
|
|
425
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
426
|
+
ON CONFLICT(source_id, job_id) DO UPDATE SET
|
|
427
|
+
thread_id = excluded.thread_id,
|
|
428
|
+
configured = excluded.configured,
|
|
429
|
+
updated_at = excluded.updated_at
|
|
430
|
+
`).run(overview.sourceId, job.jobId, threadId, job.configured ? 1 : 0, now, now);
|
|
431
|
+
this.database.prepare(`
|
|
432
|
+
INSERT INTO cron_job_snapshots (source_id, job_id, payload_json, updated_at)
|
|
433
|
+
VALUES (?, ?, ?, ?)
|
|
434
|
+
ON CONFLICT(source_id, job_id) DO UPDATE SET
|
|
435
|
+
payload_json = excluded.payload_json,
|
|
436
|
+
updated_at = excluded.updated_at
|
|
437
|
+
`).run(overview.sourceId, job.jobId, JSON.stringify(job), now);
|
|
438
|
+
jobs.push({ ...job, threadId });
|
|
439
|
+
}
|
|
440
|
+
this.database.prepare(`
|
|
441
|
+
INSERT INTO cron_overviews (
|
|
442
|
+
source_id, generated_at, actions_enabled, degraded_reason, jobs_truncated, updated_at
|
|
443
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
444
|
+
ON CONFLICT(source_id) DO UPDATE SET
|
|
445
|
+
generated_at = excluded.generated_at,
|
|
446
|
+
actions_enabled = excluded.actions_enabled,
|
|
447
|
+
degraded_reason = excluded.degraded_reason,
|
|
448
|
+
jobs_truncated = excluded.jobs_truncated,
|
|
449
|
+
updated_at = excluded.updated_at
|
|
450
|
+
`).run(overview.sourceId, overview.generatedAt, overview.actionsEnabled ? 1 : 0, overview.degradedReason ?? null, overview.jobsTruncated === true ? 1 : 0, now);
|
|
451
|
+
});
|
|
452
|
+
return {
|
|
453
|
+
generatedAt: overview.generatedAt,
|
|
454
|
+
actionsEnabled: overview.actionsEnabled,
|
|
455
|
+
jobs,
|
|
456
|
+
...(overview.degradedReason === undefined ? {} : { degradedReason: overview.degradedReason }),
|
|
457
|
+
...(overview.jobsTruncated === true ? { jobsTruncated: true } : {}),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
effectiveIncomingCronJobs(sourceId, jobs) {
|
|
461
|
+
const tombstoned = this.database.prepare(`
|
|
462
|
+
SELECT 1 FROM cron_channel_deletions WHERE source_id = ? AND job_id = ?
|
|
463
|
+
`);
|
|
464
|
+
return jobs.filter((job) => job.configured || tombstoned.get(sourceId, job.jobId) === undefined);
|
|
465
|
+
}
|
|
466
|
+
cronOverviewMatches(overview, effectiveJobs) {
|
|
467
|
+
const stored = this.storedCronOverview(overview.sourceId);
|
|
468
|
+
if (stored === undefined
|
|
469
|
+
|| stored.actionsEnabled !== overview.actionsEnabled
|
|
470
|
+
|| stored.degradedReason !== overview.degradedReason
|
|
471
|
+
|| (stored.jobsTruncated === true) !== (overview.jobsTruncated === true))
|
|
472
|
+
return false;
|
|
473
|
+
const storedById = new Map(stored.jobs.map((job) => [job.jobId, job]));
|
|
474
|
+
for (const incoming of effectiveJobs) {
|
|
475
|
+
const current = storedById.get(incoming.jobId);
|
|
476
|
+
if (current === undefined)
|
|
477
|
+
return false;
|
|
478
|
+
const { threadId: _threadId, ...currentPayload } = current;
|
|
479
|
+
if (!isDeepStrictEqual(currentPayload, incoming))
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
if (overview.jobsTruncated === true)
|
|
483
|
+
return true;
|
|
484
|
+
const incomingIds = new Set(effectiveJobs.map((job) => job.jobId));
|
|
485
|
+
return stored.jobs.every((job) => incomingIds.has(job.jobId) || job.configured === false);
|
|
486
|
+
}
|
|
487
|
+
storedCronOverview(sourceId) {
|
|
488
|
+
const overview = this.database.prepare("SELECT * FROM cron_overviews WHERE source_id = ?")
|
|
489
|
+
.get(sourceId);
|
|
490
|
+
if (overview === undefined)
|
|
491
|
+
return undefined;
|
|
492
|
+
const rows = this.database.prepare(`
|
|
493
|
+
SELECT s.payload_json, c.thread_id, c.configured
|
|
494
|
+
FROM cron_job_snapshots s
|
|
495
|
+
JOIN cron_channels c ON c.source_id = s.source_id AND c.job_id = s.job_id
|
|
496
|
+
WHERE s.source_id = ? ORDER BY s.job_id
|
|
497
|
+
`).all(sourceId);
|
|
498
|
+
const jobs = rows.map((row) => ({
|
|
499
|
+
...parseStoredCronJob(row.payload_json),
|
|
500
|
+
configured: row.configured === 1,
|
|
501
|
+
threadId: row.thread_id,
|
|
502
|
+
}));
|
|
503
|
+
return {
|
|
504
|
+
generatedAt: overview.generated_at,
|
|
505
|
+
actionsEnabled: overview.actions_enabled === 1,
|
|
506
|
+
jobs,
|
|
507
|
+
...(overview.degraded_reason === null ? {} : { degradedReason: overview.degraded_reason }),
|
|
508
|
+
...(overview.jobs_truncated === 1 ? { jobsTruncated: true } : {}),
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
cronThread(sourceId, jobId) {
|
|
512
|
+
const channel = this.cronChannel(sourceId, jobId);
|
|
513
|
+
return channel === undefined ? undefined : this.getThread(channel.thread_id);
|
|
514
|
+
}
|
|
515
|
+
cronConversationIdForThread(threadId) {
|
|
516
|
+
const resolved = this.resolveThreadId(threadId);
|
|
517
|
+
const row = this.database.prepare(`
|
|
518
|
+
SELECT s.payload_json FROM cron_channels c
|
|
519
|
+
JOIN cron_job_snapshots s ON s.source_id = c.source_id AND s.job_id = c.job_id
|
|
520
|
+
WHERE c.thread_id = ?
|
|
521
|
+
`).get(resolved);
|
|
522
|
+
return row === undefined ? undefined : parseStoredCronJob(row.payload_json).conversationId;
|
|
523
|
+
}
|
|
524
|
+
storedCronRuns(sourceId, jobId, limit = 100) {
|
|
525
|
+
const bounded = boundedPageLimit(limit, 100);
|
|
526
|
+
const rows = this.database.prepare(`
|
|
527
|
+
SELECT payload_json FROM cron_run_messages
|
|
528
|
+
WHERE source_id = ? AND job_id = ?
|
|
529
|
+
ORDER BY ordered_at DESC, sequence DESC, run_id DESC LIMIT ?
|
|
530
|
+
`).all(sourceId, jobId, bounded);
|
|
531
|
+
return { runs: rows.map((row) => parseStoredCronRun(row.payload_json)) };
|
|
532
|
+
}
|
|
533
|
+
reconcileCronRuns(sourceId, jobId, runs) {
|
|
534
|
+
return [...this.reconcileCronRunsResult(sourceId, jobId, runs).messages];
|
|
535
|
+
}
|
|
536
|
+
reconcileCronRunsResult(sourceId, jobId, runs) {
|
|
537
|
+
const channel = this.cronChannel(sourceId, jobId);
|
|
538
|
+
if (channel === undefined) {
|
|
539
|
+
throw new WebConsoleError("cron_job_not_found", "Cron channel not found.", 404);
|
|
540
|
+
}
|
|
541
|
+
const jobRow = this.database.prepare(`
|
|
542
|
+
SELECT payload_json FROM cron_job_snapshots WHERE source_id = ? AND job_id = ?
|
|
543
|
+
`).get(sourceId, jobId);
|
|
544
|
+
const conversationId = jobRow === undefined
|
|
545
|
+
? `cron:${jobId}`
|
|
546
|
+
: parseStoredCronJob(jobRow.payload_json).conversationId;
|
|
547
|
+
const ordered = [...runs].sort(compareCronRuns);
|
|
548
|
+
if (ordered.length === 0)
|
|
549
|
+
return { messages: [], changed: false };
|
|
550
|
+
const now = this.now();
|
|
551
|
+
const messageIds = [];
|
|
552
|
+
let changed = false;
|
|
553
|
+
this.transaction(() => {
|
|
554
|
+
for (const run of ordered) {
|
|
555
|
+
if (run.jobId !== jobId) {
|
|
556
|
+
throw new WebConsoleError("invalid_cron_response", "Cron run belongs to another job.", 502);
|
|
557
|
+
}
|
|
558
|
+
const mapped = this.database.prepare(`
|
|
559
|
+
SELECT thread_id, turn_id, message_id, ordered_at, sequence, payload_json
|
|
560
|
+
FROM cron_run_messages
|
|
561
|
+
WHERE source_id = ? AND job_id = ? AND run_id = ?
|
|
562
|
+
`).get(sourceId, jobId, run.runId);
|
|
563
|
+
if (mapped !== undefined && (mapped.ordered_at !== run.orderedAt || mapped.sequence !== run.sequence)) {
|
|
564
|
+
throw new WebConsoleError("invalid_cron_response", "Cron run ordering identity changed after admission.", 502);
|
|
565
|
+
}
|
|
566
|
+
const delivered = mapped === undefined
|
|
567
|
+
? this.database.prepare(`
|
|
568
|
+
SELECT d.message_id, m.turn_id
|
|
569
|
+
FROM notification_deliveries d
|
|
570
|
+
JOIN messages m ON m.id = d.message_id
|
|
571
|
+
WHERE d.source_id = ? AND d.job_id = ? AND d.run_id = ?
|
|
572
|
+
AND d.thread_id = ? AND d.completed_at IS NOT NULL
|
|
573
|
+
ORDER BY d.completed_at DESC LIMIT 1
|
|
574
|
+
`).get(sourceId, jobId, run.runId, channel.thread_id)
|
|
575
|
+
: undefined;
|
|
576
|
+
const turnId = mapped?.turn_id ?? delivered?.turn_id ?? cronEntityId("turn", sourceId, jobId, run.runId);
|
|
577
|
+
const messageId = mapped?.message_id ?? delivered?.message_id ?? cronEntityId("message", sourceId, jobId, run.runId);
|
|
578
|
+
messageIds.push(messageId);
|
|
579
|
+
const prior = this.database.prepare(`
|
|
580
|
+
SELECT thread_id, turn_id, parts_json, created_at, status FROM messages WHERE id = ?
|
|
581
|
+
`).get(messageId);
|
|
582
|
+
const priorParts = prior === undefined ? [] : parseParts(prior.parts_json);
|
|
583
|
+
const parts = cronRunParts(run, priorParts, conversationId);
|
|
584
|
+
const serializedParts = serializeParts(parts);
|
|
585
|
+
const status = cronMessageStatus(run.status);
|
|
586
|
+
const turnStatus = status === "running" ? "running" : status;
|
|
587
|
+
const finishedAt = status === "running" ? null : run.completedAt ?? run.orderedAt;
|
|
588
|
+
const existingTurn = this.database.prepare(`
|
|
589
|
+
SELECT thread_id, status, text, assistant_message_id, started_at, finished_at,
|
|
590
|
+
error_code, error_message FROM turns WHERE id = ?
|
|
591
|
+
`).get(turnId);
|
|
592
|
+
const preserveLoadedText = run.projection === "summary"
|
|
593
|
+
&& run.fieldsTruncated?.includes("text") === true
|
|
594
|
+
&& priorParts.some((part) => part.type === "telemetry"
|
|
595
|
+
&& part.event === "cron_run"
|
|
596
|
+
&& record(part.data)?.activityLoaded === true);
|
|
597
|
+
const preserveLoadedError = run.projection === "summary"
|
|
598
|
+
&& priorParts.some((part) => part.type === "telemetry"
|
|
599
|
+
&& part.event === "cron_run"
|
|
600
|
+
&& record(part.data)?.activityLoaded === true)
|
|
601
|
+
&& (run.fieldsTruncated?.includes("error") === true
|
|
602
|
+
|| run.fieldsTruncated?.includes("failureKind") === true);
|
|
603
|
+
const turnText = preserveLoadedText && existingTurn !== undefined
|
|
604
|
+
? existingTurn.text
|
|
605
|
+
: run.text ?? "";
|
|
606
|
+
const turnErrorCode = preserveLoadedError && existingTurn !== undefined
|
|
607
|
+
? existingTurn.error_code
|
|
608
|
+
: run.failureKind ?? null;
|
|
609
|
+
const turnErrorMessage = preserveLoadedError && existingTurn !== undefined
|
|
610
|
+
? existingTurn.error_message
|
|
611
|
+
: run.error ?? null;
|
|
612
|
+
if (existingTurn === undefined) {
|
|
613
|
+
this.database.prepare(`
|
|
614
|
+
INSERT INTO turns (
|
|
615
|
+
id, thread_id, status, text, model, effort, assistant_message_id,
|
|
616
|
+
started_at, finished_at, error_code, error_message
|
|
617
|
+
) VALUES (?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?)
|
|
618
|
+
`).run(turnId, channel.thread_id, turnStatus, turnText, messageId, run.orderedAt, finishedAt, turnErrorCode, turnErrorMessage);
|
|
619
|
+
changed = true;
|
|
620
|
+
}
|
|
621
|
+
else if (existingTurn.thread_id !== channel.thread_id
|
|
622
|
+
|| existingTurn.status !== turnStatus
|
|
623
|
+
|| existingTurn.text !== turnText
|
|
624
|
+
|| existingTurn.assistant_message_id !== messageId
|
|
625
|
+
|| existingTurn.started_at !== run.orderedAt
|
|
626
|
+
|| existingTurn.finished_at !== finishedAt
|
|
627
|
+
|| existingTurn.error_code !== turnErrorCode
|
|
628
|
+
|| existingTurn.error_message !== turnErrorMessage) {
|
|
629
|
+
this.database.prepare(`
|
|
630
|
+
UPDATE turns SET thread_id = ?, status = ?, text = ?, assistant_message_id = ?,
|
|
631
|
+
started_at = ?, finished_at = ?, error_code = ?, error_message = ?
|
|
632
|
+
WHERE id = ?
|
|
633
|
+
`).run(channel.thread_id, turnStatus, turnText, messageId, run.orderedAt, finishedAt, turnErrorCode, turnErrorMessage, turnId);
|
|
634
|
+
changed = true;
|
|
635
|
+
}
|
|
636
|
+
if (prior === undefined) {
|
|
637
|
+
this.database.prepare(`
|
|
638
|
+
INSERT INTO messages (
|
|
639
|
+
id, thread_id, turn_id, role, parts_json, created_at, updated_at, status
|
|
640
|
+
) VALUES (?, ?, ?, 'assistant', ?, ?, ?, ?)
|
|
641
|
+
`).run(messageId, channel.thread_id, turnId, serializedParts, run.orderedAt, now, status);
|
|
642
|
+
changed = true;
|
|
643
|
+
}
|
|
644
|
+
else if (prior.thread_id !== channel.thread_id
|
|
645
|
+
|| prior.turn_id !== turnId
|
|
646
|
+
|| prior.parts_json !== serializedParts
|
|
647
|
+
|| prior.created_at !== run.orderedAt
|
|
648
|
+
|| prior.status !== status) {
|
|
649
|
+
this.database.prepare(`
|
|
650
|
+
UPDATE messages SET thread_id = ?, turn_id = ?, parts_json = ?,
|
|
651
|
+
created_at = ?, updated_at = ?, status = ? WHERE id = ?
|
|
652
|
+
`).run(channel.thread_id, turnId, serializedParts, run.orderedAt, now, status, messageId);
|
|
653
|
+
changed = true;
|
|
654
|
+
}
|
|
655
|
+
// Detail is a message projection, not a replacement for the compact
|
|
656
|
+
// page identity. Keeping the existing summary prevents the next
|
|
657
|
+
// unchanged page poll from undoing a detail load and creating churn.
|
|
658
|
+
const serializedRun = run.projection === "detail" && mapped !== undefined
|
|
659
|
+
? mapped.payload_json
|
|
660
|
+
: JSON.stringify(cronRunSummary(run));
|
|
661
|
+
if (mapped === undefined
|
|
662
|
+
|| mapped.thread_id !== channel.thread_id
|
|
663
|
+
|| mapped.turn_id !== turnId
|
|
664
|
+
|| mapped.message_id !== messageId
|
|
665
|
+
|| mapped.payload_json !== serializedRun) {
|
|
666
|
+
this.database.prepare(`
|
|
667
|
+
INSERT INTO cron_run_messages (
|
|
668
|
+
source_id, job_id, run_id, thread_id, turn_id, message_id,
|
|
669
|
+
ordered_at, sequence, payload_json, updated_at
|
|
670
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
671
|
+
ON CONFLICT(source_id, job_id, run_id) DO UPDATE SET
|
|
672
|
+
thread_id = excluded.thread_id,
|
|
673
|
+
turn_id = excluded.turn_id,
|
|
674
|
+
message_id = excluded.message_id,
|
|
675
|
+
payload_json = excluded.payload_json,
|
|
676
|
+
updated_at = excluded.updated_at
|
|
677
|
+
`).run(sourceId, jobId, run.runId, channel.thread_id, turnId, messageId, run.orderedAt, run.sequence, serializedRun, now);
|
|
678
|
+
changed = true;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if (changed) {
|
|
682
|
+
const newest = ordered.at(-1);
|
|
683
|
+
this.database.prepare(`
|
|
684
|
+
UPDATE threads SET updated_at = MAX(updated_at, ?), revision = revision + 1 WHERE id = ?
|
|
685
|
+
`).run(newest.completedAt ?? newest.startedAt ?? newest.orderedAt, channel.thread_id);
|
|
686
|
+
this.recordThreadRevision(channel.thread_id, "cron_runs_reconciled", now);
|
|
687
|
+
const excess = this.database.prepare(`
|
|
688
|
+
SELECT turn_id FROM (
|
|
689
|
+
SELECT turn_id, ROW_NUMBER() OVER (
|
|
690
|
+
PARTITION BY source_id, job_id ORDER BY ordered_at DESC, sequence DESC, run_id DESC
|
|
691
|
+
) AS retained_row
|
|
692
|
+
FROM cron_run_messages WHERE source_id = ? AND job_id = ?
|
|
693
|
+
) WHERE retained_row > 500
|
|
694
|
+
`).all(sourceId, jobId);
|
|
695
|
+
const remove = this.database.prepare("DELETE FROM turns WHERE id = ?");
|
|
696
|
+
for (const row of excess)
|
|
697
|
+
remove.run(row.turn_id);
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
return {
|
|
701
|
+
messages: [...new Set(messageIds)].map((messageId) => this.requireMessage(messageId)),
|
|
702
|
+
changed,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
/** Append or update exactly one retained card for a web-origin process job. */
|
|
706
|
+
upsertProcessJobCard(input) {
|
|
707
|
+
const projection = parseProcessJobProjection(input.processJob);
|
|
708
|
+
const thread = this.requireThread(input.threadId);
|
|
709
|
+
if (thread.sourceId !== input.sourceId) {
|
|
710
|
+
throw new WebConsoleError("invalid_notification", "The process job does not belong to this agent thread.", 409);
|
|
711
|
+
}
|
|
712
|
+
const originConversation = projection.origin.conversationId.split("#", 1)[0];
|
|
713
|
+
if (projection.origin.channel !== "web" || originConversation !== `web:${input.threadId}`) {
|
|
714
|
+
throw new WebConsoleError("invalid_notification", "The process job origin does not match this web thread.", 409);
|
|
715
|
+
}
|
|
716
|
+
if (input.deliveryKey !== projection.wake.deliveryKey) {
|
|
717
|
+
throw new WebConsoleError("invalid_notification", "The process job delivery key does not match its projection.", 409);
|
|
718
|
+
}
|
|
719
|
+
if (input.responseText !== undefined
|
|
720
|
+
&& (input.responseText.trim().length === 0 || input.responseText.length > 8_000)) {
|
|
721
|
+
throw new WebConsoleError("invalid_notification", "The process job response must contain 1 to 8000 characters.", 413);
|
|
722
|
+
}
|
|
723
|
+
const existing = this.database.prepare(`
|
|
724
|
+
SELECT * FROM process_job_cards WHERE source_id = ? AND job_id = ?
|
|
725
|
+
`).get(input.sourceId, projection.jobId);
|
|
726
|
+
const projectionJson = JSON.stringify(projection);
|
|
727
|
+
const projectionSha256 = createHash("sha256").update(projectionJson).digest("hex");
|
|
728
|
+
const now = this.now();
|
|
729
|
+
if (existing === undefined) {
|
|
730
|
+
const messageId = randomUUID();
|
|
731
|
+
const parts = processJobCardParts(projection, input.responseText, input.replyParts);
|
|
732
|
+
this.transaction(() => {
|
|
733
|
+
this.database.prepare(`
|
|
734
|
+
INSERT INTO messages (id, thread_id, turn_id, role, parts_json, created_at, updated_at, status)
|
|
735
|
+
VALUES (?, ?, NULL, 'assistant', ?, ?, ?, ?)
|
|
736
|
+
`).run(messageId, input.threadId, serializeParts(parts), now, now, isTerminalJobState(projection.state) ? "complete" : "running");
|
|
737
|
+
this.database.prepare(`
|
|
738
|
+
INSERT INTO process_job_cards (
|
|
739
|
+
source_id, job_id, delivery_key, thread_id, message_id,
|
|
740
|
+
projection_sha256, response_text, created_at, updated_at
|
|
741
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
742
|
+
`).run(input.sourceId, projection.jobId, input.deliveryKey, input.threadId, messageId, projectionSha256, input.responseText ?? null, now, now);
|
|
743
|
+
this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
|
|
744
|
+
.run(now, input.threadId);
|
|
745
|
+
this.recordThreadRevision(input.threadId, "process_job_card_created", now);
|
|
746
|
+
});
|
|
747
|
+
return { thread: this.requireThread(input.threadId), duplicate: false };
|
|
748
|
+
}
|
|
749
|
+
if (existing.thread_id !== input.threadId || existing.delivery_key !== input.deliveryKey) {
|
|
750
|
+
throw new WebConsoleError("notification_idempotency_conflict", "The process job was already bound to a different web thread or delivery key.", 409);
|
|
751
|
+
}
|
|
752
|
+
const message = this.database.prepare("SELECT * FROM messages WHERE id = ?")
|
|
753
|
+
.get(existing.message_id);
|
|
754
|
+
if (message === undefined || message.thread_id !== input.threadId) {
|
|
755
|
+
throw new WebConsoleError("storage_corrupt", "A retained process-job card is missing its message.", 500);
|
|
756
|
+
}
|
|
757
|
+
const priorParts = parseParts(message.parts_json);
|
|
758
|
+
const priorJobParts = priorParts.filter((part) => part.type === "process-job");
|
|
759
|
+
const priorReplyParts = priorParts.filter(isDurableWebReplyPart);
|
|
760
|
+
const priorPart = priorJobParts[0];
|
|
761
|
+
if (priorJobParts.length !== 1
|
|
762
|
+
|| priorPart === undefined
|
|
763
|
+
|| priorPart.job.jobId !== projection.jobId
|
|
764
|
+
|| priorParts.length !== 1 + priorReplyParts.length) {
|
|
765
|
+
throw new WebConsoleError("storage_corrupt", "A retained process-job card has invalid content.", 500);
|
|
766
|
+
}
|
|
767
|
+
assertProcessJobCardTransition(priorPart.job, projection);
|
|
768
|
+
const hasPriorWakeResponse = existing.response_text !== null || priorReplyParts.length > 0;
|
|
769
|
+
if (hasPriorWakeResponse
|
|
770
|
+
&& input.responseText !== undefined
|
|
771
|
+
&& input.responseText !== (existing.response_text ?? undefined)) {
|
|
772
|
+
throw new WebConsoleError("notification_idempotency_conflict", "The process-job wake response cannot be replaced by different text.", 409);
|
|
773
|
+
}
|
|
774
|
+
const responseText = input.responseText ?? existing.response_text ?? undefined;
|
|
775
|
+
const nextReplyParts = input.replyParts === undefined
|
|
776
|
+
? priorReplyParts
|
|
777
|
+
: boundedWebReplyParts(input.replyParts, []);
|
|
778
|
+
if (hasPriorWakeResponse
|
|
779
|
+
&& input.replyParts !== undefined
|
|
780
|
+
&& !isDeepStrictEqual(nextReplyParts, priorReplyParts)) {
|
|
781
|
+
throw new WebConsoleError("notification_idempotency_conflict", "The process-job wake reply parts cannot be replaced by different parts.", 409);
|
|
782
|
+
}
|
|
783
|
+
const replyPartsChanged = !isDeepStrictEqual(nextReplyParts, priorReplyParts);
|
|
784
|
+
if (existing.projection_sha256 === projectionSha256
|
|
785
|
+
&& responseText === (existing.response_text ?? undefined)
|
|
786
|
+
&& !replyPartsChanged) {
|
|
787
|
+
return { thread, duplicate: true };
|
|
788
|
+
}
|
|
789
|
+
this.transaction(() => {
|
|
790
|
+
this.database.prepare(`
|
|
791
|
+
UPDATE messages SET parts_json = ?, updated_at = ?, status = ? WHERE id = ?
|
|
792
|
+
`).run(serializeParts([processJobPart(projection, responseText), ...nextReplyParts]), now, isTerminalJobState(projection.state) ? "complete" : "running", existing.message_id);
|
|
793
|
+
this.database.prepare(`
|
|
794
|
+
UPDATE process_job_cards
|
|
795
|
+
SET projection_sha256 = ?, response_text = ?, updated_at = ?
|
|
796
|
+
WHERE source_id = ? AND job_id = ?
|
|
797
|
+
`).run(projectionSha256, responseText ?? null, now, input.sourceId, projection.jobId);
|
|
798
|
+
this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
|
|
799
|
+
.run(now, input.threadId);
|
|
800
|
+
this.recordThreadRevision(input.threadId, "process_job_card_updated", now);
|
|
801
|
+
});
|
|
802
|
+
return { thread: this.requireThread(input.threadId), duplicate: false };
|
|
803
|
+
}
|
|
804
|
+
/** Exact retained binding used before proxying a single operator job. */
|
|
805
|
+
processJobCardBelongsToThread(sourceId, threadId, jobId) {
|
|
806
|
+
return this.database.prepare(`
|
|
807
|
+
SELECT 1 FROM process_job_cards
|
|
808
|
+
WHERE source_id = ? AND thread_id = ? AND job_id = ?
|
|
809
|
+
`).get(sourceId, threadId, jobId) !== undefined;
|
|
202
810
|
}
|
|
203
811
|
createThread(sourceId) {
|
|
204
812
|
const agent = this.getAgent(sourceId);
|
|
@@ -220,42 +828,152 @@ export class WebStore {
|
|
|
220
828
|
});
|
|
221
829
|
return this.requireThread(id);
|
|
222
830
|
}
|
|
831
|
+
/** Bounded bootstrap: at most one 200-row bucket per (source_id, archived). */
|
|
223
832
|
listThreads() {
|
|
224
|
-
const rows = this.database.prepare(threadSelectSql(
|
|
833
|
+
const rows = this.database.prepare(threadSelectSql(`
|
|
834
|
+
WHERE t.id IN (
|
|
835
|
+
SELECT id FROM (
|
|
836
|
+
SELECT id,
|
|
837
|
+
ROW_NUMBER() OVER (
|
|
838
|
+
PARTITION BY source_id, CASE WHEN archived_at IS NULL THEN 0 ELSE 1 END
|
|
839
|
+
ORDER BY updated_at DESC, id DESC
|
|
840
|
+
) AS bucket_row
|
|
841
|
+
FROM threads
|
|
842
|
+
) WHERE bucket_row <= ${String(WEB_THREAD_PAGE_MAX)}
|
|
843
|
+
)
|
|
844
|
+
ORDER BY t.updated_at DESC, t.id DESC
|
|
845
|
+
`)).all();
|
|
225
846
|
return rows.map((row) => this.mapThread(row));
|
|
226
847
|
}
|
|
848
|
+
listThreadsPage(input) {
|
|
849
|
+
if (this.getAgent(input.sourceId) === undefined) {
|
|
850
|
+
throw new WebConsoleError("agent_not_found", "Agent not found.", 404);
|
|
851
|
+
}
|
|
852
|
+
const limit = boundedPageLimit(input.limit, WEB_THREAD_PAGE_MAX);
|
|
853
|
+
const cursor = input.before === undefined ? undefined : decodeThreadCursor(input.before);
|
|
854
|
+
const archivedSql = input.archived ? "t.archived_at IS NOT NULL" : "t.archived_at IS NULL";
|
|
855
|
+
const beforeSql = cursor === undefined
|
|
856
|
+
? ""
|
|
857
|
+
: "AND (t.updated_at < ? OR (t.updated_at = ? AND t.id < ?))";
|
|
858
|
+
const values = [input.sourceId];
|
|
859
|
+
if (cursor !== undefined)
|
|
860
|
+
values.push(cursor.updatedAt, cursor.updatedAt, cursor.id);
|
|
861
|
+
values.push(limit + 1);
|
|
862
|
+
const rows = this.database.prepare(threadSelectSql(`
|
|
863
|
+
WHERE t.source_id = ? AND ${archivedSql} ${beforeSql}
|
|
864
|
+
ORDER BY t.updated_at DESC, t.id DESC LIMIT ?
|
|
865
|
+
`)).all(...values);
|
|
866
|
+
const hasMore = rows.length > limit;
|
|
867
|
+
const pageRows = rows.slice(0, limit);
|
|
868
|
+
const last = pageRows.at(-1);
|
|
869
|
+
return {
|
|
870
|
+
threads: pageRows.map((row) => this.mapThread(row)),
|
|
871
|
+
...(hasMore && last !== undefined
|
|
872
|
+
? { nextCursor: encodeCursor({ updatedAt: last.updated_at, id: last.id }) }
|
|
873
|
+
: {}),
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
resolveThreadId(id) {
|
|
877
|
+
let resolved = id;
|
|
878
|
+
const seen = new Set();
|
|
879
|
+
for (let depth = 0; depth < 32; depth += 1) {
|
|
880
|
+
if (seen.has(resolved)) {
|
|
881
|
+
throw new WebConsoleError("storage_corrupt", "Conversation redirects contain a cycle.", 500);
|
|
882
|
+
}
|
|
883
|
+
seen.add(resolved);
|
|
884
|
+
const row = this.database.prepare("SELECT new_thread_id FROM thread_redirects WHERE old_thread_id = ?")
|
|
885
|
+
.get(resolved);
|
|
886
|
+
if (row === undefined)
|
|
887
|
+
return resolved;
|
|
888
|
+
resolved = row.new_thread_id;
|
|
889
|
+
}
|
|
890
|
+
throw new WebConsoleError("storage_corrupt", "Conversation redirect chain is too deep.", 500);
|
|
891
|
+
}
|
|
227
892
|
getThread(id) {
|
|
228
|
-
const
|
|
893
|
+
const resolved = this.resolveThreadId(id);
|
|
894
|
+
const row = this.database.prepare(threadSelectSql("WHERE t.id = ?")).get(resolved);
|
|
229
895
|
return row === undefined ? undefined : this.mapThread(row);
|
|
230
896
|
}
|
|
231
897
|
getThreadDetail(id) {
|
|
232
|
-
const
|
|
898
|
+
const resolved = this.resolveThreadId(id);
|
|
899
|
+
const thread = this.getThread(resolved);
|
|
233
900
|
if (thread === undefined)
|
|
234
901
|
return undefined;
|
|
902
|
+
const page = this.listMessagesPage(resolved);
|
|
903
|
+
return {
|
|
904
|
+
thread,
|
|
905
|
+
messages: page.messages,
|
|
906
|
+
...(page.nextCursor === undefined ? {} : { messagesNextCursor: page.nextCursor }),
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
getMessage(id) {
|
|
910
|
+
const row = this.database.prepare("SELECT * FROM messages WHERE id = ?")
|
|
911
|
+
.get(id);
|
|
912
|
+
return row === undefined ? undefined : this.mapMessage(row);
|
|
913
|
+
}
|
|
914
|
+
listMessagesPage(id, input = {}) {
|
|
915
|
+
const resolved = this.resolveThreadId(id);
|
|
916
|
+
if (this.getThread(resolved) === undefined) {
|
|
917
|
+
throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
|
|
918
|
+
}
|
|
919
|
+
const limit = boundedPageLimit(input.limit, WEB_MESSAGE_PAGE_MAX);
|
|
920
|
+
const cursor = input.before === undefined ? undefined : decodeMessageCursor(input.before);
|
|
921
|
+
const rank = messageRoleRankSql("m", "t");
|
|
922
|
+
const orderedAt = "COALESCE(t.started_at, m.created_at)";
|
|
923
|
+
const beforeSql = cursor === undefined ? "" : `AND (
|
|
924
|
+
${orderedAt} < ?
|
|
925
|
+
OR (${orderedAt} = ? AND ${rank} < ?)
|
|
926
|
+
OR (${orderedAt} = ? AND ${rank} = ? AND m.created_at < ?)
|
|
927
|
+
OR (${orderedAt} = ? AND ${rank} = ? AND m.created_at = ? AND m.rowid < ?)
|
|
928
|
+
)`;
|
|
929
|
+
const values = [resolved];
|
|
930
|
+
if (cursor !== undefined) {
|
|
931
|
+
values.push(cursor.orderedAt, cursor.orderedAt, cursor.roleRank, cursor.orderedAt, cursor.roleRank, cursor.createdAt, cursor.orderedAt, cursor.roleRank, cursor.createdAt, cursor.rowid);
|
|
932
|
+
}
|
|
933
|
+
values.push(limit + 1);
|
|
235
934
|
const rows = this.database.prepare(`
|
|
236
|
-
SELECT m
|
|
935
|
+
SELECT m.*, ${orderedAt} AS ordered_at, ${rank} AS role_rank, m.rowid AS storage_rowid
|
|
936
|
+
FROM messages m
|
|
237
937
|
LEFT JOIN turns t ON t.id = m.turn_id
|
|
238
|
-
WHERE m.thread_id = ?
|
|
239
|
-
ORDER BY
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
938
|
+
WHERE m.thread_id = ? ${beforeSql}
|
|
939
|
+
ORDER BY ordered_at DESC, role_rank DESC, m.created_at DESC, storage_rowid DESC
|
|
940
|
+
LIMIT ?
|
|
941
|
+
`).all(...values);
|
|
942
|
+
const hasMore = rows.length > limit;
|
|
943
|
+
const pageRows = rows.slice(0, limit).reverse();
|
|
944
|
+
const oldest = pageRows[0];
|
|
945
|
+
return {
|
|
946
|
+
messages: pageRows.map((row) => this.mapMessage(row)),
|
|
947
|
+
...(hasMore && oldest !== undefined
|
|
948
|
+
? {
|
|
949
|
+
nextCursor: encodeCursor({
|
|
950
|
+
orderedAt: oldest.ordered_at,
|
|
951
|
+
roleRank: oldest.role_rank,
|
|
952
|
+
createdAt: oldest.created_at,
|
|
953
|
+
rowid: oldest.storage_rowid,
|
|
954
|
+
}),
|
|
955
|
+
}
|
|
956
|
+
: {}),
|
|
957
|
+
};
|
|
247
958
|
}
|
|
248
959
|
currentThreadId() {
|
|
249
960
|
const row = this.database.prepare("SELECT value FROM settings WHERE key = 'current_thread_id'").get();
|
|
250
|
-
if (row === undefined
|
|
961
|
+
if (row === undefined)
|
|
962
|
+
return undefined;
|
|
963
|
+
const resolved = this.resolveThreadId(row.value);
|
|
964
|
+
if (this.getThread(resolved) === undefined)
|
|
251
965
|
return undefined;
|
|
252
|
-
|
|
966
|
+
if (resolved !== row.value)
|
|
967
|
+
this.setSetting("current_thread_id", resolved);
|
|
968
|
+
return resolved;
|
|
253
969
|
}
|
|
254
970
|
selectThread(id) {
|
|
255
|
-
this.
|
|
256
|
-
this.
|
|
971
|
+
const resolved = this.resolveThreadId(id);
|
|
972
|
+
this.requireThread(resolved);
|
|
973
|
+
this.setSetting("current_thread_id", resolved);
|
|
257
974
|
}
|
|
258
975
|
patchThread(id, patch) {
|
|
976
|
+
id = this.resolveThreadId(id);
|
|
259
977
|
const current = this.requireThread(id);
|
|
260
978
|
const now = this.now();
|
|
261
979
|
const title = patch.title === undefined ? undefined : normalizeTitle(patch.title);
|
|
@@ -281,10 +999,16 @@ export class WebStore {
|
|
|
281
999
|
return { ...this.requireThread(id), sourceId: current.sourceId };
|
|
282
1000
|
}
|
|
283
1001
|
async deleteArchivedThread(id) {
|
|
1002
|
+
id = this.resolveThreadId(id);
|
|
284
1003
|
const thread = this.requireThread(id);
|
|
285
1004
|
if (thread.archivedAt === null) {
|
|
286
1005
|
throw new WebConsoleError("thread_not_archived", "Archive the conversation before deleting it.", 409);
|
|
287
1006
|
}
|
|
1007
|
+
const cronChannel = this.database.prepare("SELECT * FROM cron_channels WHERE thread_id = ?")
|
|
1008
|
+
.get(id);
|
|
1009
|
+
if (cronChannel?.configured === 1) {
|
|
1010
|
+
throw new WebConsoleError("cron_channel_configured", "Configured cron channels can be archived but not deleted.", 409);
|
|
1011
|
+
}
|
|
288
1012
|
const attachments = this.database.prepare("SELECT * FROM attachments WHERE thread_id = ?")
|
|
289
1013
|
.all(id);
|
|
290
1014
|
this.transaction(() => {
|
|
@@ -294,7 +1018,21 @@ export class WebStore {
|
|
|
294
1018
|
WHERE event_id IN (SELECT id FROM push_events WHERE thread_id = ?)
|
|
295
1019
|
AND status IN ('pending', 'sending')
|
|
296
1020
|
`).run(now, now, id);
|
|
297
|
-
|
|
1021
|
+
// Keep the delivery receipt after its channel is removed. Replays remain
|
|
1022
|
+
// duplicates and can never resurrect a deleted historical channel.
|
|
1023
|
+
this.database.prepare(`
|
|
1024
|
+
UPDATE notification_deliveries
|
|
1025
|
+
SET thread_id = NULL, message_id = NULL, completed_at = COALESCE(completed_at, ?)
|
|
1026
|
+
WHERE thread_id = ?
|
|
1027
|
+
`).run(now, id);
|
|
1028
|
+
if (cronChannel !== undefined) {
|
|
1029
|
+
this.database.prepare(`
|
|
1030
|
+
INSERT INTO cron_channel_deletions (source_id, job_id, deleted_at)
|
|
1031
|
+
VALUES (?, ?, ?)
|
|
1032
|
+
ON CONFLICT(source_id, job_id) DO UPDATE SET deleted_at = excluded.deleted_at
|
|
1033
|
+
`).run(cronChannel.source_id, cronChannel.job_id, now);
|
|
1034
|
+
}
|
|
1035
|
+
this.database.prepare("DELETE FROM cron_channels WHERE thread_id = ?").run(id);
|
|
298
1036
|
this.database.prepare("DELETE FROM revisions WHERE entity_kind = 'thread' AND entity_id = ?").run(id);
|
|
299
1037
|
this.database.prepare("DELETE FROM threads WHERE id = ?").run(id);
|
|
300
1038
|
this.database.prepare("DELETE FROM settings WHERE key = 'current_thread_id' AND value = ?").run(id);
|
|
@@ -410,14 +1148,17 @@ export class WebStore {
|
|
|
410
1148
|
return removed;
|
|
411
1149
|
}
|
|
412
1150
|
beginTurn(input) {
|
|
413
|
-
const
|
|
1151
|
+
const threadId = this.resolveThreadId(input.threadId);
|
|
1152
|
+
const thread = this.requireThread(threadId);
|
|
414
1153
|
if (thread.archivedAt !== null) {
|
|
415
1154
|
throw new WebConsoleError("thread_archived", "Unarchive this conversation before sending another message.", 409);
|
|
416
1155
|
}
|
|
1156
|
+
if (thread.trigger?.kind === "cron")
|
|
1157
|
+
throw cronChannelReadOnlyError();
|
|
417
1158
|
if (!thread.canSend) {
|
|
418
1159
|
throw new WebConsoleError("agent_offline", "This agent is offline. The conversation remains available read-only.", 409);
|
|
419
1160
|
}
|
|
420
|
-
const active = this.database.prepare("SELECT id FROM turns WHERE thread_id = ? AND status = 'running'").get(
|
|
1161
|
+
const active = this.database.prepare("SELECT id FROM turns WHERE thread_id = ? AND status = 'running'").get(threadId);
|
|
421
1162
|
if (active !== undefined) {
|
|
422
1163
|
throw new WebConsoleError("turn_active", "This conversation already has an active turn.", 409);
|
|
423
1164
|
}
|
|
@@ -445,7 +1186,7 @@ export class WebStore {
|
|
|
445
1186
|
if (input.quote.text.trim().length === 0 || input.quote.messageId.trim().length === 0) {
|
|
446
1187
|
throw new WebConsoleError("invalid_quote", "Quoted text and its source message are required.", 400);
|
|
447
1188
|
}
|
|
448
|
-
const source = this.database.prepare("SELECT id FROM messages WHERE id = ? AND thread_id = ?").get(input.quote.messageId,
|
|
1189
|
+
const source = this.database.prepare("SELECT id FROM messages WHERE id = ? AND thread_id = ?").get(input.quote.messageId, threadId);
|
|
449
1190
|
if (source === undefined) {
|
|
450
1191
|
throw new WebConsoleError("invalid_quote", "The quoted message does not belong to this conversation.", 400);
|
|
451
1192
|
}
|
|
@@ -460,7 +1201,7 @@ export class WebStore {
|
|
|
460
1201
|
id, thread_id, status, text, model, effort, assistant_message_id,
|
|
461
1202
|
started_at, finished_at, error_code, error_message
|
|
462
1203
|
) VALUES (?, ?, 'running', ?, ?, ?, ?, ?, NULL, NULL, NULL)
|
|
463
|
-
`).run(turnId,
|
|
1204
|
+
`).run(turnId, threadId, input.text, input.model ?? null, input.effort ?? null, assistantMessageId, now);
|
|
464
1205
|
const userParts = [
|
|
465
1206
|
...(input.quote === undefined
|
|
466
1207
|
? []
|
|
@@ -470,18 +1211,18 @@ export class WebStore {
|
|
|
470
1211
|
this.database.prepare(`
|
|
471
1212
|
INSERT INTO messages (id, thread_id, turn_id, role, parts_json, created_at, updated_at, status)
|
|
472
1213
|
VALUES (?, ?, ?, 'user', ?, ?, ?, 'complete')
|
|
473
|
-
`).run(userMessageId,
|
|
1214
|
+
`).run(userMessageId, threadId, turnId, serializeParts(userParts), now, now);
|
|
474
1215
|
this.database.prepare(`
|
|
475
1216
|
INSERT INTO messages (id, thread_id, turn_id, role, parts_json, created_at, updated_at, status)
|
|
476
1217
|
VALUES (?, ?, ?, 'assistant', '[]', ?, ?, 'running')
|
|
477
|
-
`).run(assistantMessageId,
|
|
1218
|
+
`).run(assistantMessageId, threadId, turnId, now, now);
|
|
478
1219
|
const commitAttachment = this.database.prepare(`
|
|
479
1220
|
UPDATE attachments
|
|
480
1221
|
SET thread_id = ?, message_id = ?, status = 'committed', updated_at = ?
|
|
481
1222
|
WHERE id = ?
|
|
482
1223
|
`);
|
|
483
1224
|
for (const attachment of attachments) {
|
|
484
|
-
commitAttachment.run(
|
|
1225
|
+
commitAttachment.run(threadId, userMessageId, now, attachment.id);
|
|
485
1226
|
}
|
|
486
1227
|
const title = deriveAutomaticTitle(input.text, attachments);
|
|
487
1228
|
this.database.prepare(`
|
|
@@ -489,26 +1230,29 @@ export class WebStore {
|
|
|
489
1230
|
SET title = CASE WHEN title_manual = 0 AND title = 'New conversation' THEN ? ELSE title END,
|
|
490
1231
|
updated_at = ?, revision = revision + 1
|
|
491
1232
|
WHERE id = ?
|
|
492
|
-
`).run(title, now,
|
|
493
|
-
this.recordThreadRevision(
|
|
494
|
-
this.setSetting("current_thread_id",
|
|
1233
|
+
`).run(title, now, threadId);
|
|
1234
|
+
this.recordThreadRevision(threadId, "turn_started", now);
|
|
1235
|
+
this.setSetting("current_thread_id", threadId);
|
|
495
1236
|
});
|
|
496
1237
|
return {
|
|
497
1238
|
turnId,
|
|
498
|
-
conversationId: `web:${
|
|
1239
|
+
conversationId: `web:${threadId}`,
|
|
499
1240
|
text: input.text,
|
|
500
1241
|
...(input.quote === undefined ? {} : { quote: input.quote }),
|
|
501
1242
|
userMessageId,
|
|
502
1243
|
assistantMessageId,
|
|
503
1244
|
attachments: attachments.map((attachment) => this.requireStoredAttachment(attachment.id)),
|
|
504
|
-
thread: this.requireThread(
|
|
1245
|
+
thread: this.requireThread(threadId),
|
|
505
1246
|
};
|
|
506
1247
|
}
|
|
507
1248
|
reserveLiveInput(threadId, text) {
|
|
1249
|
+
threadId = this.resolveThreadId(threadId);
|
|
508
1250
|
const thread = this.requireThread(threadId);
|
|
509
1251
|
if (thread.archivedAt !== null) {
|
|
510
1252
|
throw new WebConsoleError("thread_archived", "Unarchive this conversation before sending another message.", 409);
|
|
511
1253
|
}
|
|
1254
|
+
if (thread.trigger?.kind === "cron")
|
|
1255
|
+
throw cronChannelReadOnlyError();
|
|
512
1256
|
if (!thread.canSend) {
|
|
513
1257
|
throw new WebConsoleError("agent_offline", "This agent is offline. The conversation remains available read-only.", 409);
|
|
514
1258
|
}
|
|
@@ -535,7 +1279,7 @@ export class WebStore {
|
|
|
535
1279
|
this.database.prepare(`
|
|
536
1280
|
INSERT INTO messages (id, thread_id, turn_id, role, parts_json, created_at, updated_at, status)
|
|
537
1281
|
VALUES (?, ?, ?, 'user', ?, ?, ?, 'complete')
|
|
538
|
-
`).run(messageId, threadId, active?.id ?? null,
|
|
1282
|
+
`).run(messageId, threadId, active?.id ?? null, serializeParts(parts), now, now);
|
|
539
1283
|
this.database.prepare(`
|
|
540
1284
|
INSERT INTO live_inputs (
|
|
541
1285
|
id, thread_id, message_id, active_turn_id, text, model, effort, status, created_at, updated_at
|
|
@@ -567,7 +1311,7 @@ export class WebStore {
|
|
|
567
1311
|
const now = this.now();
|
|
568
1312
|
this.transaction(() => {
|
|
569
1313
|
this.database.prepare("UPDATE messages SET parts_json = ?, updated_at = ? WHERE id = ?")
|
|
570
|
-
.run(
|
|
1314
|
+
.run(serializeParts(withLiveInputStatus(message.parts, "applied")), now, row.message_id);
|
|
571
1315
|
this.database.prepare("DELETE FROM live_inputs WHERE id = ?").run(id);
|
|
572
1316
|
this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
|
|
573
1317
|
.run(now, row.thread_id);
|
|
@@ -586,7 +1330,7 @@ export class WebStore {
|
|
|
586
1330
|
UPDATE live_inputs SET status = 'queued', active_turn_id = NULL, updated_at = ? WHERE id = ?
|
|
587
1331
|
`).run(now, id);
|
|
588
1332
|
this.database.prepare("UPDATE messages SET turn_id = NULL, parts_json = ?, updated_at = ? WHERE id = ?")
|
|
589
|
-
.run(
|
|
1333
|
+
.run(serializeParts(withLiveInputStatus(message.parts, "queued")), now, row.message_id);
|
|
590
1334
|
this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
|
|
591
1335
|
.run(now, row.thread_id);
|
|
592
1336
|
this.recordThreadRevision(row.thread_id, "live_input_queued", now);
|
|
@@ -601,7 +1345,7 @@ export class WebStore {
|
|
|
601
1345
|
const now = this.now();
|
|
602
1346
|
this.transaction(() => {
|
|
603
1347
|
this.database.prepare("UPDATE messages SET turn_id = NULL, parts_json = ?, updated_at = ? WHERE id = ?")
|
|
604
|
-
.run(
|
|
1348
|
+
.run(serializeParts(withLiveInputStatus(message.parts, "cancelled")), now, row.message_id);
|
|
605
1349
|
this.database.prepare("DELETE FROM live_inputs WHERE id = ?").run(id);
|
|
606
1350
|
this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
|
|
607
1351
|
.run(now, row.thread_id);
|
|
@@ -610,6 +1354,7 @@ export class WebStore {
|
|
|
610
1354
|
return this.requireMessage(row.message_id);
|
|
611
1355
|
}
|
|
612
1356
|
cancelLiveInputs(threadId) {
|
|
1357
|
+
threadId = this.resolveThreadId(threadId);
|
|
613
1358
|
const rows = this.database.prepare("SELECT * FROM live_inputs WHERE thread_id = ? ORDER BY created_at, rowid").all(threadId);
|
|
614
1359
|
if (rows.length === 0)
|
|
615
1360
|
return [];
|
|
@@ -618,7 +1363,7 @@ export class WebStore {
|
|
|
618
1363
|
const update = this.database.prepare("UPDATE messages SET turn_id = NULL, parts_json = ?, updated_at = ? WHERE id = ?");
|
|
619
1364
|
for (const row of rows) {
|
|
620
1365
|
const message = this.requireMessage(row.message_id);
|
|
621
|
-
update.run(
|
|
1366
|
+
update.run(serializeParts(withLiveInputStatus(message.parts, "cancelled")), now, row.message_id);
|
|
622
1367
|
}
|
|
623
1368
|
this.database.prepare("DELETE FROM live_inputs WHERE thread_id = ?").run(threadId);
|
|
624
1369
|
this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
|
|
@@ -634,6 +1379,7 @@ export class WebStore {
|
|
|
634
1379
|
`).all().map((row) => row.thread_id);
|
|
635
1380
|
}
|
|
636
1381
|
promoteNextQueuedLiveInput(threadId) {
|
|
1382
|
+
threadId = this.resolveThreadId(threadId);
|
|
637
1383
|
const active = this.database.prepare("SELECT id FROM turns WHERE thread_id = ? AND status = 'running'").get(threadId);
|
|
638
1384
|
if (active !== undefined)
|
|
639
1385
|
return undefined;
|
|
@@ -659,7 +1405,7 @@ export class WebStore {
|
|
|
659
1405
|
) VALUES (?, ?, 'running', ?, ?, ?, ?, ?, NULL, NULL, NULL)
|
|
660
1406
|
`).run(turnId, threadId, row.text, row.model, row.effort, assistantMessageId, now);
|
|
661
1407
|
this.database.prepare("UPDATE messages SET turn_id = ?, parts_json = ?, updated_at = ? WHERE id = ?")
|
|
662
|
-
.run(turnId,
|
|
1408
|
+
.run(turnId, serializeParts(withoutLiveInputTelemetry(userMessage.parts)), now, row.message_id);
|
|
663
1409
|
this.database.prepare(`
|
|
664
1410
|
INSERT INTO messages (id, thread_id, turn_id, role, parts_json, created_at, updated_at, status)
|
|
665
1411
|
VALUES (?, ?, ?, 'assistant', '[]', ?, ?, 'running')
|
|
@@ -713,7 +1459,7 @@ export class WebStore {
|
|
|
713
1459
|
const now = this.now();
|
|
714
1460
|
this.transaction(() => {
|
|
715
1461
|
this.database.prepare("UPDATE messages SET parts_json = ?, updated_at = ? WHERE id = ?")
|
|
716
|
-
.run(
|
|
1462
|
+
.run(serializeParts(parts), now, message.id);
|
|
717
1463
|
if (actualModel !== undefined || actualEffort !== undefined) {
|
|
718
1464
|
this.database.prepare(`
|
|
719
1465
|
UPDATE turns SET
|
|
@@ -725,9 +1471,9 @@ export class WebStore {
|
|
|
725
1471
|
});
|
|
726
1472
|
return this.requireMessage(message.id);
|
|
727
1473
|
}
|
|
728
|
-
completeTurn(turnId, finalText, metadata) {
|
|
1474
|
+
completeTurn(turnId, finalText, metadata, replyParts) {
|
|
729
1475
|
const runtime = runtimeMetadata(metadata);
|
|
730
|
-
return this.finishTurn(turnId, "complete", finalText, undefined, undefined, runtime);
|
|
1476
|
+
return this.finishTurn(turnId, "complete", finalText, undefined, undefined, runtime, replyParts);
|
|
731
1477
|
}
|
|
732
1478
|
failTurn(turnId, error) {
|
|
733
1479
|
return this.finishTurn(turnId, error.cancelled === true ? "cancelled" : "failed", undefined, error.code, error.message, undefined);
|
|
@@ -736,6 +1482,7 @@ export class WebStore {
|
|
|
736
1482
|
return this.finishTurn(turnId, "interrupted", undefined, "interrupted", message, undefined);
|
|
737
1483
|
}
|
|
738
1484
|
activeTurn(threadId) {
|
|
1485
|
+
threadId = this.resolveThreadId(threadId);
|
|
739
1486
|
const row = this.database.prepare("SELECT id FROM turns WHERE thread_id = ? AND status = 'running'").get(threadId);
|
|
740
1487
|
return row === undefined ? undefined : { id: row.id, conversationId: `web:${threadId}` };
|
|
741
1488
|
}
|
|
@@ -778,6 +1525,23 @@ export class WebStore {
|
|
|
778
1525
|
});
|
|
779
1526
|
return { ...generated, fingerprint: generatedFingerprint };
|
|
780
1527
|
}
|
|
1528
|
+
/** Stable owner-private key for short-lived message-bound rich-part URLs. */
|
|
1529
|
+
ensureReplyAccessKey(generate) {
|
|
1530
|
+
const existing = this.database.prepare("SELECT value FROM settings WHERE key = 'reply_access_key_v1'")
|
|
1531
|
+
.get();
|
|
1532
|
+
if (existing !== undefined) {
|
|
1533
|
+
if (!/^[A-Za-z0-9_-]{43}$/u.test(existing.value)) {
|
|
1534
|
+
throw new WebConsoleError("reply_access_key_corrupt", "The stored reply access key is invalid.", 500);
|
|
1535
|
+
}
|
|
1536
|
+
return existing.value;
|
|
1537
|
+
}
|
|
1538
|
+
const created = generate();
|
|
1539
|
+
if (!/^[A-Za-z0-9_-]{43}$/u.test(created)) {
|
|
1540
|
+
throw new WebConsoleError("reply_access_key_generation_failed", "Unable to generate a reply access key.", 500);
|
|
1541
|
+
}
|
|
1542
|
+
this.setSetting("reply_access_key_v1", created);
|
|
1543
|
+
return created;
|
|
1544
|
+
}
|
|
781
1545
|
registerWebPushSubscription(input) {
|
|
782
1546
|
const endpointSha256 = createHash("sha256").update(input.endpoint).digest("hex");
|
|
783
1547
|
const previousEndpointSha256 = input.previousEndpoint === undefined
|
|
@@ -1076,6 +1840,9 @@ export class WebStore {
|
|
|
1076
1840
|
default_effort TEXT,
|
|
1077
1841
|
efforts_json TEXT,
|
|
1078
1842
|
model_options_json TEXT,
|
|
1843
|
+
cron_read INTEGER NOT NULL DEFAULT 0,
|
|
1844
|
+
cron_actions INTEGER NOT NULL DEFAULT 0,
|
|
1845
|
+
ask_by_id INTEGER NOT NULL DEFAULT 0,
|
|
1079
1846
|
updated_at TEXT NOT NULL
|
|
1080
1847
|
);
|
|
1081
1848
|
CREATE TABLE IF NOT EXISTS threads (
|
|
@@ -1161,13 +1928,87 @@ export class WebStore {
|
|
|
1161
1928
|
CREATE TABLE IF NOT EXISTS notification_deliveries (
|
|
1162
1929
|
source_id TEXT NOT NULL REFERENCES agents(source_id),
|
|
1163
1930
|
delivery_key TEXT NOT NULL,
|
|
1164
|
-
thread_id TEXT
|
|
1931
|
+
thread_id TEXT,
|
|
1932
|
+
message_id TEXT,
|
|
1165
1933
|
trigger_kind TEXT NOT NULL CHECK (trigger_kind IN ('cron', 'webhook')),
|
|
1934
|
+
job_id TEXT,
|
|
1935
|
+
run_id TEXT,
|
|
1166
1936
|
payload_sha256 TEXT NOT NULL,
|
|
1167
1937
|
created_at TEXT NOT NULL,
|
|
1168
1938
|
completed_at TEXT,
|
|
1939
|
+
CHECK ((job_id IS NULL AND run_id IS NULL) OR (trigger_kind = 'cron' AND job_id IS NOT NULL AND run_id IS NOT NULL)),
|
|
1169
1940
|
PRIMARY KEY (source_id, delivery_key)
|
|
1170
1941
|
);
|
|
1942
|
+
CREATE INDEX IF NOT EXISTS notification_deliveries_by_thread
|
|
1943
|
+
ON notification_deliveries(thread_id) WHERE thread_id IS NOT NULL;
|
|
1944
|
+
CREATE TABLE IF NOT EXISTS cron_channels (
|
|
1945
|
+
source_id TEXT NOT NULL REFERENCES agents(source_id),
|
|
1946
|
+
job_id TEXT NOT NULL,
|
|
1947
|
+
thread_id TEXT NOT NULL UNIQUE REFERENCES threads(id) ON DELETE CASCADE,
|
|
1948
|
+
configured INTEGER NOT NULL CHECK (configured IN (0, 1)),
|
|
1949
|
+
created_at TEXT NOT NULL,
|
|
1950
|
+
updated_at TEXT NOT NULL,
|
|
1951
|
+
PRIMARY KEY (source_id, job_id)
|
|
1952
|
+
);
|
|
1953
|
+
CREATE INDEX IF NOT EXISTS cron_channels_by_source
|
|
1954
|
+
ON cron_channels(source_id, configured, job_id);
|
|
1955
|
+
CREATE TABLE IF NOT EXISTS cron_channel_deletions (
|
|
1956
|
+
source_id TEXT NOT NULL REFERENCES agents(source_id) ON DELETE CASCADE,
|
|
1957
|
+
job_id TEXT NOT NULL,
|
|
1958
|
+
deleted_at TEXT NOT NULL,
|
|
1959
|
+
PRIMARY KEY (source_id, job_id)
|
|
1960
|
+
);
|
|
1961
|
+
CREATE TABLE IF NOT EXISTS cron_overviews (
|
|
1962
|
+
source_id TEXT PRIMARY KEY REFERENCES agents(source_id) ON DELETE CASCADE,
|
|
1963
|
+
generated_at TEXT NOT NULL,
|
|
1964
|
+
actions_enabled INTEGER NOT NULL CHECK (actions_enabled IN (0, 1)),
|
|
1965
|
+
degraded_reason TEXT,
|
|
1966
|
+
jobs_truncated INTEGER NOT NULL DEFAULT 0 CHECK (jobs_truncated IN (0, 1)),
|
|
1967
|
+
updated_at TEXT NOT NULL
|
|
1968
|
+
);
|
|
1969
|
+
CREATE TABLE IF NOT EXISTS cron_job_snapshots (
|
|
1970
|
+
source_id TEXT NOT NULL,
|
|
1971
|
+
job_id TEXT NOT NULL,
|
|
1972
|
+
payload_json TEXT NOT NULL,
|
|
1973
|
+
updated_at TEXT NOT NULL,
|
|
1974
|
+
PRIMARY KEY (source_id, job_id),
|
|
1975
|
+
FOREIGN KEY (source_id, job_id) REFERENCES cron_channels(source_id, job_id) ON DELETE CASCADE
|
|
1976
|
+
);
|
|
1977
|
+
CREATE TABLE IF NOT EXISTS cron_run_messages (
|
|
1978
|
+
source_id TEXT NOT NULL,
|
|
1979
|
+
job_id TEXT NOT NULL,
|
|
1980
|
+
run_id TEXT NOT NULL,
|
|
1981
|
+
thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
|
1982
|
+
turn_id TEXT NOT NULL UNIQUE REFERENCES turns(id) ON DELETE CASCADE,
|
|
1983
|
+
message_id TEXT NOT NULL UNIQUE REFERENCES messages(id) ON DELETE CASCADE,
|
|
1984
|
+
ordered_at TEXT NOT NULL,
|
|
1985
|
+
sequence INTEGER NOT NULL,
|
|
1986
|
+
payload_json TEXT NOT NULL,
|
|
1987
|
+
updated_at TEXT NOT NULL,
|
|
1988
|
+
PRIMARY KEY (source_id, job_id, run_id),
|
|
1989
|
+
FOREIGN KEY (source_id, job_id) REFERENCES cron_channels(source_id, job_id) ON DELETE CASCADE
|
|
1990
|
+
);
|
|
1991
|
+
CREATE INDEX IF NOT EXISTS cron_run_messages_by_order
|
|
1992
|
+
ON cron_run_messages(source_id, job_id, ordered_at DESC, sequence DESC, run_id DESC);
|
|
1993
|
+
CREATE TABLE IF NOT EXISTS thread_redirects (
|
|
1994
|
+
old_thread_id TEXT PRIMARY KEY,
|
|
1995
|
+
new_thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
|
1996
|
+
created_at TEXT NOT NULL,
|
|
1997
|
+
CHECK (old_thread_id <> new_thread_id)
|
|
1998
|
+
);
|
|
1999
|
+
CREATE TABLE IF NOT EXISTS process_job_cards (
|
|
2000
|
+
source_id TEXT NOT NULL REFERENCES agents(source_id),
|
|
2001
|
+
job_id TEXT NOT NULL,
|
|
2002
|
+
delivery_key TEXT NOT NULL,
|
|
2003
|
+
thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
|
2004
|
+
message_id TEXT NOT NULL UNIQUE REFERENCES messages(id) ON DELETE CASCADE,
|
|
2005
|
+
projection_sha256 TEXT NOT NULL,
|
|
2006
|
+
response_text TEXT,
|
|
2007
|
+
created_at TEXT NOT NULL,
|
|
2008
|
+
updated_at TEXT NOT NULL,
|
|
2009
|
+
PRIMARY KEY (source_id, job_id),
|
|
2010
|
+
UNIQUE (source_id, delivery_key)
|
|
2011
|
+
);
|
|
1171
2012
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
|
1172
2013
|
id TEXT PRIMARY KEY,
|
|
1173
2014
|
endpoint TEXT NOT NULL,
|
|
@@ -1227,6 +2068,15 @@ export class WebStore {
|
|
|
1227
2068
|
this.database.exec("ALTER TABLE threads ADD COLUMN trigger_kind TEXT CHECK (trigger_kind IN ('cron', 'webhook'))");
|
|
1228
2069
|
}
|
|
1229
2070
|
}
|
|
2071
|
+
if (versionRow.user_version < 5)
|
|
2072
|
+
this.migrateCronChannels();
|
|
2073
|
+
if (versionRow.user_version < 6) {
|
|
2074
|
+
const columns = new Set(this.database.prepare("PRAGMA table_info(cron_overviews)").all()
|
|
2075
|
+
.map((column) => column.name));
|
|
2076
|
+
if (!columns.has("jobs_truncated")) {
|
|
2077
|
+
this.database.exec("ALTER TABLE cron_overviews ADD COLUMN jobs_truncated INTEGER NOT NULL DEFAULT 0 CHECK (jobs_truncated IN (0, 1))");
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
1230
2080
|
if (migrating)
|
|
1231
2081
|
this.database.exec(`PRAGMA user_version = ${WEB_STORAGE_SCHEMA_VERSION}; COMMIT`);
|
|
1232
2082
|
}
|
|
@@ -1243,6 +2093,140 @@ export class WebStore {
|
|
|
1243
2093
|
throw new WebConsoleError("storage_corrupt", `Unable to initialize web state: ${error instanceof Error ? error.message : String(error)}`, 500);
|
|
1244
2094
|
}
|
|
1245
2095
|
}
|
|
2096
|
+
/**
|
|
2097
|
+
* Schema-v5 adoption runs after every older fixup inside the same
|
|
2098
|
+
* BEGIN IMMEDIATE transaction. Each operation is guarded by the resulting
|
|
2099
|
+
* schema/keys, so reopening an interrupted migration is idempotent.
|
|
2100
|
+
*/
|
|
2101
|
+
migrateCronChannels() {
|
|
2102
|
+
const agentColumns = new Set(this.database.prepare("PRAGMA table_info(agents)").all()
|
|
2103
|
+
.map((column) => column.name));
|
|
2104
|
+
if (!agentColumns.has("cron_read")) {
|
|
2105
|
+
this.database.exec("ALTER TABLE agents ADD COLUMN cron_read INTEGER NOT NULL DEFAULT 0");
|
|
2106
|
+
}
|
|
2107
|
+
if (!agentColumns.has("cron_actions")) {
|
|
2108
|
+
this.database.exec("ALTER TABLE agents ADD COLUMN cron_actions INTEGER NOT NULL DEFAULT 0");
|
|
2109
|
+
}
|
|
2110
|
+
if (!agentColumns.has("ask_by_id")) {
|
|
2111
|
+
this.database.exec("ALTER TABLE agents ADD COLUMN ask_by_id INTEGER NOT NULL DEFAULT 0");
|
|
2112
|
+
}
|
|
2113
|
+
const deliveryColumns = this.database.prepare("PRAGMA table_info(notification_deliveries)")
|
|
2114
|
+
.all();
|
|
2115
|
+
const threadColumn = deliveryColumns.find((column) => column.name === "thread_id");
|
|
2116
|
+
if (!deliveryColumns.some((column) => column.name === "job_id") || threadColumn?.notnull === 1) {
|
|
2117
|
+
const legacyRows = this.database.prepare("SELECT * FROM notification_deliveries")
|
|
2118
|
+
.all();
|
|
2119
|
+
this.database.exec(`
|
|
2120
|
+
DROP TABLE notification_deliveries;
|
|
2121
|
+
CREATE TABLE notification_deliveries (
|
|
2122
|
+
source_id TEXT NOT NULL REFERENCES agents(source_id),
|
|
2123
|
+
delivery_key TEXT NOT NULL,
|
|
2124
|
+
thread_id TEXT,
|
|
2125
|
+
message_id TEXT,
|
|
2126
|
+
trigger_kind TEXT NOT NULL CHECK (trigger_kind IN ('cron', 'webhook')),
|
|
2127
|
+
job_id TEXT,
|
|
2128
|
+
run_id TEXT,
|
|
2129
|
+
payload_sha256 TEXT NOT NULL,
|
|
2130
|
+
created_at TEXT NOT NULL,
|
|
2131
|
+
completed_at TEXT,
|
|
2132
|
+
CHECK ((job_id IS NULL AND run_id IS NULL) OR (trigger_kind = 'cron' AND job_id IS NOT NULL AND run_id IS NOT NULL)),
|
|
2133
|
+
PRIMARY KEY (source_id, delivery_key)
|
|
2134
|
+
);
|
|
2135
|
+
CREATE INDEX notification_deliveries_by_thread
|
|
2136
|
+
ON notification_deliveries(thread_id) WHERE thread_id IS NOT NULL;
|
|
2137
|
+
`);
|
|
2138
|
+
const insert = this.database.prepare(`
|
|
2139
|
+
INSERT INTO notification_deliveries (
|
|
2140
|
+
source_id, delivery_key, thread_id, trigger_kind, job_id, run_id,
|
|
2141
|
+
message_id, payload_sha256, created_at, completed_at
|
|
2142
|
+
) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)
|
|
2143
|
+
`);
|
|
2144
|
+
for (const row of legacyRows) {
|
|
2145
|
+
const identity = row.trigger_kind === "cron" ? legacyCronDeliveryIdentity(row.delivery_key) : undefined;
|
|
2146
|
+
insert.run(row.source_id, row.delivery_key, row.thread_id, row.trigger_kind, identity?.jobId ?? null, identity?.runId ?? null, row.payload_sha256, row.created_at, row.completed_at);
|
|
2147
|
+
}
|
|
2148
|
+
this.database.exec(`
|
|
2149
|
+
UPDATE notification_deliveries
|
|
2150
|
+
SET message_id = (
|
|
2151
|
+
SELECT m.id FROM messages m
|
|
2152
|
+
WHERE m.thread_id = notification_deliveries.thread_id AND m.role = 'assistant'
|
|
2153
|
+
ORDER BY m.created_at DESC, m.rowid DESC LIMIT 1
|
|
2154
|
+
)
|
|
2155
|
+
WHERE completed_at IS NOT NULL
|
|
2156
|
+
`);
|
|
2157
|
+
}
|
|
2158
|
+
// Startup recovery normally runs after schema initialization. Cron adoption
|
|
2159
|
+
// can merge several legacy notification threads into one thread, though,
|
|
2160
|
+
// and two independently-running legacy turns would violate the one-active-
|
|
2161
|
+
// turn index during that reparenting. Settle them while this migration's
|
|
2162
|
+
// BEGIN IMMEDIATE transaction still owns the database, before any merge.
|
|
2163
|
+
this.recoverInterruptedTurnsInTransaction();
|
|
2164
|
+
const adoptable = this.database.prepare(`
|
|
2165
|
+
SELECT d.source_id, d.job_id, d.thread_id, d.created_at, t.created_at AS thread_created_at
|
|
2166
|
+
FROM notification_deliveries d
|
|
2167
|
+
JOIN threads t ON t.id = d.thread_id
|
|
2168
|
+
WHERE d.trigger_kind = 'cron' AND d.job_id IS NOT NULL AND d.run_id IS NOT NULL
|
|
2169
|
+
AND d.thread_id IS NOT NULL AND d.completed_at IS NOT NULL
|
|
2170
|
+
ORDER BY d.source_id, d.job_id, t.created_at, d.created_at, d.thread_id
|
|
2171
|
+
`).all();
|
|
2172
|
+
const groups = new Map();
|
|
2173
|
+
for (const row of adoptable) {
|
|
2174
|
+
const key = `${row.source_id}\0${row.job_id}`;
|
|
2175
|
+
const group = groups.get(key) ?? [];
|
|
2176
|
+
if (!group.some((entry) => entry.thread_id === row.thread_id))
|
|
2177
|
+
group.push(row);
|
|
2178
|
+
groups.set(key, group);
|
|
2179
|
+
}
|
|
2180
|
+
const now = this.now();
|
|
2181
|
+
for (const rows of groups.values()) {
|
|
2182
|
+
const canonical = rows[0];
|
|
2183
|
+
if (canonical === undefined)
|
|
2184
|
+
continue;
|
|
2185
|
+
const existing = this.cronChannel(canonical.source_id, canonical.job_id);
|
|
2186
|
+
const canonicalId = existing?.thread_id ?? canonical.thread_id;
|
|
2187
|
+
const conversationId = cronConsoleConversationId(canonical.source_id, canonical.job_id);
|
|
2188
|
+
this.database.prepare(`
|
|
2189
|
+
UPDATE threads SET conversation_id = ?, trigger_kind = 'cron', title = ?, updated_at = MAX(updated_at, ?)
|
|
2190
|
+
WHERE id = ?
|
|
2191
|
+
`).run(conversationId, `Cron · ${canonical.job_id}`, canonical.created_at, canonicalId);
|
|
2192
|
+
this.database.prepare(`
|
|
2193
|
+
INSERT INTO cron_channels (source_id, job_id, thread_id, configured, created_at, updated_at)
|
|
2194
|
+
VALUES (?, ?, ?, 0, ?, ?)
|
|
2195
|
+
ON CONFLICT(source_id, job_id) DO NOTHING
|
|
2196
|
+
`).run(canonical.source_id, canonical.job_id, canonicalId, canonical.thread_created_at, now);
|
|
2197
|
+
for (const legacy of rows) {
|
|
2198
|
+
if (legacy.thread_id === canonicalId)
|
|
2199
|
+
continue;
|
|
2200
|
+
this.database.prepare("UPDATE turns SET thread_id = ? WHERE thread_id = ?")
|
|
2201
|
+
.run(canonicalId, legacy.thread_id);
|
|
2202
|
+
this.database.prepare("UPDATE messages SET thread_id = ? WHERE thread_id = ?")
|
|
2203
|
+
.run(canonicalId, legacy.thread_id);
|
|
2204
|
+
this.database.prepare("UPDATE live_inputs SET thread_id = ? WHERE thread_id = ?")
|
|
2205
|
+
.run(canonicalId, legacy.thread_id);
|
|
2206
|
+
this.database.prepare("UPDATE attachments SET thread_id = ? WHERE thread_id = ?")
|
|
2207
|
+
.run(canonicalId, legacy.thread_id);
|
|
2208
|
+
this.database.prepare("UPDATE push_events SET thread_id = ? WHERE thread_id = ?")
|
|
2209
|
+
.run(canonicalId, legacy.thread_id);
|
|
2210
|
+
this.database.prepare("UPDATE notification_deliveries SET thread_id = ? WHERE thread_id = ?")
|
|
2211
|
+
.run(canonicalId, legacy.thread_id);
|
|
2212
|
+
this.database.prepare(`
|
|
2213
|
+
UPDATE revisions SET entity_id = ? WHERE entity_kind = 'thread' AND entity_id = ?
|
|
2214
|
+
`).run(canonicalId, legacy.thread_id);
|
|
2215
|
+
this.database.prepare("UPDATE settings SET value = ? WHERE key = 'current_thread_id' AND value = ?")
|
|
2216
|
+
.run(canonicalId, legacy.thread_id);
|
|
2217
|
+
this.database.prepare(`
|
|
2218
|
+
INSERT INTO thread_redirects (old_thread_id, new_thread_id, created_at)
|
|
2219
|
+
VALUES (?, ?, ?) ON CONFLICT(old_thread_id) DO UPDATE SET new_thread_id = excluded.new_thread_id
|
|
2220
|
+
`).run(legacy.thread_id, canonicalId, now);
|
|
2221
|
+
this.database.prepare("DELETE FROM threads WHERE id = ?").run(legacy.thread_id);
|
|
2222
|
+
}
|
|
2223
|
+
this.database.prepare(`
|
|
2224
|
+
UPDATE threads SET revision = revision + 1,
|
|
2225
|
+
updated_at = MAX(updated_at, COALESCE((SELECT MAX(created_at) FROM messages WHERE thread_id = ?), updated_at))
|
|
2226
|
+
WHERE id = ?
|
|
2227
|
+
`).run(canonicalId, canonicalId);
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
1246
2230
|
validateStorage() {
|
|
1247
2231
|
const check = this.database.prepare("PRAGMA quick_check(1)").get();
|
|
1248
2232
|
if (check === undefined || !Object.values(check).includes("ok")) {
|
|
@@ -1258,6 +2242,13 @@ export class WebStore {
|
|
|
1258
2242
|
"revisions",
|
|
1259
2243
|
"settings",
|
|
1260
2244
|
"notification_deliveries",
|
|
2245
|
+
"cron_channels",
|
|
2246
|
+
"cron_channel_deletions",
|
|
2247
|
+
"cron_overviews",
|
|
2248
|
+
"cron_job_snapshots",
|
|
2249
|
+
"cron_run_messages",
|
|
2250
|
+
"thread_redirects",
|
|
2251
|
+
"process_job_cards",
|
|
1261
2252
|
"push_subscriptions",
|
|
1262
2253
|
"push_events",
|
|
1263
2254
|
"push_deliveries",
|
|
@@ -1284,6 +2275,12 @@ export class WebStore {
|
|
|
1284
2275
|
this.interruptTurn(turnId, "The web service restarted before this turn completed.");
|
|
1285
2276
|
}
|
|
1286
2277
|
}
|
|
2278
|
+
recoverInterruptedTurnsInTransaction() {
|
|
2279
|
+
const active = this.listActiveTurnIds();
|
|
2280
|
+
for (const turnId of active) {
|
|
2281
|
+
this.finishTurnInTransaction(turnId, "interrupted", undefined, "interrupted", "The web service restarted before this turn completed.", undefined);
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
1287
2284
|
recoverLiveInputs() {
|
|
1288
2285
|
const rows = this.database.prepare("SELECT * FROM live_inputs WHERE status = 'offered' ORDER BY created_at, rowid").all();
|
|
1289
2286
|
if (rows.length === 0)
|
|
@@ -1302,7 +2299,7 @@ export class WebStore {
|
|
|
1302
2299
|
throw new WebConsoleError("storage_corrupt", `Live input ${row.id} has no message.`, 500);
|
|
1303
2300
|
}
|
|
1304
2301
|
updateInput.run(now, row.id);
|
|
1305
|
-
updateMessage.run(
|
|
2302
|
+
updateMessage.run(serializeParts(withLiveInputStatus(parseParts(persisted.parts_json), "queued")), now, row.message_id);
|
|
1306
2303
|
}
|
|
1307
2304
|
for (const threadId of threadIds) {
|
|
1308
2305
|
this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
|
|
@@ -1318,72 +2315,83 @@ export class WebStore {
|
|
|
1318
2315
|
WHERE status = 'sending'
|
|
1319
2316
|
`).run(now, now);
|
|
1320
2317
|
}
|
|
1321
|
-
finishTurn(turnId, status, finalText, errorCode, errorMessage, runtime) {
|
|
2318
|
+
finishTurn(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts) {
|
|
1322
2319
|
const turn = this.requireTurn(turnId);
|
|
1323
|
-
const existing = this.requireMessage(turn.assistant_message_id);
|
|
1324
2320
|
if (turn.status !== "running") {
|
|
1325
2321
|
return this.requireThreadDetail(turn.thread_id);
|
|
1326
2322
|
}
|
|
1327
|
-
|
|
2323
|
+
this.transaction(() => {
|
|
2324
|
+
this.finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts);
|
|
2325
|
+
});
|
|
2326
|
+
return this.requireThreadDetail(turn.thread_id);
|
|
2327
|
+
}
|
|
2328
|
+
finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts) {
|
|
2329
|
+
const turn = this.requireTurn(turnId);
|
|
2330
|
+
if (turn.status !== "running")
|
|
2331
|
+
return;
|
|
2332
|
+
const existing = this.requireMessage(turn.assistant_message_id);
|
|
2333
|
+
let parts = [...existing.parts];
|
|
1328
2334
|
if (finalText !== undefined && finalText.length > 0)
|
|
1329
2335
|
reconcileFinalText(parts, finalText);
|
|
2336
|
+
if (replyParts !== undefined)
|
|
2337
|
+
parts = boundedWebReplyParts(replyParts, parts);
|
|
1330
2338
|
if (errorMessage !== undefined) {
|
|
1331
2339
|
parts.push({ type: "error", ...(errorCode === undefined ? {} : { code: errorCode }), message: errorMessage });
|
|
1332
2340
|
}
|
|
1333
2341
|
const now = this.now();
|
|
1334
2342
|
const thread = this.requireThread(turn.thread_id);
|
|
1335
2343
|
const agent = this.getAgent(thread.sourceId);
|
|
1336
|
-
this.
|
|
1337
|
-
this.database.prepare(`
|
|
2344
|
+
this.database.prepare(`
|
|
1338
2345
|
UPDATE turns SET status = ?, finished_at = ?, error_code = ?, error_message = ?,
|
|
1339
2346
|
model = CASE WHEN ? IS NULL THEN model ELSE ? END,
|
|
1340
2347
|
effort = CASE WHEN ? IS NULL THEN effort ELSE ? END
|
|
1341
2348
|
WHERE id = ?
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
2349
|
+
`).run(status, now, errorCode ?? null, errorMessage ?? null, runtime?.model ?? null, runtime?.model ?? null, runtime?.effort ?? null, runtime?.effort ?? null, turnId);
|
|
2350
|
+
this.database.prepare("UPDATE messages SET parts_json = ?, status = ?, updated_at = ? WHERE id = ?")
|
|
2351
|
+
.run(serializeParts(parts), status, now, existing.id);
|
|
2352
|
+
this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
|
|
2353
|
+
.run(now, turn.thread_id);
|
|
2354
|
+
this.recordThreadRevision(turn.thread_id, `turn_${status}`, now);
|
|
2355
|
+
const recentEnoughForRecoveredInterruption = status !== "interrupted"
|
|
2356
|
+
|| new Date(now).getTime() - new Date(existing.updatedAt).getTime() <= 60 * 60 * 1_000;
|
|
2357
|
+
// Projected cron turns are agent-owned scheduler state. Restart recovery
|
|
2358
|
+
// still settles the local projection, but only web-owned turns may emit a
|
|
2359
|
+
// Web Push terminal notification from this service.
|
|
2360
|
+
if (thread.trigger?.kind !== "cron" && recentEnoughForRecoveredInterruption) {
|
|
2361
|
+
const kind = status === "complete"
|
|
2362
|
+
? "response.ready"
|
|
2363
|
+
: status === "cancelled"
|
|
2364
|
+
? "run.cancelled"
|
|
2365
|
+
: status === "interrupted"
|
|
2366
|
+
? "run.interrupted"
|
|
2367
|
+
: "run.failed";
|
|
2368
|
+
const label = agent?.label ?? "mono-agent";
|
|
2369
|
+
const body = status === "complete"
|
|
2370
|
+
? parts.filter((part) => part.type === "text")
|
|
2371
|
+
.map((part) => part.text)
|
|
2372
|
+
.join(" ")
|
|
2373
|
+
: status === "cancelled"
|
|
2374
|
+
? "The run was cancelled."
|
|
2375
|
+
: status === "interrupted"
|
|
2376
|
+
? "The run was interrupted when the web service stopped."
|
|
2377
|
+
: errorMessage ?? "The run failed.";
|
|
2378
|
+
this.enqueueWebPushEventInTransaction({
|
|
2379
|
+
logicalKey: `turn:${turnId}:terminal`,
|
|
2380
|
+
kind,
|
|
2381
|
+
threadId: turn.thread_id,
|
|
2382
|
+
sourceId: thread.sourceId,
|
|
2383
|
+
title: status === "complete"
|
|
2384
|
+
? `${label} replied`
|
|
1363
2385
|
: status === "cancelled"
|
|
1364
|
-
?
|
|
2386
|
+
? `${label} run cancelled`
|
|
1365
2387
|
: status === "interrupted"
|
|
1366
|
-
?
|
|
1367
|
-
:
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
title: status === "complete"
|
|
1374
|
-
? `${label} replied`
|
|
1375
|
-
: status === "cancelled"
|
|
1376
|
-
? `${label} run cancelled`
|
|
1377
|
-
: status === "interrupted"
|
|
1378
|
-
? `${label} run interrupted`
|
|
1379
|
-
: `${label} run failed`,
|
|
1380
|
-
body,
|
|
1381
|
-
expiresAt: new Date(new Date(now).getTime() + (status === "complete" ? 24 : 1) * 60 * 60 * 1_000).toISOString(),
|
|
1382
|
-
notBefore: new Date(new Date(now).getTime() + 3_000).toISOString(),
|
|
1383
|
-
});
|
|
1384
|
-
}
|
|
1385
|
-
});
|
|
1386
|
-
return this.requireThreadDetail(turn.thread_id);
|
|
2388
|
+
? `${label} run interrupted`
|
|
2389
|
+
: `${label} run failed`,
|
|
2390
|
+
body,
|
|
2391
|
+
expiresAt: new Date(new Date(now).getTime() + (status === "complete" ? 24 : 1) * 60 * 60 * 1_000).toISOString(),
|
|
2392
|
+
notBefore: new Date(new Date(now).getTime() + 3_000).toISOString(),
|
|
2393
|
+
});
|
|
2394
|
+
}
|
|
1387
2395
|
}
|
|
1388
2396
|
mapThread(row) {
|
|
1389
2397
|
const runState = this.latestRunState(row.id);
|
|
@@ -1396,9 +2404,17 @@ export class WebStore {
|
|
|
1396
2404
|
createdAt: row.created_at,
|
|
1397
2405
|
updatedAt: row.updated_at,
|
|
1398
2406
|
revision: row.revision,
|
|
1399
|
-
...(row.trigger_kind === "cron"
|
|
1400
|
-
? {
|
|
1401
|
-
|
|
2407
|
+
...(row.trigger_kind === "cron"
|
|
2408
|
+
? {
|
|
2409
|
+
trigger: {
|
|
2410
|
+
kind: "cron",
|
|
2411
|
+
...(row.cron_job_id === null ? {} : { jobId: row.cron_job_id }),
|
|
2412
|
+
...(row.cron_configured === null ? {} : { configured: row.cron_configured === 1 }),
|
|
2413
|
+
},
|
|
2414
|
+
}
|
|
2415
|
+
: row.trigger_kind === "webhook"
|
|
2416
|
+
? { trigger: { kind: "webhook" } }
|
|
2417
|
+
: {}),
|
|
1402
2418
|
...(preview === undefined ? {} : { lastMessagePreview: preview }),
|
|
1403
2419
|
messageCount: row.message_count,
|
|
1404
2420
|
runState,
|
|
@@ -1458,6 +2474,11 @@ export class WebStore {
|
|
|
1458
2474
|
.trim();
|
|
1459
2475
|
return text.length === 0 ? undefined : text.slice(0, 160);
|
|
1460
2476
|
}
|
|
2477
|
+
cronChannel(sourceId, jobId) {
|
|
2478
|
+
return this.database.prepare(`
|
|
2479
|
+
SELECT * FROM cron_channels WHERE source_id = ? AND job_id = ?
|
|
2480
|
+
`).get(sourceId, jobId);
|
|
2481
|
+
}
|
|
1461
2482
|
requireThread(id) {
|
|
1462
2483
|
const thread = this.getThread(id);
|
|
1463
2484
|
if (thread === undefined)
|
|
@@ -1586,6 +2607,13 @@ export class WebStore {
|
|
|
1586
2607
|
const row = this.database.prepare("SELECT revision FROM threads WHERE id = ?").get(threadId);
|
|
1587
2608
|
this.database.prepare("INSERT INTO revisions (entity_kind, entity_id, revision, event, created_at) VALUES ('thread', ?, ?, ?, ?)")
|
|
1588
2609
|
.run(threadId, row.revision, event, now);
|
|
2610
|
+
this.database.prepare(`
|
|
2611
|
+
DELETE FROM revisions WHERE id IN (
|
|
2612
|
+
SELECT id FROM revisions
|
|
2613
|
+
WHERE entity_kind = 'thread' AND entity_id = ?
|
|
2614
|
+
ORDER BY revision DESC, id DESC LIMIT -1 OFFSET ?
|
|
2615
|
+
)
|
|
2616
|
+
`).run(threadId, MAX_REVISIONS_PER_THREAD);
|
|
1589
2617
|
}
|
|
1590
2618
|
setSetting(key, value) {
|
|
1591
2619
|
this.database.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
|
|
@@ -1684,10 +2712,14 @@ function isValidVapidKeyPair(publicKey, privateKey) {
|
|
|
1684
2712
|
function threadSelectSql(suffix) {
|
|
1685
2713
|
return `
|
|
1686
2714
|
SELECT t.id, t.source_id, t.title, t.trigger_kind, t.archived_at, t.created_at, t.updated_at, t.revision,
|
|
1687
|
-
|
|
1688
|
-
CASE WHEN
|
|
2715
|
+
cc.job_id AS cron_job_id, cc.configured AS cron_configured,
|
|
2716
|
+
CASE WHEN t.trigger_kind = 'cron' THEN 0
|
|
2717
|
+
WHEN a.status = 'online' OR a.status = 'degraded' THEN 1 ELSE 0 END AS can_send,
|
|
2718
|
+
CASE WHEN t.trigger_kind = 'cron' THEN 0
|
|
2719
|
+
WHEN (a.status = 'online' OR a.status = 'degraded') AND a.supports_attachments = 1 THEN 1 ELSE 0 END AS can_upload,
|
|
1689
2720
|
(SELECT COUNT(*) FROM messages m WHERE m.thread_id = t.id) AS message_count
|
|
1690
2721
|
FROM threads t JOIN agents a ON a.source_id = t.source_id
|
|
2722
|
+
LEFT JOIN cron_channels cc ON cc.thread_id = t.id
|
|
1691
2723
|
${suffix}
|
|
1692
2724
|
`;
|
|
1693
2725
|
}
|
|
@@ -1714,6 +2746,21 @@ function notificationThreadId(sourceId, deliveryKey) {
|
|
|
1714
2746
|
.slice(0, 32);
|
|
1715
2747
|
return `notification-${digest}`;
|
|
1716
2748
|
}
|
|
2749
|
+
function cronChannelThreadId(sourceId, jobId) {
|
|
2750
|
+
return `cron-${stableDigest(sourceId, jobId)}`;
|
|
2751
|
+
}
|
|
2752
|
+
function cronConsoleConversationId(sourceId, jobId) {
|
|
2753
|
+
return `web-cron:${stableDigest(sourceId, jobId)}`;
|
|
2754
|
+
}
|
|
2755
|
+
function cronEntityId(kind, sourceId, jobId, runId) {
|
|
2756
|
+
return `cron-${kind}-${stableDigest(sourceId, jobId, runId)}`;
|
|
2757
|
+
}
|
|
2758
|
+
function stableDigest(...values) {
|
|
2759
|
+
const hash = createHash("sha256");
|
|
2760
|
+
for (const value of values)
|
|
2761
|
+
hash.update(value).update("\0");
|
|
2762
|
+
return hash.digest("hex").slice(0, 32);
|
|
2763
|
+
}
|
|
1717
2764
|
function notificationPayloadSha256(kind, text) {
|
|
1718
2765
|
return createHash("sha256")
|
|
1719
2766
|
.update(kind)
|
|
@@ -1721,6 +2768,282 @@ function notificationPayloadSha256(kind, text) {
|
|
|
1721
2768
|
.update(text)
|
|
1722
2769
|
.digest("hex");
|
|
1723
2770
|
}
|
|
2771
|
+
export function notificationPushLogicalKey(sourceId, deliveryKey) {
|
|
2772
|
+
return `web-new:${stableDigest(sourceId, deliveryKey)}`;
|
|
2773
|
+
}
|
|
2774
|
+
function legacyCronDeliveryIdentity(deliveryKey) {
|
|
2775
|
+
// Parse from the anchored terminal suffix and fixed-width canonical UTC
|
|
2776
|
+
// timestamp. The greedy job segment may therefore contain legal literal
|
|
2777
|
+
// colons while malformed prefixes/suffixes remain unadoptable.
|
|
2778
|
+
const isoTimestamp = "(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z)";
|
|
2779
|
+
const success = new RegExp(`^cron:(.+):${isoTimestamp}:success$`, "u").exec(deliveryKey);
|
|
2780
|
+
const failure = new RegExp(`^cron:(.+):${isoTimestamp}:failure:[^:]+$`, "u").exec(deliveryKey);
|
|
2781
|
+
const match = success ?? failure;
|
|
2782
|
+
if (match === null)
|
|
2783
|
+
return undefined;
|
|
2784
|
+
const encodedJobId = match[1];
|
|
2785
|
+
const middle = match[2];
|
|
2786
|
+
if (encodedJobId === undefined || middle === undefined)
|
|
2787
|
+
return undefined;
|
|
2788
|
+
const timestamp = Date.parse(middle);
|
|
2789
|
+
if (!Number.isFinite(timestamp) || new Date(timestamp).toISOString() !== middle)
|
|
2790
|
+
return undefined;
|
|
2791
|
+
try {
|
|
2792
|
+
const jobId = decodeURIComponent(encodedJobId);
|
|
2793
|
+
if (jobId.length === 0)
|
|
2794
|
+
return undefined;
|
|
2795
|
+
return { jobId, runId: `cron:${encodedJobId}:${middle}` };
|
|
2796
|
+
}
|
|
2797
|
+
catch {
|
|
2798
|
+
return undefined;
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
function compareCronRuns(left, right) {
|
|
2802
|
+
return left.orderedAt.localeCompare(right.orderedAt)
|
|
2803
|
+
|| left.sequence - right.sequence
|
|
2804
|
+
|| left.runId.localeCompare(right.runId);
|
|
2805
|
+
}
|
|
2806
|
+
function cronMessageStatus(status) {
|
|
2807
|
+
if (status === "running")
|
|
2808
|
+
return "running";
|
|
2809
|
+
if (status === "failed")
|
|
2810
|
+
return "failed";
|
|
2811
|
+
if (status === "cancelled")
|
|
2812
|
+
return "cancelled";
|
|
2813
|
+
return "complete";
|
|
2814
|
+
}
|
|
2815
|
+
function cronRunParts(run, prior, conversationId) {
|
|
2816
|
+
const silent = run.status === "succeeded" && classifyNotifySuppression(run.text) !== "none";
|
|
2817
|
+
const priorCron = prior.find((part) => part.type === "telemetry" && part.event === "cron_run");
|
|
2818
|
+
const priorCronData = record(priorCron?.data);
|
|
2819
|
+
const priorActivityLoaded = priorCronData?.activityLoaded === true;
|
|
2820
|
+
const priorLoadedEventCount = Number.isSafeInteger(priorCronData?.loadedEventCount)
|
|
2821
|
+
? Number(priorCronData?.loadedEventCount)
|
|
2822
|
+
: undefined;
|
|
2823
|
+
const priorActivityEventCount = Number.isSafeInteger(priorCronData?.activityEventCount)
|
|
2824
|
+
? Number(priorCronData?.activityEventCount)
|
|
2825
|
+
: priorLoadedEventCount;
|
|
2826
|
+
const activityLoaded = run.projection === "detail"
|
|
2827
|
+
|| (priorActivityLoaded && priorActivityEventCount === run.eventCount);
|
|
2828
|
+
const activityStale = run.projection === "summary"
|
|
2829
|
+
&& priorActivityLoaded
|
|
2830
|
+
&& priorActivityEventCount !== run.eventCount;
|
|
2831
|
+
const eventsTruncated = run.projection === "detail"
|
|
2832
|
+
? run.eventsTruncated === true
|
|
2833
|
+
: run.eventsTruncated === true || (priorActivityLoaded && priorCronData?.eventsTruncated === true);
|
|
2834
|
+
// Reconciliation updates one durable message in place. Strip the prior
|
|
2835
|
+
// synthetic state/identity before rebuilding it, while retaining a genuine
|
|
2836
|
+
// notification text that arrived before the operator run projection.
|
|
2837
|
+
const retained = prior.filter((part) => !(part.type === "telemetry" && part.event === "cron_run")
|
|
2838
|
+
&& !(part.type === "text" && isSyntheticCronStateText(part.text)));
|
|
2839
|
+
const parts = run.projection === "summary"
|
|
2840
|
+
? [...retained]
|
|
2841
|
+
: retained.filter((part) => part.type === "text" || part.type === "error");
|
|
2842
|
+
for (const event of run.projection === "detail" ? run.events : [])
|
|
2843
|
+
applyEvent(parts, event);
|
|
2844
|
+
const preserveLoadedText = run.projection === "summary"
|
|
2845
|
+
&& run.fieldsTruncated?.includes("text") === true
|
|
2846
|
+
&& priorActivityLoaded;
|
|
2847
|
+
const preserveLoadedError = run.projection === "summary"
|
|
2848
|
+
&& priorActivityLoaded
|
|
2849
|
+
&& (run.fieldsTruncated?.includes("error") === true
|
|
2850
|
+
|| run.fieldsTruncated?.includes("failureKind") === true);
|
|
2851
|
+
if (!silent && !preserveLoadedText && run.text !== undefined && run.text.length > 0) {
|
|
2852
|
+
reconcileFinalText(parts, run.text);
|
|
2853
|
+
}
|
|
2854
|
+
const hasText = parts.some((part) => part.type === "text" && part.text.trim().length > 0);
|
|
2855
|
+
if (!hasText) {
|
|
2856
|
+
const stateText = run.status === "succeeded"
|
|
2857
|
+
? "Completed silently (no message was reported)."
|
|
2858
|
+
: run.status === "skipped_overlap"
|
|
2859
|
+
? run.blockedByTrigger === "manual"
|
|
2860
|
+
? "Scheduled firing skipped because an operator-started manual run was still in flight."
|
|
2861
|
+
: "Firing skipped because the previous run was still in flight."
|
|
2862
|
+
: run.status === "queued"
|
|
2863
|
+
? `Queued behind an active run${run.queueDepth === undefined ? "." : ` (position ${String(run.queueDepth)}).`}`
|
|
2864
|
+
: run.status === "dropped"
|
|
2865
|
+
? "Dropped because the pending-run queue was full."
|
|
2866
|
+
: run.status === "cancelled"
|
|
2867
|
+
? "Run cancelled."
|
|
2868
|
+
: run.status === "failed"
|
|
2869
|
+
? "Run failed."
|
|
2870
|
+
: run.status === "admitted"
|
|
2871
|
+
? "Run admitted and waiting to start."
|
|
2872
|
+
: "Run is in progress.";
|
|
2873
|
+
parts.push({ type: "text", text: stateText });
|
|
2874
|
+
}
|
|
2875
|
+
if (!preserveLoadedError
|
|
2876
|
+
&& run.error !== undefined
|
|
2877
|
+
&& !parts.some((part) => part.type === "error" && part.message === run.error)) {
|
|
2878
|
+
parts.push({
|
|
2879
|
+
type: "error",
|
|
2880
|
+
...(run.failureKind === undefined ? {} : { code: run.failureKind }),
|
|
2881
|
+
message: run.error,
|
|
2882
|
+
});
|
|
2883
|
+
}
|
|
2884
|
+
parts.push({
|
|
2885
|
+
type: "telemetry",
|
|
2886
|
+
event: "cron_run",
|
|
2887
|
+
data: {
|
|
2888
|
+
runId: run.runId,
|
|
2889
|
+
conversationId,
|
|
2890
|
+
scheduledAt: run.scheduledAt,
|
|
2891
|
+
orderedAt: run.orderedAt,
|
|
2892
|
+
sequence: run.sequence,
|
|
2893
|
+
trigger: run.trigger,
|
|
2894
|
+
status: run.status,
|
|
2895
|
+
...(silent ? { silent: true } : {}),
|
|
2896
|
+
...(run.startedAt === undefined ? {} : { startedAt: run.startedAt }),
|
|
2897
|
+
...(run.completedAt === undefined ? {} : { completedAt: run.completedAt }),
|
|
2898
|
+
...(run.artifactRunId === undefined ? {} : { artifactRunId: run.artifactRunId }),
|
|
2899
|
+
...(run.blockedByRunId === undefined ? {} : { blockedByRunId: run.blockedByRunId }),
|
|
2900
|
+
eventCount: run.eventCount,
|
|
2901
|
+
...(activityLoaded
|
|
2902
|
+
? {
|
|
2903
|
+
activityLoaded: true,
|
|
2904
|
+
activityEventCount: run.eventCount,
|
|
2905
|
+
loadedEventCount: run.projection === "detail" ? run.eventsIncluded : priorLoadedEventCount ?? run.eventCount,
|
|
2906
|
+
}
|
|
2907
|
+
: {}),
|
|
2908
|
+
...(activityStale ? { activityStale: true, loadedEventCount: priorLoadedEventCount } : {}),
|
|
2909
|
+
...(eventsTruncated ? { eventsTruncated: true } : {}),
|
|
2910
|
+
...(run.fieldsTruncated === undefined ? {} : { fieldsTruncated: run.fieldsTruncated }),
|
|
2911
|
+
},
|
|
2912
|
+
});
|
|
2913
|
+
return parts;
|
|
2914
|
+
}
|
|
2915
|
+
function cronRunSummary(run) {
|
|
2916
|
+
if (run.projection === "summary")
|
|
2917
|
+
return run;
|
|
2918
|
+
const { events: _events, eventsIncluded: _eventsIncluded, ...base } = run;
|
|
2919
|
+
return { ...base, projection: "summary" };
|
|
2920
|
+
}
|
|
2921
|
+
function record(value) {
|
|
2922
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
2923
|
+
? value
|
|
2924
|
+
: undefined;
|
|
2925
|
+
}
|
|
2926
|
+
function isSyntheticCronStateText(text) {
|
|
2927
|
+
return text === "Completed silently (no message was reported)."
|
|
2928
|
+
|| text === "Scheduled firing skipped because an operator-started manual run was still in flight."
|
|
2929
|
+
|| text === "Firing skipped because the previous run was still in flight."
|
|
2930
|
+
|| text.startsWith("Queued behind an active run")
|
|
2931
|
+
|| text === "Dropped because the pending-run queue was full."
|
|
2932
|
+
|| text === "Run cancelled."
|
|
2933
|
+
|| text === "Run failed."
|
|
2934
|
+
|| text === "Run admitted and waiting to start."
|
|
2935
|
+
|| text === "Run is in progress.";
|
|
2936
|
+
}
|
|
2937
|
+
function parseStoredCronJob(serialized) {
|
|
2938
|
+
let value;
|
|
2939
|
+
try {
|
|
2940
|
+
value = JSON.parse(serialized);
|
|
2941
|
+
}
|
|
2942
|
+
catch {
|
|
2943
|
+
throw new WebConsoleError("storage_corrupt", "Stored cron job metadata is invalid JSON.", 500);
|
|
2944
|
+
}
|
|
2945
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
2946
|
+
throw new WebConsoleError("storage_corrupt", "Stored cron job metadata is invalid.", 500);
|
|
2947
|
+
}
|
|
2948
|
+
const job = value;
|
|
2949
|
+
if (typeof job.jobId !== "string"
|
|
2950
|
+
|| typeof job.conversationId !== "string"
|
|
2951
|
+
|| typeof job.configured !== "boolean"
|
|
2952
|
+
|| typeof job.declaredEnabled !== "boolean"
|
|
2953
|
+
|| typeof job.effectiveEnabled !== "boolean"
|
|
2954
|
+
|| !["healthy", "warning", "unhealthy", "disabled", "unknown"].includes(String(job.health))) {
|
|
2955
|
+
throw new WebConsoleError("storage_corrupt", "Stored cron job metadata is invalid.", 500);
|
|
2956
|
+
}
|
|
2957
|
+
return job;
|
|
2958
|
+
}
|
|
2959
|
+
function parseStoredCronRun(serialized) {
|
|
2960
|
+
let value;
|
|
2961
|
+
try {
|
|
2962
|
+
value = JSON.parse(serialized);
|
|
2963
|
+
}
|
|
2964
|
+
catch {
|
|
2965
|
+
throw new WebConsoleError("storage_corrupt", "Stored cron run metadata is invalid JSON.", 500);
|
|
2966
|
+
}
|
|
2967
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
2968
|
+
throw new WebConsoleError("storage_corrupt", "Stored cron run metadata is invalid.", 500);
|
|
2969
|
+
}
|
|
2970
|
+
const raw = value;
|
|
2971
|
+
const legacyEvents = Array.isArray(raw.events) ? raw.events : undefined;
|
|
2972
|
+
const { events: _legacyEvents, ...legacyBase } = raw;
|
|
2973
|
+
const run = (raw.projection === undefined
|
|
2974
|
+
? {
|
|
2975
|
+
...legacyBase,
|
|
2976
|
+
projection: "summary",
|
|
2977
|
+
eventCount: legacyEvents?.length ?? 0,
|
|
2978
|
+
...(legacyEvents === undefined ? {} : { eventsTruncated: true }),
|
|
2979
|
+
}
|
|
2980
|
+
: raw);
|
|
2981
|
+
if (run.projection !== "summary"
|
|
2982
|
+
|| typeof run.runId !== "string"
|
|
2983
|
+
|| typeof run.jobId !== "string"
|
|
2984
|
+
|| typeof run.scheduledAt !== "string"
|
|
2985
|
+
|| typeof run.orderedAt !== "string"
|
|
2986
|
+
|| !Number.isSafeInteger(run.sequence)
|
|
2987
|
+
|| (run.trigger !== "scheduled" && run.trigger !== "manual")
|
|
2988
|
+
|| !["admitted", "running", "queued", "succeeded", "failed", "cancelled", "skipped_overlap", "dropped"]
|
|
2989
|
+
.includes(String(run.status))
|
|
2990
|
+
|| !Number.isSafeInteger(run.eventCount)
|
|
2991
|
+
|| Number(run.eventCount) < 0) {
|
|
2992
|
+
throw new WebConsoleError("storage_corrupt", "Stored cron run metadata is invalid.", 500);
|
|
2993
|
+
}
|
|
2994
|
+
return run;
|
|
2995
|
+
}
|
|
2996
|
+
function messageRoleRankSql(messageAlias, turnAlias) {
|
|
2997
|
+
return `CASE WHEN ${messageAlias}.turn_id IS NOT NULL AND ${messageAlias}.role = 'user' THEN 0
|
|
2998
|
+
WHEN ${messageAlias}.turn_id IS NOT NULL AND ${messageAlias}.role = 'system' THEN 1
|
|
2999
|
+
WHEN ${messageAlias}.turn_id IS NOT NULL THEN 2 ELSE 3 END`;
|
|
3000
|
+
}
|
|
3001
|
+
function boundedPageLimit(value, maximum) {
|
|
3002
|
+
const normalized = value ?? maximum;
|
|
3003
|
+
if (!Number.isSafeInteger(normalized) || normalized < 1 || normalized > maximum) {
|
|
3004
|
+
throw new WebConsoleError("invalid_page", `limit must be 1-${String(maximum)}.`, 400);
|
|
3005
|
+
}
|
|
3006
|
+
return normalized;
|
|
3007
|
+
}
|
|
3008
|
+
function encodeCursor(value) {
|
|
3009
|
+
return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
|
|
3010
|
+
}
|
|
3011
|
+
function decodeCursor(value) {
|
|
3012
|
+
if (value.length === 0 || value.length > 4_096 || !/^[A-Za-z0-9_-]+$/u.test(value)) {
|
|
3013
|
+
throw new WebConsoleError("invalid_page", "Pagination cursor is invalid.", 400);
|
|
3014
|
+
}
|
|
3015
|
+
try {
|
|
3016
|
+
const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
|
|
3017
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
3018
|
+
throw new Error("invalid");
|
|
3019
|
+
return parsed;
|
|
3020
|
+
}
|
|
3021
|
+
catch {
|
|
3022
|
+
throw new WebConsoleError("invalid_page", "Pagination cursor is invalid.", 400);
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
function decodeThreadCursor(value) {
|
|
3026
|
+
const cursor = decodeCursor(value);
|
|
3027
|
+
if (typeof cursor.updatedAt !== "string" || typeof cursor.id !== "string") {
|
|
3028
|
+
throw new WebConsoleError("invalid_page", "Pagination cursor is invalid.", 400);
|
|
3029
|
+
}
|
|
3030
|
+
return { updatedAt: cursor.updatedAt, id: cursor.id };
|
|
3031
|
+
}
|
|
3032
|
+
function decodeMessageCursor(value) {
|
|
3033
|
+
const cursor = decodeCursor(value);
|
|
3034
|
+
if (typeof cursor.orderedAt !== "string"
|
|
3035
|
+
|| !Number.isSafeInteger(cursor.roleRank)
|
|
3036
|
+
|| typeof cursor.createdAt !== "string"
|
|
3037
|
+
|| !Number.isSafeInteger(cursor.rowid)) {
|
|
3038
|
+
throw new WebConsoleError("invalid_page", "Pagination cursor is invalid.", 400);
|
|
3039
|
+
}
|
|
3040
|
+
return {
|
|
3041
|
+
orderedAt: cursor.orderedAt,
|
|
3042
|
+
roleRank: cursor.roleRank,
|
|
3043
|
+
createdAt: cursor.createdAt,
|
|
3044
|
+
rowid: cursor.rowid,
|
|
3045
|
+
};
|
|
3046
|
+
}
|
|
1724
3047
|
function mapAgent(row) {
|
|
1725
3048
|
const models = parseStringArray(row.models_json);
|
|
1726
3049
|
const efforts = parseStringArray(row.efforts_json);
|
|
@@ -1737,6 +3060,10 @@ function mapAgent(row) {
|
|
|
1737
3060
|
...(row.default_effort === null ? {} : { defaultEffort: row.default_effort }),
|
|
1738
3061
|
...(efforts === undefined ? {} : { efforts }),
|
|
1739
3062
|
...(modelOptions === undefined ? {} : { modelOptions }),
|
|
3063
|
+
...(row.cron_read === 1
|
|
3064
|
+
? { cron: { read: true, actions: row.cron_actions === 1 } }
|
|
3065
|
+
: {}),
|
|
3066
|
+
...(row.ask_by_id === 1 ? { supportsAskById: true } : {}),
|
|
1740
3067
|
updatedAt: row.updated_at,
|
|
1741
3068
|
};
|
|
1742
3069
|
}
|
|
@@ -1785,19 +3112,25 @@ function applyEvent(parts, event) {
|
|
|
1785
3112
|
return;
|
|
1786
3113
|
}
|
|
1787
3114
|
if (event.type === "tool_call_started") {
|
|
3115
|
+
const historyUpdate = canonicalEventHistoryUpdate(event.history);
|
|
1788
3116
|
const subagent = subagentOf(event);
|
|
1789
3117
|
if (subagent !== undefined) {
|
|
1790
3118
|
const group = ensureSubagentPart(parts, subagent);
|
|
1791
3119
|
// The bookend only announces the subagent; the group it belongs to is the
|
|
1792
3120
|
// whole of its contribution here.
|
|
1793
|
-
if (event.metadata?.subagentLifecycle
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
toolName: subagentToolName(event.name),
|
|
3121
|
+
if (event.metadata?.subagentLifecycle === true) {
|
|
3122
|
+
replaceSubagentPart(parts, withEventHistoryUpdate({
|
|
3123
|
+
...group,
|
|
1797
3124
|
...(event.arguments === undefined ? {} : { args: event.arguments }),
|
|
1798
|
-
|
|
1799
|
-
|
|
3125
|
+
}, historyUpdate));
|
|
3126
|
+
return;
|
|
1800
3127
|
}
|
|
3128
|
+
upsertSubagentCall(parts, group, {
|
|
3129
|
+
toolCallId: event.id,
|
|
3130
|
+
toolName: subagentToolName(event.name),
|
|
3131
|
+
...(event.arguments === undefined ? {} : { args: event.arguments }),
|
|
3132
|
+
status: "running",
|
|
3133
|
+
}, historyUpdate);
|
|
1801
3134
|
return;
|
|
1802
3135
|
}
|
|
1803
3136
|
upsertToolCall(parts, {
|
|
@@ -1806,7 +3139,7 @@ function applyEvent(parts, event) {
|
|
|
1806
3139
|
toolName: event.name,
|
|
1807
3140
|
...(event.arguments === undefined ? {} : { args: event.arguments }),
|
|
1808
3141
|
status: "running",
|
|
1809
|
-
});
|
|
3142
|
+
}, historyUpdate);
|
|
1810
3143
|
return;
|
|
1811
3144
|
}
|
|
1812
3145
|
if (event.type === "tool_call_progress") {
|
|
@@ -1821,16 +3154,17 @@ function applyEvent(parts, event) {
|
|
|
1821
3154
|
}
|
|
1822
3155
|
if (event.type === "tool_call_completed") {
|
|
1823
3156
|
const status = event.isError === true ? "failed" : "complete";
|
|
3157
|
+
const historyUpdate = canonicalEventHistoryUpdate(event.history);
|
|
1824
3158
|
const subagent = subagentOf(event);
|
|
1825
3159
|
if (subagent !== undefined) {
|
|
1826
3160
|
const group = ensureSubagentPart(parts, subagent);
|
|
1827
3161
|
if (event.metadata?.subagentLifecycle === true) {
|
|
1828
|
-
replaceSubagentPart(parts, {
|
|
3162
|
+
replaceSubagentPart(parts, withEventHistoryUpdate({
|
|
1829
3163
|
...group,
|
|
1830
3164
|
status,
|
|
1831
3165
|
...(event.executionMs === undefined ? {} : { executionMs: event.executionMs }),
|
|
1832
3166
|
...(subagent.costUsd === undefined ? {} : { costUsd: subagent.costUsd }),
|
|
1833
|
-
});
|
|
3167
|
+
}, historyUpdate));
|
|
1834
3168
|
return;
|
|
1835
3169
|
}
|
|
1836
3170
|
upsertSubagentCall(parts, group, {
|
|
@@ -1839,19 +3173,19 @@ function applyEvent(parts, event) {
|
|
|
1839
3173
|
...(event.arguments === undefined ? {} : { args: event.arguments }),
|
|
1840
3174
|
...(event.content === undefined ? {} : { result: event.content }),
|
|
1841
3175
|
status,
|
|
1842
|
-
});
|
|
3176
|
+
}, historyUpdate);
|
|
1843
3177
|
return;
|
|
1844
3178
|
}
|
|
1845
3179
|
// The parent `Agent` call completes against the group that replaced its
|
|
1846
3180
|
// tool-call part, so its answer and outcome are not lost to the conversion.
|
|
1847
3181
|
const group = findSubagentPart(parts, event.id);
|
|
1848
3182
|
if (group !== undefined) {
|
|
1849
|
-
replaceSubagentPart(parts, {
|
|
3183
|
+
replaceSubagentPart(parts, withEventHistoryUpdate({
|
|
1850
3184
|
...group,
|
|
1851
3185
|
status,
|
|
1852
3186
|
...(event.content === undefined ? {} : { result: event.content }),
|
|
1853
3187
|
...(event.executionMs === undefined ? {} : { executionMs: event.executionMs }),
|
|
1854
|
-
});
|
|
3188
|
+
}, historyUpdate));
|
|
1855
3189
|
return;
|
|
1856
3190
|
}
|
|
1857
3191
|
upsertToolCall(parts, {
|
|
@@ -1861,7 +3195,7 @@ function applyEvent(parts, event) {
|
|
|
1861
3195
|
...(event.arguments === undefined ? {} : { args: event.arguments }),
|
|
1862
3196
|
...(event.content === undefined ? {} : { result: event.content }),
|
|
1863
3197
|
status,
|
|
1864
|
-
});
|
|
3198
|
+
}, historyUpdate);
|
|
1865
3199
|
return;
|
|
1866
3200
|
}
|
|
1867
3201
|
if (event.type === "runtime_telemetry" && event.kind === "context_compaction") {
|
|
@@ -1899,6 +3233,14 @@ function existingToolName(parts, id) {
|
|
|
1899
3233
|
const existing = parts.find((part) => part.type === "tool-call" && part.toolCallId === id);
|
|
1900
3234
|
return existing?.type === "tool-call" ? existing.toolName : undefined;
|
|
1901
3235
|
}
|
|
3236
|
+
function canonicalEventHistoryUpdate(value) {
|
|
3237
|
+
return value === undefined
|
|
3238
|
+
? undefined
|
|
3239
|
+
: { value: canonicalSessionToolHistoryMetadata(value) };
|
|
3240
|
+
}
|
|
3241
|
+
function withEventHistoryUpdate(value, update) {
|
|
3242
|
+
return update === undefined ? value : withSessionToolHistory(value, update.value);
|
|
3243
|
+
}
|
|
1902
3244
|
/**
|
|
1903
3245
|
* The subagent this tool event belongs to, or undefined for the agent's own
|
|
1904
3246
|
* work. Validated rather than cast: `metadata` is an open record arriving over
|
|
@@ -1962,6 +3304,7 @@ function ensureSubagentPart(parts, subagent) {
|
|
|
1962
3304
|
...(subagent.label === undefined ? {} : { label: subagent.label }),
|
|
1963
3305
|
...(previous?.type === "tool-call" && previous.args !== undefined ? { args: previous.args } : {}),
|
|
1964
3306
|
...(previous?.type === "tool-call" && previous.result !== undefined ? { result: previous.result } : {}),
|
|
3307
|
+
...(previous?.type === "tool-call" && previous.history !== undefined ? { history: previous.history } : {}),
|
|
1965
3308
|
status: previous?.type === "tool-call" ? previous.status : "running",
|
|
1966
3309
|
calls: [],
|
|
1967
3310
|
};
|
|
@@ -1978,22 +3321,32 @@ function replaceSubagentPart(parts, next) {
|
|
|
1978
3321
|
else
|
|
1979
3322
|
parts[index] = next;
|
|
1980
3323
|
}
|
|
1981
|
-
function upsertSubagentCall(parts, group, next) {
|
|
3324
|
+
function upsertSubagentCall(parts, group, next, historyUpdate) {
|
|
1982
3325
|
const index = group.calls.findIndex((call) => call.toolCallId === next.toolCallId);
|
|
3326
|
+
const merged = index < 0
|
|
3327
|
+
? next
|
|
3328
|
+
: { ...group.calls[index], ...next };
|
|
3329
|
+
const updated = historyUpdate === undefined
|
|
3330
|
+
? merged
|
|
3331
|
+
: withSessionToolHistory(merged, historyUpdate.value);
|
|
1983
3332
|
const calls = index < 0
|
|
1984
|
-
? [...group.calls,
|
|
1985
|
-
: group.calls.map((call, at) => at === index ?
|
|
3333
|
+
? [...group.calls, updated]
|
|
3334
|
+
: group.calls.map((call, at) => at === index ? updated : call);
|
|
1986
3335
|
replaceSubagentPart(parts, { ...group, calls });
|
|
1987
3336
|
}
|
|
1988
|
-
function upsertToolCall(parts, next) {
|
|
3337
|
+
function upsertToolCall(parts, next, historyUpdate) {
|
|
1989
3338
|
const index = parts.findIndex((part) => part.type === "tool-call" && part.toolCallId === next.toolCallId);
|
|
3339
|
+
const previous = index < 0 ? undefined : parts[index];
|
|
3340
|
+
const merged = previous?.type === "tool-call" ? { ...previous, ...next } : next;
|
|
3341
|
+
const updated = historyUpdate === undefined
|
|
3342
|
+
? merged
|
|
3343
|
+
: withSessionToolHistory(merged, historyUpdate.value);
|
|
1990
3344
|
if (index < 0) {
|
|
1991
|
-
parts.push(
|
|
3345
|
+
parts.push(updated);
|
|
1992
3346
|
return;
|
|
1993
3347
|
}
|
|
1994
|
-
const previous = parts[index];
|
|
1995
3348
|
if (previous?.type === "tool-call")
|
|
1996
|
-
parts[index] =
|
|
3349
|
+
parts[index] = updated;
|
|
1997
3350
|
}
|
|
1998
3351
|
/**
|
|
1999
3352
|
* Whether a stored part reaches the transcript at all. The console renders
|
|
@@ -2147,6 +3500,290 @@ function normalizeTitle(value) {
|
|
|
2147
3500
|
throw new WebConsoleError("invalid_title", "A conversation title cannot be empty.", 400);
|
|
2148
3501
|
return title;
|
|
2149
3502
|
}
|
|
3503
|
+
const REPLY_FAILURE_CODES = new Set([
|
|
3504
|
+
"app_capability_mismatch",
|
|
3505
|
+
"app_connection_closed",
|
|
3506
|
+
"app_resource_invalid",
|
|
3507
|
+
"artifact_expired",
|
|
3508
|
+
"artifact_integrity_failed",
|
|
3509
|
+
"artifact_missing",
|
|
3510
|
+
"artifact_publish_failed",
|
|
3511
|
+
"artifact_too_large",
|
|
3512
|
+
"reply_part_too_large",
|
|
3513
|
+
"unsupported_destination",
|
|
3514
|
+
]);
|
|
3515
|
+
const REPLY_PARTS_TRUNCATED_ID = "web-reply-parts-truncated";
|
|
3516
|
+
const INVALID_REPLY_PART_ID = "invalid-rich-part";
|
|
3517
|
+
/**
|
|
3518
|
+
* The SQLite boundary does not trust the operator wire parser. A truncated
|
|
3519
|
+
* reply reserves one of the shared outcome slots for a durable diagnostic so
|
|
3520
|
+
* every omitted suffix is visible without allowing this completion to take a
|
|
3521
|
+
* message beyond the producer/wire limit. Legacy over-cap state is repaired by
|
|
3522
|
+
* retaining a deterministic prefix and reserving the final slot for the same
|
|
3523
|
+
* bounded diagnostic.
|
|
3524
|
+
*/
|
|
3525
|
+
function nextSyntheticReplyPartId(base, ids) {
|
|
3526
|
+
let failureId = base;
|
|
3527
|
+
for (let suffix = 2; ids.has(failureId); suffix += 1) {
|
|
3528
|
+
failureId = `${base}-${suffix}`;
|
|
3529
|
+
}
|
|
3530
|
+
ids.add(failureId);
|
|
3531
|
+
return failureId;
|
|
3532
|
+
}
|
|
3533
|
+
function replyPartIds(values) {
|
|
3534
|
+
const ids = new Set();
|
|
3535
|
+
for (const value of values) {
|
|
3536
|
+
const id = record(value)?.id;
|
|
3537
|
+
if (validRichId(id))
|
|
3538
|
+
ids.add(id);
|
|
3539
|
+
}
|
|
3540
|
+
return ids;
|
|
3541
|
+
}
|
|
3542
|
+
function replyPartIdCounts(values) {
|
|
3543
|
+
const counts = new Map();
|
|
3544
|
+
for (const value of values) {
|
|
3545
|
+
const id = record(value)?.id;
|
|
3546
|
+
if (validRichId(id))
|
|
3547
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
3548
|
+
}
|
|
3549
|
+
return counts;
|
|
3550
|
+
}
|
|
3551
|
+
function isDurableWebReplyPart(part) {
|
|
3552
|
+
return part.type === "attachment" || part.type === "mcp_app" || part.type === "failure";
|
|
3553
|
+
}
|
|
3554
|
+
function boundedWebReplyParts(input, existingParts) {
|
|
3555
|
+
const existingReplyParts = existingParts.filter(isDurableWebReplyPart);
|
|
3556
|
+
const existingReplyPartCount = existingReplyParts.length;
|
|
3557
|
+
const existingIds = replyPartIds(existingReplyParts);
|
|
3558
|
+
const ids = new Set(existingIds);
|
|
3559
|
+
const claimedIds = new Set(existingIds);
|
|
3560
|
+
const availableSlots = Math.max(0, MAX_AGENT_REPLY_PARTS - existingReplyPartCount);
|
|
3561
|
+
const inputParts = Array.isArray(input) ? input : undefined;
|
|
3562
|
+
const needsDiagnostic = inputParts === undefined
|
|
3563
|
+
|| existingReplyPartCount > MAX_AGENT_REPLY_PARTS
|
|
3564
|
+
|| inputParts.length > availableSlots;
|
|
3565
|
+
const retainedExistingCount = needsDiagnostic
|
|
3566
|
+
? Math.min(existingReplyPartCount, MAX_AGENT_REPLY_PARTS - 1)
|
|
3567
|
+
: existingReplyPartCount;
|
|
3568
|
+
let seenExistingReplyParts = 0;
|
|
3569
|
+
const retainedExistingParts = existingParts.filter((part) => {
|
|
3570
|
+
if (!isDurableWebReplyPart(part))
|
|
3571
|
+
return true;
|
|
3572
|
+
seenExistingReplyParts += 1;
|
|
3573
|
+
return seenExistingReplyParts <= retainedExistingCount;
|
|
3574
|
+
});
|
|
3575
|
+
if (inputParts === undefined) {
|
|
3576
|
+
const invalidRecord = record(input);
|
|
3577
|
+
const legacyValues = invalidRecord === undefined ? [input] : [input, ...Object.values(invalidRecord)];
|
|
3578
|
+
for (const id of replyPartIds(legacyValues))
|
|
3579
|
+
ids.add(id);
|
|
3580
|
+
const omittedExistingCount = existingReplyPartCount - retainedExistingCount;
|
|
3581
|
+
return [...retainedExistingParts, {
|
|
3582
|
+
type: "failure",
|
|
3583
|
+
id: nextSyntheticReplyPartId(REPLY_PARTS_TRUNCATED_ID, ids),
|
|
3584
|
+
code: "unsupported_destination",
|
|
3585
|
+
message: `The web console retained ${retainedExistingCount} existing rich reply parts, omitted ${omittedExistingCount} existing parts, and rejected an invalid incoming rich reply collection; ${availableSlots} of ${MAX_AGENT_REPLY_PARTS} outcome slots were available before this bounded diagnostic.`,
|
|
3586
|
+
}];
|
|
3587
|
+
}
|
|
3588
|
+
// Object.values visits only populated entries, so a legacy sparse array with
|
|
3589
|
+
// a very large length cannot make this collision scan walk every empty slot.
|
|
3590
|
+
const populated = Object.values(inputParts);
|
|
3591
|
+
const inputIdCounts = replyPartIdCounts(populated);
|
|
3592
|
+
for (const id of inputIdCounts.keys())
|
|
3593
|
+
ids.add(id);
|
|
3594
|
+
const retainedCount = Math.min(inputParts.length, Math.max(0, MAX_AGENT_REPLY_PARTS - retainedExistingCount - (needsDiagnostic ? 1 : 0)));
|
|
3595
|
+
const retained = Array.from({ length: retainedCount }, (_, index) => {
|
|
3596
|
+
const converted = toWebReplyPart(inputParts[index], () => {
|
|
3597
|
+
const inputId = record(inputParts[index])?.id;
|
|
3598
|
+
return validRichId(inputId)
|
|
3599
|
+
&& !existingIds.has(inputId)
|
|
3600
|
+
&& inputIdCounts.get(inputId) === 1
|
|
3601
|
+
? inputId
|
|
3602
|
+
: nextSyntheticReplyPartId(INVALID_REPLY_PART_ID, ids);
|
|
3603
|
+
});
|
|
3604
|
+
if (!claimedIds.has(converted.id)) {
|
|
3605
|
+
claimedIds.add(converted.id);
|
|
3606
|
+
return converted;
|
|
3607
|
+
}
|
|
3608
|
+
const collision = {
|
|
3609
|
+
type: "failure",
|
|
3610
|
+
id: nextSyntheticReplyPartId(INVALID_REPLY_PART_ID, ids),
|
|
3611
|
+
code: "unsupported_destination",
|
|
3612
|
+
message: "A rich reply part reused an existing identifier and could not be displayed.",
|
|
3613
|
+
};
|
|
3614
|
+
claimedIds.add(collision.id);
|
|
3615
|
+
return collision;
|
|
3616
|
+
});
|
|
3617
|
+
const merged = [...retainedExistingParts, ...retained];
|
|
3618
|
+
if (!needsDiagnostic)
|
|
3619
|
+
return merged;
|
|
3620
|
+
const omittedExistingCount = existingReplyPartCount - retainedExistingCount;
|
|
3621
|
+
const omittedIncomingCount = inputParts.length - retainedCount;
|
|
3622
|
+
merged.push({
|
|
3623
|
+
type: "failure",
|
|
3624
|
+
id: nextSyntheticReplyPartId(REPLY_PARTS_TRUNCATED_ID, ids),
|
|
3625
|
+
code: "reply_part_too_large",
|
|
3626
|
+
message: `The web console retained ${retainedExistingCount} existing and ${retainedCount} incoming rich reply parts, omitted ${omittedExistingCount} existing and ${omittedIncomingCount} incoming parts, and used one diagnostic slot; before reserving it, ${availableSlots} of ${MAX_AGENT_REPLY_PARTS} outcome slots were available to incoming parts.`,
|
|
3627
|
+
});
|
|
3628
|
+
return merged;
|
|
3629
|
+
}
|
|
3630
|
+
function isMcpAppProtocolVersion(value) {
|
|
3631
|
+
return value === "2026-01-26" || value === "2025-11-21";
|
|
3632
|
+
}
|
|
3633
|
+
function toWebReplyPart(input, syntheticId) {
|
|
3634
|
+
const part = record(input);
|
|
3635
|
+
const failure = (code) => ({
|
|
3636
|
+
type: "failure",
|
|
3637
|
+
id: syntheticId(),
|
|
3638
|
+
code,
|
|
3639
|
+
message: code === "artifact_too_large"
|
|
3640
|
+
? "The generated file exceeded the web console attachment limit."
|
|
3641
|
+
: code === "app_resource_invalid"
|
|
3642
|
+
? "The MCP App metadata was invalid and could not be displayed."
|
|
3643
|
+
: "The generated file metadata was invalid and could not be displayed.",
|
|
3644
|
+
});
|
|
3645
|
+
if (part?.type === "attachment") {
|
|
3646
|
+
const reference = record(part.reference);
|
|
3647
|
+
if (!validRichId(part.id)
|
|
3648
|
+
|| reference?.scheme !== "mono-agent-artifact"
|
|
3649
|
+
|| !validRichId(reference.id)
|
|
3650
|
+
|| typeof part.name !== "string"
|
|
3651
|
+
|| part.name.length === 0
|
|
3652
|
+
|| part.name.length > 255
|
|
3653
|
+
|| /[\u0000-\u001f\u007f/\\]/u.test(part.name)
|
|
3654
|
+
|| typeof part.mediaType !== "string"
|
|
3655
|
+
|| !validReplyMimeType(part.mediaType)
|
|
3656
|
+
|| !Number.isSafeInteger(part.sizeBytes)
|
|
3657
|
+
|| Number(part.sizeBytes) < 0
|
|
3658
|
+
|| typeof part.integrityId !== "string"
|
|
3659
|
+
|| !/^sha256:[0-9a-f]{64}$/u.test(part.integrityId)
|
|
3660
|
+
|| !validOptionalDate(part.expiresAt))
|
|
3661
|
+
return failure("artifact_publish_failed");
|
|
3662
|
+
if (Number(part.sizeBytes) > 20 * 1024 * 1024)
|
|
3663
|
+
return failure("artifact_too_large");
|
|
3664
|
+
return {
|
|
3665
|
+
type: "attachment",
|
|
3666
|
+
id: part.id,
|
|
3667
|
+
artifactId: reference.id,
|
|
3668
|
+
name: part.name,
|
|
3669
|
+
mediaType: part.mediaType,
|
|
3670
|
+
sizeBytes: part.sizeBytes,
|
|
3671
|
+
integrityId: part.integrityId,
|
|
3672
|
+
...(part.expiresAt === undefined ? {} : { expiresAt: part.expiresAt }),
|
|
3673
|
+
};
|
|
3674
|
+
}
|
|
3675
|
+
if (part?.type === "mcp_app") {
|
|
3676
|
+
if (!validRichId(part.id)
|
|
3677
|
+
|| part.invocationId !== part.id
|
|
3678
|
+
|| !validRichId(part.invocationId)
|
|
3679
|
+
|| !validRichId(part.connectionId)
|
|
3680
|
+
|| typeof part.serverName !== "string"
|
|
3681
|
+
|| typeof part.toolName !== "string"
|
|
3682
|
+
|| typeof part.resourceUri !== "string"
|
|
3683
|
+
|| !part.resourceUri.startsWith("ui://")
|
|
3684
|
+
|| part.mediaType !== "text/html;profile=mcp-app"
|
|
3685
|
+
|| !isMcpAppProtocolVersion(part.protocolVersion)
|
|
3686
|
+
|| !validOptionalBoundedText(part.title, 240)
|
|
3687
|
+
|| !validOptionalBoundedText(part.description, 1_000)
|
|
3688
|
+
|| !validOptionalDate(part.expiresAt))
|
|
3689
|
+
return failure("app_resource_invalid");
|
|
3690
|
+
return {
|
|
3691
|
+
type: "mcp_app",
|
|
3692
|
+
id: part.id,
|
|
3693
|
+
invocationId: part.invocationId,
|
|
3694
|
+
connectionId: part.connectionId,
|
|
3695
|
+
serverName: part.serverName.slice(0, 256),
|
|
3696
|
+
toolName: part.toolName.slice(0, 256),
|
|
3697
|
+
resourceUri: part.resourceUri.slice(0, 4_096),
|
|
3698
|
+
mediaType: "text/html;profile=mcp-app",
|
|
3699
|
+
protocolVersion: part.protocolVersion,
|
|
3700
|
+
...(part.title === undefined ? {} : { title: part.title }),
|
|
3701
|
+
...(part.description === undefined ? {} : { description: part.description }),
|
|
3702
|
+
...(part.expiresAt === undefined ? {} : { expiresAt: part.expiresAt }),
|
|
3703
|
+
};
|
|
3704
|
+
}
|
|
3705
|
+
if (part?.type === "failure"
|
|
3706
|
+
&& validRichId(part.id)
|
|
3707
|
+
&& typeof part.code === "string"
|
|
3708
|
+
&& REPLY_FAILURE_CODES.has(part.code)
|
|
3709
|
+
&& typeof part.message === "string"
|
|
3710
|
+
&& Buffer.byteLength(part.message, "utf8") <= 1_024
|
|
3711
|
+
&& (part.relatedPartId === undefined || validRichId(part.relatedPartId))) {
|
|
3712
|
+
return {
|
|
3713
|
+
type: "failure",
|
|
3714
|
+
id: part.id,
|
|
3715
|
+
code: part.code,
|
|
3716
|
+
message: part.message,
|
|
3717
|
+
...(part.relatedPartId === undefined ? {} : { relatedPartId: part.relatedPartId }),
|
|
3718
|
+
};
|
|
3719
|
+
}
|
|
3720
|
+
return {
|
|
3721
|
+
type: "failure",
|
|
3722
|
+
id: syntheticId(),
|
|
3723
|
+
code: "unsupported_destination",
|
|
3724
|
+
message: "A rich reply part used an unsupported format and could not be displayed.",
|
|
3725
|
+
};
|
|
3726
|
+
}
|
|
3727
|
+
/** Persist only the inert durable projection; browser capabilities are DTO-only. */
|
|
3728
|
+
function durableMessagePart(part) {
|
|
3729
|
+
if (part.type === "attachment") {
|
|
3730
|
+
return {
|
|
3731
|
+
type: "attachment",
|
|
3732
|
+
id: part.id,
|
|
3733
|
+
artifactId: part.artifactId,
|
|
3734
|
+
name: part.name,
|
|
3735
|
+
mediaType: part.mediaType,
|
|
3736
|
+
sizeBytes: part.sizeBytes,
|
|
3737
|
+
integrityId: part.integrityId,
|
|
3738
|
+
...(part.expiresAt === undefined ? {} : { expiresAt: part.expiresAt }),
|
|
3739
|
+
};
|
|
3740
|
+
}
|
|
3741
|
+
if (part.type === "mcp_app") {
|
|
3742
|
+
return {
|
|
3743
|
+
type: "mcp_app",
|
|
3744
|
+
id: part.id,
|
|
3745
|
+
invocationId: part.invocationId,
|
|
3746
|
+
connectionId: part.connectionId,
|
|
3747
|
+
serverName: part.serverName,
|
|
3748
|
+
toolName: part.toolName,
|
|
3749
|
+
resourceUri: part.resourceUri,
|
|
3750
|
+
mediaType: part.mediaType,
|
|
3751
|
+
protocolVersion: part.protocolVersion,
|
|
3752
|
+
...(part.title === undefined ? {} : { title: part.title }),
|
|
3753
|
+
...(part.description === undefined ? {} : { description: part.description }),
|
|
3754
|
+
...(part.expiresAt === undefined ? {} : { expiresAt: part.expiresAt }),
|
|
3755
|
+
};
|
|
3756
|
+
}
|
|
3757
|
+
if (part.type === "failure") {
|
|
3758
|
+
return {
|
|
3759
|
+
type: "failure",
|
|
3760
|
+
id: part.id,
|
|
3761
|
+
code: part.code,
|
|
3762
|
+
message: part.message,
|
|
3763
|
+
...(part.relatedPartId === undefined ? {} : { relatedPartId: part.relatedPartId }),
|
|
3764
|
+
};
|
|
3765
|
+
}
|
|
3766
|
+
return part;
|
|
3767
|
+
}
|
|
3768
|
+
function serializeParts(parts) {
|
|
3769
|
+
return JSON.stringify(parts.map(durableMessagePart));
|
|
3770
|
+
}
|
|
3771
|
+
function validRichId(value) {
|
|
3772
|
+
return typeof value === "string"
|
|
3773
|
+
&& value.length > 0
|
|
3774
|
+
&& Buffer.byteLength(value, "utf8") <= 256
|
|
3775
|
+
&& !/[\u0000-\u001f\u007f]/u.test(value);
|
|
3776
|
+
}
|
|
3777
|
+
function validReplyMimeType(value) {
|
|
3778
|
+
return value.length <= 256
|
|
3779
|
+
&& /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*(?:;[a-z0-9=._+-]+)*$/iu.test(value);
|
|
3780
|
+
}
|
|
3781
|
+
function validOptionalDate(value) {
|
|
3782
|
+
return value === undefined || (typeof value === "string" && !Number.isNaN(Date.parse(value)));
|
|
3783
|
+
}
|
|
3784
|
+
function validOptionalBoundedText(value, maxBytes) {
|
|
3785
|
+
return value === undefined || (typeof value === "string" && Buffer.byteLength(value, "utf8") <= maxBytes);
|
|
3786
|
+
}
|
|
2150
3787
|
function parseParts(value) {
|
|
2151
3788
|
let parsed;
|
|
2152
3789
|
try {
|
|
@@ -2155,11 +3792,14 @@ function parseParts(value) {
|
|
|
2155
3792
|
catch {
|
|
2156
3793
|
throw new WebConsoleError("storage_corrupt", "Persisted message parts are not valid JSON.", 500);
|
|
2157
3794
|
}
|
|
2158
|
-
|
|
3795
|
+
const parts = Array.isArray(parsed)
|
|
3796
|
+
? parsed.map(canonicalizePersistedPartHistory)
|
|
3797
|
+
: parsed;
|
|
3798
|
+
if (!Array.isArray(parts) || !parts.every(isWebMessagePart)) {
|
|
2159
3799
|
throw new WebConsoleError("storage_corrupt", "Persisted message parts have an invalid shape.", 500);
|
|
2160
3800
|
}
|
|
2161
|
-
quoteFromParts(
|
|
2162
|
-
return
|
|
3801
|
+
quoteFromParts(parts);
|
|
3802
|
+
return parts;
|
|
2163
3803
|
}
|
|
2164
3804
|
const QUOTE_TELEMETRY_EVENT = "quote";
|
|
2165
3805
|
const LIVE_INPUT_TELEMETRY_EVENT = "live_input";
|
|
@@ -2216,7 +3856,132 @@ function isWebToolCall(value) {
|
|
|
2216
3856
|
const call = value;
|
|
2217
3857
|
return typeof call.toolCallId === "string"
|
|
2218
3858
|
&& typeof call.toolName === "string"
|
|
2219
|
-
&& isWebToolCallStatus(call.status)
|
|
3859
|
+
&& isWebToolCallStatus(call.status)
|
|
3860
|
+
&& (call.history === undefined || isSessionToolHistoryMetadata(call.history));
|
|
3861
|
+
}
|
|
3862
|
+
const SESSION_TOOL_HISTORY_TERMINAL_STATES = new Set([
|
|
3863
|
+
"success", "rejected", "error", "exit_nonzero", "timeout", "signal", "cancelled", "interrupted",
|
|
3864
|
+
]);
|
|
3865
|
+
/**
|
|
3866
|
+
* Canonicalize the open wire record before it reaches SQLite or rendering.
|
|
3867
|
+
* Unknown fields are deliberately omitted so a future/stale producer cannot
|
|
3868
|
+
* turn this bounded display record into an unbounded persistence side channel.
|
|
3869
|
+
*/
|
|
3870
|
+
function canonicalSessionToolHistoryMetadata(value) {
|
|
3871
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
3872
|
+
return undefined;
|
|
3873
|
+
const history = value;
|
|
3874
|
+
const valid = (history.persistence === "persisted" || history.persistence === "failed")
|
|
3875
|
+
&& history.untrusted === true
|
|
3876
|
+
&& (history.recordId === undefined || boundedHistoryString(history.recordId, 4_096))
|
|
3877
|
+
&& (history.sequence === undefined || positiveSafeInteger(history.sequence))
|
|
3878
|
+
&& (history.terminalState === undefined || (typeof history.terminalState === "string"
|
|
3879
|
+
&& SESSION_TOOL_HISTORY_TERMINAL_STATES.has(history.terminalState)))
|
|
3880
|
+
&& (history.truncated === undefined || typeof history.truncated === "boolean")
|
|
3881
|
+
&& (history.originalBytes === undefined || nonNegativeSafeInteger(history.originalBytes))
|
|
3882
|
+
&& (history.retainedBytes === undefined || nonNegativeSafeInteger(history.retainedBytes))
|
|
3883
|
+
&& (history.errorCode === undefined || boundedHistoryString(history.errorCode, 256))
|
|
3884
|
+
&& (history.artifactReferences === undefined || (Array.isArray(history.artifactReferences)
|
|
3885
|
+
&& history.artifactReferences.length <= 32
|
|
3886
|
+
&& history.artifactReferences.every((entry) => {
|
|
3887
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry))
|
|
3888
|
+
return false;
|
|
3889
|
+
const artifact = entry;
|
|
3890
|
+
return boundedHistoryString(artifact.id, 4_096) && typeof artifact.available === "boolean";
|
|
3891
|
+
})));
|
|
3892
|
+
if (!valid)
|
|
3893
|
+
return undefined;
|
|
3894
|
+
return {
|
|
3895
|
+
persistence: history.persistence,
|
|
3896
|
+
untrusted: true,
|
|
3897
|
+
...(history.recordId === undefined ? {} : { recordId: history.recordId }),
|
|
3898
|
+
...(history.sequence === undefined ? {} : { sequence: history.sequence }),
|
|
3899
|
+
...(history.terminalState === undefined
|
|
3900
|
+
? {}
|
|
3901
|
+
: { terminalState: history.terminalState }),
|
|
3902
|
+
...(history.truncated === undefined ? {} : { truncated: history.truncated }),
|
|
3903
|
+
...(history.originalBytes === undefined ? {} : { originalBytes: history.originalBytes }),
|
|
3904
|
+
...(history.retainedBytes === undefined ? {} : { retainedBytes: history.retainedBytes }),
|
|
3905
|
+
...(history.errorCode === undefined ? {} : { errorCode: history.errorCode }),
|
|
3906
|
+
...(history.artifactReferences === undefined
|
|
3907
|
+
? {}
|
|
3908
|
+
: {
|
|
3909
|
+
artifactReferences: history.artifactReferences.map((entry) => {
|
|
3910
|
+
const artifact = entry;
|
|
3911
|
+
return { id: artifact.id, available: artifact.available };
|
|
3912
|
+
}),
|
|
3913
|
+
}),
|
|
3914
|
+
};
|
|
3915
|
+
}
|
|
3916
|
+
function isSessionToolHistoryMetadata(value) {
|
|
3917
|
+
return canonicalSessionToolHistoryMetadata(value) !== undefined;
|
|
3918
|
+
}
|
|
3919
|
+
/** An explicitly supplied invalid frame replaces, rather than retaining, stale metadata. */
|
|
3920
|
+
function withSessionToolHistory(value, history) {
|
|
3921
|
+
const { history: _staleHistory, ...withoutHistory } = value;
|
|
3922
|
+
return {
|
|
3923
|
+
...withoutHistory,
|
|
3924
|
+
...(history === undefined ? {} : { history }),
|
|
3925
|
+
};
|
|
3926
|
+
}
|
|
3927
|
+
/**
|
|
3928
|
+
* Older builds could persist an unchecked history record. Salvage the otherwise
|
|
3929
|
+
* valid message by dropping only that optional record; unrelated corruption
|
|
3930
|
+
* remains a storage error.
|
|
3931
|
+
*/
|
|
3932
|
+
function canonicalizePersistedPartHistory(value) {
|
|
3933
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
3934
|
+
return value;
|
|
3935
|
+
const part = value;
|
|
3936
|
+
if (part.type === "tool-call")
|
|
3937
|
+
return canonicalizePersistedHistoryRecord(part);
|
|
3938
|
+
if (part.type !== "subagent")
|
|
3939
|
+
return value;
|
|
3940
|
+
const canonicalPart = canonicalizePersistedObjectHistory(part);
|
|
3941
|
+
return {
|
|
3942
|
+
...canonicalPart,
|
|
3943
|
+
...(Array.isArray(part.calls)
|
|
3944
|
+
? { calls: part.calls.map((call) => canonicalizePersistedHistoryRecord(call)) }
|
|
3945
|
+
: {}),
|
|
3946
|
+
};
|
|
3947
|
+
}
|
|
3948
|
+
function canonicalizePersistedHistoryRecord(value) {
|
|
3949
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
3950
|
+
return value;
|
|
3951
|
+
return canonicalizePersistedObjectHistory(value);
|
|
3952
|
+
}
|
|
3953
|
+
function canonicalizePersistedObjectHistory(record) {
|
|
3954
|
+
const { history: rawHistory, ...withoutHistory } = record;
|
|
3955
|
+
const history = canonicalSessionToolHistoryMetadata(rawHistory);
|
|
3956
|
+
return {
|
|
3957
|
+
...withoutHistory,
|
|
3958
|
+
...(history === undefined ? {} : { history }),
|
|
3959
|
+
};
|
|
3960
|
+
}
|
|
3961
|
+
function boundedHistoryString(value, maxBytes) {
|
|
3962
|
+
return typeof value === "string"
|
|
3963
|
+
&& value.length > 0
|
|
3964
|
+
&& Buffer.byteLength(value, "utf8") <= maxBytes
|
|
3965
|
+
&& !/[\u0000-\u001f\u007f]/u.test(value);
|
|
3966
|
+
}
|
|
3967
|
+
function positiveSafeInteger(value) {
|
|
3968
|
+
return Number.isSafeInteger(value) && Number(value) > 0;
|
|
3969
|
+
}
|
|
3970
|
+
function nonNegativeSafeInteger(value) {
|
|
3971
|
+
return Number.isSafeInteger(value) && Number(value) >= 0;
|
|
3972
|
+
}
|
|
3973
|
+
const DURABLE_REPLY_ATTACHMENT_KEYS = new Set([
|
|
3974
|
+
"type", "id", "artifactId", "name", "mediaType", "sizeBytes", "integrityId", "expiresAt",
|
|
3975
|
+
]);
|
|
3976
|
+
const DURABLE_MCP_APP_KEYS = new Set([
|
|
3977
|
+
"type", "id", "invocationId", "connectionId", "serverName", "toolName", "resourceUri",
|
|
3978
|
+
"mediaType", "protocolVersion", "title", "description", "expiresAt",
|
|
3979
|
+
]);
|
|
3980
|
+
const DURABLE_REPLY_FAILURE_KEYS = new Set([
|
|
3981
|
+
"type", "id", "code", "message", "relatedPartId",
|
|
3982
|
+
]);
|
|
3983
|
+
function hasOnlyKeys(value, allowed) {
|
|
3984
|
+
return Object.keys(value).every((key) => allowed.has(key));
|
|
2220
3985
|
}
|
|
2221
3986
|
function isWebMessagePart(value) {
|
|
2222
3987
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
@@ -2232,14 +3997,133 @@ function isWebMessagePart(value) {
|
|
|
2232
3997
|
&& (part.label === undefined || typeof part.label === "string")
|
|
2233
3998
|
&& (part.executionMs === undefined || typeof part.executionMs === "number")
|
|
2234
3999
|
&& (part.costUsd === undefined || typeof part.costUsd === "number")
|
|
4000
|
+
&& (part.history === undefined || isSessionToolHistoryMetadata(part.history))
|
|
2235
4001
|
&& isWebToolCallStatus(part.status)
|
|
2236
4002
|
&& Array.isArray(part.calls)
|
|
2237
4003
|
&& part.calls.every(isWebToolCall);
|
|
2238
4004
|
}
|
|
4005
|
+
if (part.type === "process-job") {
|
|
4006
|
+
if (part.responseText !== undefined && (typeof part.responseText !== "string" || part.responseText.length > 8_000)) {
|
|
4007
|
+
return false;
|
|
4008
|
+
}
|
|
4009
|
+
try {
|
|
4010
|
+
parseProcessJobProjection(part.job);
|
|
4011
|
+
return Object.keys(part).every((key) => key === "type" || key === "job" || key === "responseText")
|
|
4012
|
+
&& Object.keys(part).length === (part.responseText === undefined ? 2 : 3);
|
|
4013
|
+
}
|
|
4014
|
+
catch {
|
|
4015
|
+
return false;
|
|
4016
|
+
}
|
|
4017
|
+
}
|
|
2239
4018
|
if (part.type === "telemetry")
|
|
2240
4019
|
return typeof part.event === "string";
|
|
2241
4020
|
if (part.type === "error")
|
|
2242
4021
|
return typeof part.message === "string" && (part.code === undefined || typeof part.code === "string");
|
|
4022
|
+
if (part.type === "attachment") {
|
|
4023
|
+
return hasOnlyKeys(part, DURABLE_REPLY_ATTACHMENT_KEYS)
|
|
4024
|
+
&& validRichId(part.id)
|
|
4025
|
+
&& validRichId(part.artifactId)
|
|
4026
|
+
&& typeof part.name === "string"
|
|
4027
|
+
&& part.name.length > 0
|
|
4028
|
+
&& !/[\u0000-\u001f\u007f/\\]/u.test(part.name)
|
|
4029
|
+
&& typeof part.mediaType === "string"
|
|
4030
|
+
&& validReplyMimeType(part.mediaType)
|
|
4031
|
+
&& Number.isSafeInteger(part.sizeBytes)
|
|
4032
|
+
&& Number(part.sizeBytes) >= 0
|
|
4033
|
+
&& Number(part.sizeBytes) <= 20 * 1024 * 1024
|
|
4034
|
+
&& typeof part.integrityId === "string"
|
|
4035
|
+
&& /^sha256:[0-9a-f]{64}$/u.test(part.integrityId)
|
|
4036
|
+
&& validOptionalDate(part.expiresAt);
|
|
4037
|
+
}
|
|
4038
|
+
if (part.type === "mcp_app") {
|
|
4039
|
+
return hasOnlyKeys(part, DURABLE_MCP_APP_KEYS)
|
|
4040
|
+
&& validRichId(part.id)
|
|
4041
|
+
&& part.invocationId === part.id
|
|
4042
|
+
&& validRichId(part.connectionId)
|
|
4043
|
+
&& typeof part.serverName === "string"
|
|
4044
|
+
&& typeof part.toolName === "string"
|
|
4045
|
+
&& typeof part.resourceUri === "string"
|
|
4046
|
+
&& part.resourceUri.startsWith("ui://")
|
|
4047
|
+
&& part.mediaType === "text/html;profile=mcp-app"
|
|
4048
|
+
&& isMcpAppProtocolVersion(part.protocolVersion)
|
|
4049
|
+
&& validOptionalBoundedText(part.title, 240)
|
|
4050
|
+
&& validOptionalBoundedText(part.description, 1_000)
|
|
4051
|
+
&& validOptionalDate(part.expiresAt);
|
|
4052
|
+
}
|
|
4053
|
+
if (part.type === "failure") {
|
|
4054
|
+
return hasOnlyKeys(part, DURABLE_REPLY_FAILURE_KEYS)
|
|
4055
|
+
&& validRichId(part.id)
|
|
4056
|
+
&& typeof part.code === "string"
|
|
4057
|
+
&& REPLY_FAILURE_CODES.has(part.code)
|
|
4058
|
+
&& typeof part.message === "string"
|
|
4059
|
+
&& Buffer.byteLength(part.message, "utf8") <= 1_024
|
|
4060
|
+
&& (part.relatedPartId === undefined || validRichId(part.relatedPartId));
|
|
4061
|
+
}
|
|
4062
|
+
return false;
|
|
4063
|
+
}
|
|
4064
|
+
function processJobPart(job, responseText) {
|
|
4065
|
+
return {
|
|
4066
|
+
type: "process-job",
|
|
4067
|
+
job,
|
|
4068
|
+
...(responseText === undefined ? {} : { responseText }),
|
|
4069
|
+
};
|
|
4070
|
+
}
|
|
4071
|
+
function processJobCardParts(job, responseText, replyParts) {
|
|
4072
|
+
const card = processJobPart(job, responseText);
|
|
4073
|
+
return replyParts === undefined ? [card] : boundedWebReplyParts(replyParts, [card]);
|
|
4074
|
+
}
|
|
4075
|
+
function isTerminalJobState(state) {
|
|
4076
|
+
return state === "succeeded"
|
|
4077
|
+
|| state === "failed"
|
|
4078
|
+
|| state === "timed_out"
|
|
4079
|
+
|| state === "cancelled"
|
|
4080
|
+
|| state === "spawn_failed"
|
|
4081
|
+
|| state === "queue_expired"
|
|
4082
|
+
|| state === "interrupted";
|
|
4083
|
+
}
|
|
4084
|
+
function assertProcessJobCardTransition(previous, next) {
|
|
4085
|
+
if (previous.tool !== next.tool
|
|
4086
|
+
|| previous.summary !== next.summary
|
|
4087
|
+
|| previous.origin.conversationId !== next.origin.conversationId
|
|
4088
|
+
|| previous.origin.channel !== next.origin.channel
|
|
4089
|
+
|| previous.origin.runId !== next.origin.runId
|
|
4090
|
+
|| previous.origin.historyBoundary !== next.origin.historyBoundary
|
|
4091
|
+
|| previous.origin.bucket !== next.origin.bucket
|
|
4092
|
+
|| previous.timestamps.admittedAt !== next.timestamps.admittedAt
|
|
4093
|
+
|| previous.timestamps.queueDeadlineAt !== next.timestamps.queueDeadlineAt
|
|
4094
|
+
|| JSON.stringify(previous.limits) !== JSON.stringify(next.limits)
|
|
4095
|
+
|| previous.wake.deliveryKey !== next.wake.deliveryKey) {
|
|
4096
|
+
throw new WebConsoleError("notification_idempotency_conflict", "The process-job projection changed immutable identity fields.", 409);
|
|
4097
|
+
}
|
|
4098
|
+
if (!allowedProcessJobTransition(previous.state, next.state)) {
|
|
4099
|
+
throw new WebConsoleError("notification_idempotency_conflict", `Process-job card lifecycle cannot transition from ${previous.state} to ${next.state}.`, 409);
|
|
4100
|
+
}
|
|
4101
|
+
if ((previous.timestamps.startedAt !== null && previous.timestamps.startedAt !== next.timestamps.startedAt)
|
|
4102
|
+
|| (previous.timestamps.runtimeDeadlineAt !== null
|
|
4103
|
+
&& previous.timestamps.runtimeDeadlineAt !== next.timestamps.runtimeDeadlineAt)
|
|
4104
|
+
|| (previous.timestamps.completedAt !== null && previous.timestamps.completedAt !== next.timestamps.completedAt)) {
|
|
4105
|
+
throw new WebConsoleError("notification_idempotency_conflict", "Process-job card timing changed after becoming durable.", 409);
|
|
4106
|
+
}
|
|
4107
|
+
if (next.wake.attempts < previous.wake.attempts
|
|
4108
|
+
|| (previous.wake.state !== "pending" && previous.wake.state !== next.wake.state)) {
|
|
4109
|
+
throw new WebConsoleError("notification_idempotency_conflict", "Process-job wake settlement cannot move backwards.", 409);
|
|
4110
|
+
}
|
|
4111
|
+
}
|
|
4112
|
+
function allowedProcessJobTransition(previous, next) {
|
|
4113
|
+
if (previous === next)
|
|
4114
|
+
return true;
|
|
4115
|
+
if (previous === "queued") {
|
|
4116
|
+
// Retained surfaces are sampled; a fast launch/completion may skip one or
|
|
4117
|
+
// both internal nonterminal states between card updates.
|
|
4118
|
+
return next === "starting" || next === "running" || isTerminalJobState(next);
|
|
4119
|
+
}
|
|
4120
|
+
if (previous === "starting") {
|
|
4121
|
+
return next === "running" || (isTerminalJobState(next) && next !== "queue_expired");
|
|
4122
|
+
}
|
|
4123
|
+
if (previous === "running") {
|
|
4124
|
+
return next === "succeeded" || next === "failed" || next === "timed_out"
|
|
4125
|
+
|| next === "cancelled" || next === "spawn_failed" || next === "interrupted";
|
|
4126
|
+
}
|
|
2243
4127
|
return false;
|
|
2244
4128
|
}
|
|
2245
4129
|
function parseStringArray(value) {
|