@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.
Files changed (54) hide show
  1. package/README.md +123 -21
  2. package/dist/contracts.d.ts +115 -4
  3. package/dist/contracts.d.ts.map +1 -1
  4. package/dist/contracts.js +1 -1
  5. package/dist/contracts.js.map +1 -1
  6. package/dist/discovery.d.ts +1 -0
  7. package/dist/discovery.d.ts.map +1 -1
  8. package/dist/discovery.js +43 -1
  9. package/dist/discovery.js.map +1 -1
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/mcp-app-document.d.ts +15 -0
  14. package/dist/mcp-app-document.d.ts.map +1 -0
  15. package/dist/mcp-app-document.js +42 -0
  16. package/dist/mcp-app-document.js.map +1 -0
  17. package/dist/mcp-app-proxy.d.ts +20 -0
  18. package/dist/mcp-app-proxy.d.ts.map +1 -0
  19. package/dist/mcp-app-proxy.js +321 -0
  20. package/dist/mcp-app-proxy.js.map +1 -0
  21. package/dist/notification-client.d.ts +18 -4
  22. package/dist/notification-client.d.ts.map +1 -1
  23. package/dist/notification-client.js +13 -4
  24. package/dist/notification-client.js.map +1 -1
  25. package/dist/notification-ingress.d.ts +19 -0
  26. package/dist/notification-ingress.d.ts.map +1 -1
  27. package/dist/notification-ingress.js +68 -6
  28. package/dist/notification-ingress.js.map +1 -1
  29. package/dist/operator-client.d.ts +51 -2
  30. package/dist/operator-client.d.ts.map +1 -1
  31. package/dist/operator-client.js +349 -2
  32. package/dist/operator-client.js.map +1 -1
  33. package/dist/push.d.ts +3 -0
  34. package/dist/push.d.ts.map +1 -1
  35. package/dist/push.js +11 -3
  36. package/dist/push.js.map +1 -1
  37. package/dist/server.d.ts.map +1 -1
  38. package/dist/server.js +244 -0
  39. package/dist/server.js.map +1 -1
  40. package/dist/service.d.ts +81 -5
  41. package/dist/service.d.ts.map +1 -1
  42. package/dist/service.js +401 -30
  43. package/dist/service.js.map +1 -1
  44. package/dist/store.d.ts +79 -7
  45. package/dist/store.d.ts.map +1 -1
  46. package/dist/store.js +2046 -162
  47. package/dist/store.js.map +1 -1
  48. package/package.json +4 -4
  49. package/webapp/dist/assets/index-CNY5_en5.css +1 -0
  50. package/webapp/dist/assets/index-Dl-iLQzF.js +152 -0
  51. package/webapp/dist/index.html +2 -2
  52. package/webapp/dist/sw.js +1 -1
  53. package/webapp/dist/assets/index-DBk-VjNE.css +0 -1
  54. 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 { AGENT_LIVE_INPUT_MAX_CHARACTERS, } from "@mono-agent/agent-contracts";
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 = 4;
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, updated_at
69
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
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
- const threadId = notificationThreadId(input.sourceId, input.deliveryKey);
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
- if (existing.thread_id !== threadId
126
- || existing.trigger_kind !== input.triggerKind
127
- || existing.payload_sha256 !== payloadSha256) {
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 { ...input, threadId, payloadSha256, duplicate: existing.completed_at !== null };
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, payload_sha256, created_at, completed_at
139
- ) VALUES (?, ?, ?, ?, ?, ?, NULL)
140
- `).run(input.sourceId, input.deliveryKey, threadId, input.triggerKind, payloadSha256, now);
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
- const turnId = randomUUID();
158
- const assistantMessageId = randomUUID();
159
- const title = reservation.triggerKind === "cron" ? "Cron notification" : "Webhook notification";
243
+ let turnId = randomUUID();
244
+ let assistantMessageId = randomUUID();
245
+ let completedThreadId = existing.thread_id;
160
246
  this.transaction(() => {
161
- if (this.getThread(reservation.threadId) !== undefined) {
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
- INSERT INTO threads (
166
- id, source_id, conversation_id, title, title_manual, trigger_kind, archived_at,
167
- created_at, updated_at, revision
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: `web-new:${reservation.threadId}`,
323
+ logicalKey: notificationPushLogicalKey(reservation.sourceId, reservation.deliveryKey),
192
324
  kind: "response.ready",
193
- threadId: reservation.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(reservation.threadId), duplicate: false };
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("ORDER BY t.updated_at DESC, t.id")).all();
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 row = this.database.prepare(threadSelectSql("WHERE t.id = ?")).get(id);
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 thread = this.getThread(id);
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.* FROM messages 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 COALESCE(t.started_at, m.created_at),
240
- CASE WHEN m.turn_id IS NOT NULL AND m.role = 'user' THEN 0
241
- WHEN m.turn_id IS NOT NULL AND m.role = 'system' THEN 1
242
- WHEN m.turn_id IS NOT NULL THEN 2
243
- ELSE 3 END,
244
- m.created_at, m.rowid
245
- `).all(id);
246
- return { thread, messages: rows.map((row) => this.mapMessage(row)) };
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 || this.getThread(row.value) === 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
- return row.value;
966
+ if (resolved !== row.value)
967
+ this.setSetting("current_thread_id", resolved);
968
+ return resolved;
253
969
  }
254
970
  selectThread(id) {
255
- this.requireThread(id);
256
- this.setSetting("current_thread_id", id);
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
- this.database.prepare("DELETE FROM notification_deliveries WHERE thread_id = ?").run(id);
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 thread = this.requireThread(input.threadId);
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(input.threadId);
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, input.threadId);
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, input.threadId, input.text, input.model ?? null, input.effort ?? null, assistantMessageId, now);
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, input.threadId, turnId, JSON.stringify(userParts), now, now);
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, input.threadId, turnId, now, now);
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(input.threadId, userMessageId, now, attachment.id);
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, input.threadId);
493
- this.recordThreadRevision(input.threadId, "turn_started", now);
494
- this.setSetting("current_thread_id", input.threadId);
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:${input.threadId}`,
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(input.threadId),
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, JSON.stringify(parts), now, now);
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(JSON.stringify(withLiveInputStatus(message.parts, "applied")), now, row.message_id);
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(JSON.stringify(withLiveInputStatus(message.parts, "queued")), now, row.message_id);
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(JSON.stringify(withLiveInputStatus(message.parts, "cancelled")), now, row.message_id);
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(JSON.stringify(withLiveInputStatus(message.parts, "cancelled")), now, row.message_id);
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, JSON.stringify(withoutLiveInputTelemetry(userMessage.parts)), now, row.message_id);
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(JSON.stringify(parts), now, message.id);
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 NOT NULL UNIQUE,
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(JSON.stringify(withLiveInputStatus(parseParts(persisted.parts_json), "queued")), now, row.message_id);
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
- const parts = [...existing.parts];
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.transaction(() => {
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
- `).run(status, now, errorCode ?? null, errorMessage ?? null, runtime?.model ?? null, runtime?.model ?? null, runtime?.effort ?? null, runtime?.effort ?? null, turnId);
1343
- this.database.prepare("UPDATE messages SET parts_json = ?, status = ?, updated_at = ? WHERE id = ?")
1344
- .run(JSON.stringify(parts), status, now, existing.id);
1345
- this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
1346
- .run(now, turn.thread_id);
1347
- this.recordThreadRevision(turn.thread_id, `turn_${status}`, now);
1348
- const recentEnoughForRecoveredInterruption = status !== "interrupted"
1349
- || new Date(now).getTime() - new Date(existing.updatedAt).getTime() <= 60 * 60 * 1_000;
1350
- if (recentEnoughForRecoveredInterruption) {
1351
- const kind = status === "complete"
1352
- ? "response.ready"
1353
- : status === "cancelled"
1354
- ? "run.cancelled"
1355
- : status === "interrupted"
1356
- ? "run.interrupted"
1357
- : "run.failed";
1358
- const label = agent?.label ?? "mono-agent";
1359
- const body = status === "complete"
1360
- ? parts.filter((part) => part.type === "text")
1361
- .map((part) => part.text)
1362
- .join(" ")
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
- ? "The run was cancelled."
2386
+ ? `${label} run cancelled`
1365
2387
  : status === "interrupted"
1366
- ? "The run was interrupted when the web service stopped."
1367
- : errorMessage ?? "The run failed.";
1368
- this.enqueueWebPushEventInTransaction({
1369
- logicalKey: `turn:${turnId}:terminal`,
1370
- kind,
1371
- threadId: turn.thread_id,
1372
- sourceId: thread.sourceId,
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" || row.trigger_kind === "webhook"
1400
- ? { trigger: { kind: row.trigger_kind } }
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
- CASE WHEN a.status = 'online' OR a.status = 'degraded' THEN 1 ELSE 0 END AS can_send,
1688
- CASE WHEN (a.status = 'online' OR a.status = 'degraded') AND a.supports_attachments = 1 THEN 1 ELSE 0 END AS can_upload,
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 !== true) {
1794
- upsertSubagentCall(parts, group, {
1795
- toolCallId: event.id,
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
- status: "running",
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, next]
1985
- : group.calls.map((call, at) => at === index ? { ...call, ...next } : call);
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(next);
3345
+ parts.push(updated);
1992
3346
  return;
1993
3347
  }
1994
- const previous = parts[index];
1995
3348
  if (previous?.type === "tool-call")
1996
- parts[index] = { ...previous, ...next };
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
- if (!Array.isArray(parsed) || !parsed.every(isWebMessagePart)) {
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(parsed);
2162
- return parsed;
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) {