@mono-agent/web 0.20.14 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +388 -70
  2. package/dist/contracts.d.ts +364 -8
  3. package/dist/contracts.d.ts.map +1 -1
  4. package/dist/contracts.js +2 -0
  5. package/dist/contracts.js.map +1 -1
  6. package/dist/cron-reply-context.d.ts +27 -0
  7. package/dist/cron-reply-context.d.ts.map +1 -0
  8. package/dist/cron-reply-context.js +241 -0
  9. package/dist/cron-reply-context.js.map +1 -0
  10. package/dist/discovery.d.ts +1 -0
  11. package/dist/discovery.d.ts.map +1 -1
  12. package/dist/discovery.js +8 -6
  13. package/dist/discovery.js.map +1 -1
  14. package/dist/effort-ladder.d.ts +14 -0
  15. package/dist/effort-ladder.d.ts.map +1 -1
  16. package/dist/effort-ladder.js +41 -0
  17. package/dist/effort-ladder.js.map +1 -1
  18. package/dist/index.d.ts +2 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/long-lived-fetch.d.ts +5 -0
  23. package/dist/long-lived-fetch.d.ts.map +1 -0
  24. package/dist/long-lived-fetch.js +22 -0
  25. package/dist/long-lived-fetch.js.map +1 -0
  26. package/dist/monitor-reply.d.ts +10 -0
  27. package/dist/monitor-reply.d.ts.map +1 -0
  28. package/dist/monitor-reply.js +67 -0
  29. package/dist/monitor-reply.js.map +1 -0
  30. package/dist/notification-client.d.ts.map +1 -1
  31. package/dist/notification-client.js +5 -4
  32. package/dist/notification-client.js.map +1 -1
  33. package/dist/operator-client.d.ts +25 -1
  34. package/dist/operator-client.d.ts.map +1 -1
  35. package/dist/operator-client.js +167 -8
  36. package/dist/operator-client.js.map +1 -1
  37. package/dist/server.d.ts +42 -1
  38. package/dist/server.d.ts.map +1 -1
  39. package/dist/server.js +627 -49
  40. package/dist/server.js.map +1 -1
  41. package/dist/service.d.ts +201 -8
  42. package/dist/service.d.ts.map +1 -1
  43. package/dist/service.js +1425 -136
  44. package/dist/service.js.map +1 -1
  45. package/dist/store-migrations.d.ts +23 -0
  46. package/dist/store-migrations.d.ts.map +1 -0
  47. package/dist/store-migrations.js +212 -0
  48. package/dist/store-migrations.js.map +1 -0
  49. package/dist/store.d.ts +294 -17
  50. package/dist/store.d.ts.map +1 -1
  51. package/dist/store.js +1637 -263
  52. package/dist/store.js.map +1 -1
  53. package/package.json +8 -5
  54. package/webapp/dist/assets/{assistant-ui-BzN2E6n6.js → assistant-ui-pZmGxIp2.js} +16 -16
  55. package/webapp/dist/assets/index-6uQ7TVQe.css +1 -0
  56. package/webapp/dist/assets/index-CsSMjSgW.js +156 -0
  57. package/webapp/dist/assets/{markdown-Vq23xgh7.js → markdown-Du5t10ja.js} +1 -1
  58. package/webapp/dist/badge-96.png +0 -0
  59. package/webapp/dist/index.html +26 -6
  60. package/webapp/dist/manifest.webmanifest +1 -1
  61. package/webapp/dist/notification-sw.js +3 -1
  62. package/webapp/dist/sw.js +1 -1
  63. package/webapp/dist/{workbox-9c191d2f.js → workbox-2fbc6a65.js} +1 -1
  64. package/webapp/dist/assets/index-C4a2Dv1W.js +0 -155
  65. package/webapp/dist/assets/index-mhMBLGB0.css +0 -1
package/dist/store.js CHANGED
@@ -3,15 +3,20 @@ import { chmod, lstat, readdir, unlink } from "node:fs/promises";
3
3
  import { resolve } from "node:path";
4
4
  import { DatabaseSync } from "node:sqlite";
5
5
  import { isDeepStrictEqual } from "node:util";
6
- import { AGENT_LIVE_INPUT_MAX_CHARACTERS, AGENT_LIVE_INPUT_MAX_MESSAGES, MAX_AGENT_REPLY_PARTS, classifyNotifySuppression, parseMonitorProjection, parseProcessJobProjection, } from "@mono-agent/agent-contracts";
6
+ import { normalizeMonitorTerminalReply, hasMonitorReplyContent, monitorReplyText } from "./monitor-reply.js";
7
+ import { AGENT_CONTEXT_IMPORT_SYSTEM_PROVENANCE, AGENT_LIVE_INPUT_MAX_CHARACTERS, AGENT_LIVE_INPUT_MAX_MESSAGES, MAX_AGENT_REPLY_PARTS, classifyNotifySuppression, NOTHING_TO_REPORT_SENTINEL, parseMonitorProjection, parseProcessJobProjection, } from "@mono-agent/agent-contracts";
7
8
  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";
9
+ import { formatCronReplyContext } from "./cron-reply-context.js";
8
10
  import { WebConsoleError } from "./errors.js";
11
+ import { runWebStorageMigrations, validateWebStorageMigrationRegistry, WEB_STORAGE_SCHEMA_VERSION } from "./store-migrations.js";
9
12
  import { webPushPreview } from "./push-preview.js";
10
13
  import { prepareWebStatePaths } from "./state-paths.js";
11
14
  /**
12
15
  * The searchable text of one message row, derived in SQL so the index cannot
13
16
  * drift from `parts_json`: every write path in this store goes through the
14
- * triggers below rather than remembering to maintain a second copy.
17
+ * triggers below rather than remembering to maintain a second copy. Legacy
18
+ * Monitor history gets an association-verified projection repair at open; its
19
+ * canonical parts remain untouched.
15
20
  *
16
21
  * Only `text` parts are indexed. Reasoning is the agent's working-out and tool
17
22
  * payloads are machine JSON; both would drown a search of what was actually
@@ -118,10 +123,26 @@ export function messageSearchMatchExpression(raw) {
118
123
  export function escapeLikeTerm(raw) {
119
124
  return raw.replaceAll(/[\\%_]/gu, (character) => `\\${character}`);
120
125
  }
121
- const WEB_STORAGE_SCHEMA_VERSION = 15;
122
126
  const MAX_REVISIONS_PER_THREAD = 1_000;
123
127
  export const WEB_THREAD_PAGE_MAX = 200;
128
+ /**
129
+ * What one page is when the caller does not say.
130
+ *
131
+ * A sidebar shows a handful of conversations and pages from there, so both the
132
+ * bootstrap's bucket and the thread-list route answer with this rather than the
133
+ * whole per-bucket cap.
134
+ */
135
+ export const WEB_THREAD_PAGE_DEFAULT = 50;
124
136
  export const WEB_MESSAGE_PAGE_MAX = 100;
137
+ /**
138
+ * What one page of a transcript is when the caller does not say.
139
+ *
140
+ * A conversation read used to answer with the whole cap, which on a tool-heavy
141
+ * thread is hundreds of kilobytes the viewport never shows. The console renders
142
+ * the tail and pages backwards from `messagesNextCursor`, so the default is the
143
+ * screenful rather than the ceiling.
144
+ */
145
+ export const WEB_MESSAGE_PAGE_DEFAULT = 30;
125
146
  const MAX_ACTIVE_PUSH_SUBSCRIPTIONS = 32;
126
147
  const MAX_PENDING_PUSH_DELIVERIES_PER_SUBSCRIPTION = 200;
127
148
  const PUSH_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
@@ -141,6 +162,14 @@ export class WebStore {
141
162
  * and it drops ids discovery no longer reports.
142
163
  */
143
164
  agentGenerations = new Map();
165
+ /**
166
+ * `writeMessageParts` statements by the columns they assign. A streaming
167
+ * answer is written every ~50 ms and the SET list is one of a handful of
168
+ * shapes, so preparing each shape once keeps the hot path off the SQL
169
+ * compiler. Keyed by the generated SQL, which is derived solely from which
170
+ * columns the caller moves.
171
+ */
172
+ partsWriteStatements = new Map();
144
173
  constructor(database, paths, clock) {
145
174
  this.database = database;
146
175
  this.paths = paths;
@@ -174,6 +203,7 @@ export class WebStore {
174
203
  // After recovery, so anything the recovery settled is already indexed by
175
204
  // its own trigger and this only sweeps what genuinely stayed running.
176
205
  store.reindexUnsettledMessages();
206
+ store.reindexLegacyMonitorMessages();
177
207
  return store;
178
208
  }
179
209
  catch (error) {
@@ -185,6 +215,9 @@ export class WebStore {
185
215
  if (this.closed)
186
216
  return;
187
217
  this.closed = true;
218
+ // Drop the cached statements first: they hold native handles onto the
219
+ // connection this is about to close.
220
+ this.partsWriteStatements.clear();
188
221
  this.database.close();
189
222
  }
190
223
  /**
@@ -210,14 +243,17 @@ export class WebStore {
210
243
  const current = this.listAgents();
211
244
  const currentById = new Map(current.map((agent) => [agent.sourceId, agent]));
212
245
  const incomingIds = new Set(agents.map((agent) => agent.sourceId));
213
- const departed = current.some((agent) => !incomingIds.has(agent.sourceId) && agent.status !== "offline");
246
+ // Presence is separate from reachability. An offline summary still belongs
247
+ // in the picker because discovery found its source; an omitted source does
248
+ // not, even when it was already offline before it disappeared.
249
+ const departed = current.some((agent) => !incomingIds.has(agent.sourceId));
214
250
  const differs = (agent, ignoreHeartbeat) => {
215
251
  const prior = currentById.get(agent.sourceId);
216
252
  if (prior === undefined)
217
253
  return true;
218
254
  // `pinned` is store-owned and never arrives from discovery; normalizing it
219
255
  // keeps a locally pinned agent from looking like an incoming change.
220
- const next = { ...agent, pinned: prior.pinned };
256
+ const next = { ...agent, pinned: prior.pinned, runSettings: prior.runSettings };
221
257
  if (!ignoreHeartbeat)
222
258
  return !isDeepStrictEqual(prior, next);
223
259
  return !isDeepStrictEqual({ ...prior, updatedAt: "" }, { ...next, updatedAt: "" });
@@ -251,18 +287,24 @@ export class WebStore {
251
287
  return false;
252
288
  }
253
289
  this.transaction(() => {
254
- this.database.prepare("UPDATE agents SET status = 'offline'").run();
290
+ // Rows stay as foreign-key parents for retained conversations and
291
+ // delivery ledgers. Discovery presence controls whether they are part of
292
+ // the live console projection; it is restored by the upsert below when a
293
+ // source id returns.
294
+ this.database.prepare("UPDATE agents SET status = 'offline', discovered = 0 WHERE discovered = 1").run();
255
295
  const statement = this.database.prepare(`
256
296
  INSERT INTO agents (
257
- source_id, label, status, health, supports_attachments, models_json,
297
+ source_id, label, status, discovered, health, supports_attachments, supports_provider_auth, models_json,
258
298
  default_model, default_effort, efforts_json, model_options_json,
259
299
  providers_json, cron_read, cron_actions, ask_by_id, updated_at
260
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
300
+ ) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
261
301
  ON CONFLICT(source_id) DO UPDATE SET
262
302
  label = excluded.label,
263
303
  status = excluded.status,
304
+ discovered = 1,
264
305
  health = excluded.health,
265
306
  supports_attachments = excluded.supports_attachments,
307
+ supports_provider_auth = excluded.supports_provider_auth,
266
308
  models_json = excluded.models_json,
267
309
  default_model = excluded.default_model,
268
310
  default_effort = excluded.default_effort,
@@ -275,18 +317,39 @@ export class WebStore {
275
317
  updated_at = excluded.updated_at
276
318
  `);
277
319
  for (const agent of agents) {
278
- 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), stringifyOptional(agent.providers), agent.cron?.read === true ? 1 : 0, agent.cron?.actions === true ? 1 : 0, agent.supportsAskById === true ? 1 : 0, agent.updatedAt);
320
+ statement.run(agent.sourceId, agent.label, agent.status, agent.health ?? null, agent.supportsAttachments ? 1 : 0, agent.supportsProviderAuth === true ? 1 : 0, stringifyOptional(agent.models), agent.defaultModel ?? null, agent.defaultEffort ?? null, stringifyOptional(agent.efforts), stringifyOptional(agent.modelOptions), stringifyOptional(agent.providers), agent.cron?.read === true ? 1 : 0, agent.cron?.actions === true ? 1 : 0, agent.supportsAskById === true ? 1 : 0, agent.updatedAt);
279
321
  }
280
322
  });
281
323
  adoptGenerations();
282
324
  return notable;
283
325
  }
326
+ /**
327
+ * A failed registry walk is not an authoritative empty registry. Keep every
328
+ * currently discovered source in the console, but make its lack of a live
329
+ * connection explicit until a later successful discovery reconciles it.
330
+ */
331
+ markDiscoveredAgentsOffline() {
332
+ const changed = this.listAgents().some((agent) => agent.status !== "offline");
333
+ this.agentGenerations.clear();
334
+ if (!changed)
335
+ return false;
336
+ this.database.prepare(`
337
+ UPDATE agents SET status = 'offline'
338
+ WHERE discovered = 1 AND status != 'offline'
339
+ `).run();
340
+ return true;
341
+ }
284
342
  listAgents() {
285
- const rows = this.database.prepare(agentSelectSql("ORDER BY pinned DESC, a.label COLLATE NOCASE, a.source_id")).all();
343
+ const rows = this.database.prepare(agentSelectSql("WHERE a.discovered = 1 ORDER BY pinned DESC, a.label COLLATE NOCASE, a.source_id")).all();
286
344
  return rows.map((row) => this.withGeneration(mapAgent(row)));
287
345
  }
288
346
  getAgent(sourceId) {
289
- const row = this.database.prepare(agentSelectSql("WHERE a.source_id = ?")).get(sourceId);
347
+ const row = this.database.prepare(agentSelectSql("WHERE a.source_id = ? AND a.discovered = 1")).get(sourceId);
348
+ return row === undefined ? undefined : this.withGeneration(mapAgent(row));
349
+ }
350
+ getStoredAgent(sourceId) {
351
+ const row = this.database.prepare(agentSelectSql("WHERE a.source_id = ?"))
352
+ .get(sourceId);
290
353
  return row === undefined ? undefined : this.withGeneration(mapAgent(row));
291
354
  }
292
355
  /** Stitch the live generation onto a row read back from disk. */
@@ -308,6 +371,30 @@ export class WebStore {
308
371
  throw new WebConsoleError("agent_not_found", "The selected agent is no longer available.", 404);
309
372
  return agent;
310
373
  }
374
+ setAgentRunOverride(sourceId, override) {
375
+ if (this.getAgent(sourceId) === undefined) {
376
+ throw new WebConsoleError("agent_not_found", "The selected agent is no longer available.", 404);
377
+ }
378
+ if (override.model === null && override.effort === null) {
379
+ throw new WebConsoleError("invalid_request", "Choose a model or effort override, or use Revert to config.", 400);
380
+ }
381
+ this.database.prepare(`
382
+ INSERT INTO agent_run_overrides (source_id, model, effort, updated_at)
383
+ VALUES (?, ?, ?, ?)
384
+ ON CONFLICT(source_id) DO UPDATE SET
385
+ model = excluded.model,
386
+ effort = excluded.effort,
387
+ updated_at = excluded.updated_at
388
+ `).run(sourceId, override.model, override.effort, this.now());
389
+ return this.getAgent(sourceId);
390
+ }
391
+ clearAgentRunOverride(sourceId) {
392
+ if (this.getAgent(sourceId) === undefined) {
393
+ throw new WebConsoleError("agent_not_found", "The selected agent is no longer available.", 404);
394
+ }
395
+ this.database.prepare("DELETE FROM agent_run_overrides WHERE source_id = ?").run(sourceId);
396
+ return this.getAgent(sourceId);
397
+ }
311
398
  reserveNotification(input) {
312
399
  if (this.getAgent(input.sourceId) === undefined) {
313
400
  throw new WebConsoleError("agent_not_found", "The notification agent is no longer available.", 404);
@@ -424,6 +511,14 @@ export class WebStore {
424
511
  let turnId = randomUUID();
425
512
  let assistantMessageId = randomUUID();
426
513
  let completedThreadId = existing.thread_id;
514
+ /**
515
+ * Whether this completion actually MOVED the assistant row.
516
+ *
517
+ * A notification that maps onto a cron run whose message already carries
518
+ * this text writes nothing at all, and naming the row anyway costs every
519
+ * subscribed console one message read for a transcript that did not change.
520
+ */
521
+ let wroteMessage = false;
427
522
  this.transaction(() => {
428
523
  const cronChannel = reservation.jobId === undefined
429
524
  ? undefined
@@ -474,21 +569,26 @@ export class WebStore {
474
569
  id, thread_id, turn_id, role, parts_json, created_at, updated_at, status
475
570
  ) VALUES (?, ?, ?, 'assistant', ?, ?, ?, 'complete')
476
571
  `).run(assistantMessageId, completedThreadId, turnId, serializeParts([{ type: "text", text: reservation.text }]), now, now);
572
+ wroteMessage = true;
477
573
  }
478
574
  else {
479
575
  turnId = mappedRun.turn_id;
480
576
  assistantMessageId = mappedRun.message_id;
481
577
  const message = this.database.prepare(`
482
- SELECT parts_json FROM messages WHERE id = ? AND thread_id = ? AND turn_id = ?
578
+ SELECT parts_json, cron_suppressed FROM messages WHERE id = ? AND thread_id = ? AND turn_id = ?
483
579
  `).get(assistantMessageId, completedThreadId, turnId);
484
580
  if (message === undefined) {
485
581
  throw new WebConsoleError("storage_corrupt", "A cron run mapping is missing its message.", 500);
486
582
  }
487
- const parts = parseParts(message.parts_json);
488
- if (!parts.some((part) => part.type === "text" && part.text === reservation.text)) {
583
+ const parts = parseParts(message.parts_json).filter((part) => !(part.type === "text" && isSyntheticCronStateText(part.text)))
584
+ .map((part) => part.type === "telemetry" && part.event === "cron_run"
585
+ ? { ...part, data: withoutCronSilentFlag(part.data) } : part);
586
+ if (!parts.some((part) => part.type === "text" && part.text === reservation.text))
489
587
  parts.push({ type: "text", text: reservation.text });
490
- this.database.prepare("UPDATE messages SET parts_json = ?, updated_at = ? WHERE id = ?")
491
- .run(serializeParts(parts), now, assistantMessageId);
588
+ if (message.cron_suppressed === 1 || serializeParts(parts) !== message.parts_json) {
589
+ this.database.prepare("UPDATE messages SET cron_suppressed = 0 WHERE id = ?").run(assistantMessageId);
590
+ this.writeMessageParts(assistantMessageId, parts, now);
591
+ wroteMessage = true;
492
592
  }
493
593
  }
494
594
  this.database.prepare(`
@@ -499,7 +599,7 @@ export class WebStore {
499
599
  UPDATE notification_deliveries SET thread_id = ?, message_id = ?, completed_at = ?
500
600
  WHERE source_id = ? AND delivery_key = ? AND completed_at IS NULL
501
601
  `).run(completedThreadId, assistantMessageId, now, reservation.sourceId, reservation.deliveryKey);
502
- const agent = this.getAgent(reservation.sourceId);
602
+ const agent = this.getStoredAgent(reservation.sourceId);
503
603
  this.enqueueWebPushEventInTransaction({
504
604
  logicalKey: notificationPushLogicalKey(reservation.sourceId, reservation.deliveryKey),
505
605
  kind: "response.ready",
@@ -511,7 +611,12 @@ export class WebStore {
511
611
  notBefore: new Date(new Date(now).getTime() + 3_000).toISOString(),
512
612
  });
513
613
  });
514
- return { thread: this.requireThread(completedThreadId), duplicate: false };
614
+ return {
615
+ thread: this.requireThread(completedThreadId),
616
+ duplicate: false,
617
+ // Only when there is a write to name. See `wroteMessage`.
618
+ ...(wroteMessage ? { messageId: assistantMessageId } : {}),
619
+ };
515
620
  }
516
621
  /** Persist an agent-authoritative overview without deriving scheduler facts in the console. */
517
622
  syncCronOverviewResult(overview) {
@@ -705,11 +810,184 @@ export class WebStore {
705
810
  storedCronRuns(sourceId, jobId, limit = 100) {
706
811
  const bounded = boundedPageLimit(limit, 100);
707
812
  const rows = this.database.prepare(`
708
- SELECT payload_json FROM cron_run_messages
813
+ SELECT payload_json, message_id FROM cron_run_messages
709
814
  WHERE source_id = ? AND job_id = ?
710
815
  ORDER BY ordered_at DESC, sequence DESC, run_id DESC LIMIT ?
711
816
  `).all(sourceId, jobId, bounded);
712
- return { runs: rows.map((row) => parseStoredCronRun(row.payload_json)) };
817
+ return {
818
+ runs: rows.map((row) => parseStoredCronRun(row.payload_json)),
819
+ messages: [...rows].reverse().flatMap((row) => {
820
+ const message = this.getMessage(row.message_id);
821
+ return message === undefined ? [] : [message];
822
+ }),
823
+ };
824
+ }
825
+ captureCronReplySnapshot(sourceId, jobId, runId, snapshotKind) {
826
+ const row = this.database.prepare(`
827
+ SELECT r.payload_json, m.parts_json, m.cron_suppressed,
828
+ t.text, t.error_code, t.error_message
829
+ FROM cron_run_messages r
830
+ JOIN cron_channels c ON c.source_id = r.source_id AND c.job_id = r.job_id AND c.thread_id = r.thread_id
831
+ JOIN messages m ON m.id = r.message_id AND m.thread_id = r.thread_id
832
+ JOIN turns t ON t.id = r.turn_id AND t.thread_id = r.thread_id
833
+ WHERE r.source_id = ? AND r.job_id = ? AND r.run_id = ?
834
+ `).get(sourceId, jobId, runId);
835
+ if (row === undefined) {
836
+ throw new WebConsoleError("cron_reply_run_not_found", "Cron run not found for this agent and job.", 404);
837
+ }
838
+ const run = parseStoredCronRun(row.payload_json);
839
+ if (run.jobId !== jobId || run.runId !== runId) {
840
+ throw new WebConsoleError("storage_corrupt", "Stored cron run identity is inconsistent.", 500);
841
+ }
842
+ if (row.cron_suppressed === 1 || !isTerminalCronRun(run.status)) {
843
+ throw new WebConsoleError("cron_reply_unavailable", "Only visible terminal cron results can be replied to.", 422);
844
+ }
845
+ if (snapshotKind === "summary") {
846
+ return {
847
+ sourceId,
848
+ jobId,
849
+ runId,
850
+ snapshotKind,
851
+ capturedAt: this.now(),
852
+ run,
853
+ text: run.text ?? "",
854
+ ...(run.failureKind === undefined ? {} : { errorCode: run.failureKind }),
855
+ ...(run.error === undefined ? {} : { errorMessage: run.error }),
856
+ sourceFieldsTruncated: run.fieldsTruncated ?? [],
857
+ sourceTruncationKnown: true,
858
+ };
859
+ }
860
+ const telemetry = parseParts(row.parts_json).find((part) => part.type === "telemetry" && part.event === "cron_run");
861
+ const data = record(telemetry?.data);
862
+ if (data?.activityLoaded !== true) {
863
+ throw new WebConsoleError("cron_reply_detail_unavailable", "Cron run detail was not loaded when Reply was activated.", 422);
864
+ }
865
+ const detailFields = Array.isArray(data.detailFieldsTruncated)
866
+ && data.detailFieldsTruncated.every((field) => typeof field === "string")
867
+ ? data.detailFieldsTruncated
868
+ : undefined;
869
+ return {
870
+ sourceId,
871
+ jobId,
872
+ runId,
873
+ snapshotKind,
874
+ capturedAt: this.now(),
875
+ run,
876
+ text: row.text,
877
+ ...(row.error_code === null ? {} : { errorCode: row.error_code }),
878
+ ...(row.error_message === null ? {} : { errorMessage: row.error_message }),
879
+ ...(detailFields === undefined ? {} : { sourceFieldsTruncated: detailFields }),
880
+ sourceTruncationKnown: detailFields !== undefined,
881
+ };
882
+ }
883
+ cronReplyOperation(operationId) {
884
+ return this.transaction(() => {
885
+ const row = this.database.prepare("SELECT * FROM cron_reply_operations WHERE operation_id = ?")
886
+ .get(operationId);
887
+ return row === undefined ? undefined : this.cronReplyState(row);
888
+ });
889
+ }
890
+ reserveCronReplyOperation(operationId, candidate) {
891
+ return this.transaction(() => {
892
+ const existing = this.database.prepare("SELECT * FROM cron_reply_operations WHERE operation_id = ?")
893
+ .get(operationId);
894
+ if (existing !== undefined) {
895
+ this.assertCronReplyIdentity(existing, candidate.sourceId, candidate.jobId, candidate.runId);
896
+ return this.cronReplyState(existing);
897
+ }
898
+ const pending = this.database.prepare(`
899
+ SELECT * FROM cron_reply_operations
900
+ WHERE source_id = ? AND job_id = ? AND run_id = ? AND state = 'pending'
901
+ `).get(candidate.sourceId, candidate.jobId, candidate.runId);
902
+ if (pending !== undefined)
903
+ return { kind: "pending", operation: mapCronReplyOperation(pending) };
904
+ // Revalidate only eligibility/identity. The candidate remains the exact
905
+ // activation snapshot and is never replaced by a later detail refresh.
906
+ const eligible = this.database.prepare(`
907
+ SELECT r.payload_json, m.cron_suppressed
908
+ FROM cron_run_messages r JOIN messages m ON m.id = r.message_id
909
+ WHERE r.source_id = ? AND r.job_id = ? AND r.run_id = ?
910
+ `).get(candidate.sourceId, candidate.jobId, candidate.runId);
911
+ if (eligible === undefined || eligible.cron_suppressed === 1
912
+ || !isTerminalCronRun(parseStoredCronRun(eligible.payload_json).status)) {
913
+ throw new WebConsoleError("cron_reply_unavailable", "This cron result is no longer eligible for Reply.", 422);
914
+ }
915
+ const threadId = randomUUID();
916
+ const conversationId = `web:${threadId}`;
917
+ const provenanceMessageId = randomUUID();
918
+ const resultMessageId = randomUUID();
919
+ const idempotencyKey = `web-cron-reply:v1:${createHash("sha256")
920
+ .update(`${candidate.sourceId}\0${candidate.jobId}\0${candidate.runId}\0${operationId}`)
921
+ .digest("hex")}`;
922
+ const snapshotText = formatCronReplyContext(candidate);
923
+ const snapshotSha256 = createHash("sha256").update(snapshotText).digest("hex");
924
+ const title = normalizeTitle(`Reply to ${candidate.jobId} · Run ${String(candidate.run.sequence)}`);
925
+ const override = this.database.prepare("SELECT model, effort FROM agent_run_overrides WHERE source_id = ?")
926
+ .get(candidate.sourceId);
927
+ this.database.prepare(`
928
+ INSERT INTO cron_reply_operations (
929
+ operation_id, source_id, job_id, run_id, thread_id, conversation_id,
930
+ provenance_message_id, result_message_id, idempotency_key, state, snapshot_kind,
931
+ snapshot_text, snapshot_sha256, title, run_model, run_effort, created_at
932
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?)
933
+ `).run(operationId, candidate.sourceId, candidate.jobId, candidate.runId, threadId, conversationId, provenanceMessageId, resultMessageId, idempotencyKey, candidate.snapshotKind, snapshotText, snapshotSha256, title, override?.model ?? null, override?.effort ?? null, candidate.capturedAt);
934
+ const row = this.requireCronReplyOperationRow(operationId);
935
+ return { kind: "reserved", operation: mapCronReplyOperation(row) };
936
+ });
937
+ }
938
+ completeCronReplyOperation(operationId, canonicalStatus) {
939
+ return this.transaction(() => {
940
+ const row = this.requireCronReplyOperationRow(operationId);
941
+ if (row.state !== "pending")
942
+ return this.cronReplyState(row);
943
+ if (row.snapshot_text === null || row.snapshot_sha256 === null || row.title === null
944
+ || row.provenance_message_id === null || row.result_message_id === null
945
+ || createHash("sha256").update(row.snapshot_text).digest("hex") !== row.snapshot_sha256) {
946
+ throw new WebConsoleError("storage_corrupt", "Cron reply reservation is incomplete or changed.", 500);
947
+ }
948
+ const now = this.now();
949
+ this.database.prepare(`
950
+ INSERT INTO threads (
951
+ id, source_id, conversation_id, title, title_manual, archived_at,
952
+ created_at, updated_at, run_model, run_effort, revision
953
+ ) VALUES (?, ?, ?, ?, 0, NULL, ?, ?, ?, ?, 1)
954
+ `).run(row.thread_id, row.source_id, row.conversation_id, row.title, now, now, row.run_model, row.run_effort);
955
+ this.database.prepare(`
956
+ INSERT INTO messages (id, thread_id, turn_id, role, parts_json, created_at, updated_at, status)
957
+ VALUES (?, ?, NULL, 'system', ?, ?, ?, 'complete')
958
+ `).run(row.provenance_message_id, row.thread_id, serializeParts([{ type: "text", text: AGENT_CONTEXT_IMPORT_SYSTEM_PROVENANCE }]), now, now);
959
+ this.database.prepare(`
960
+ INSERT INTO messages (id, thread_id, turn_id, role, parts_json, created_at, updated_at, status)
961
+ VALUES (?, ?, NULL, 'assistant', ?, ?, ?, 'complete')
962
+ `).run(row.result_message_id, row.thread_id, serializeParts([{ type: "text", text: row.snapshot_text }]), now, now);
963
+ this.database.prepare("INSERT INTO revisions (entity_kind, entity_id, revision, event, created_at) VALUES ('thread', ?, 1, 'cron_reply_imported', ?)")
964
+ .run(row.thread_id, now);
965
+ this.setSetting("current_thread_id", row.thread_id);
966
+ const settled = this.database.prepare(`
967
+ UPDATE cron_reply_operations
968
+ SET state = 'completed', canonical_status = ?, completed_at = ?
969
+ WHERE operation_id = ? AND state = 'pending'
970
+ `).run(canonicalStatus, now, operationId);
971
+ if (settled.changes !== 1)
972
+ return this.cronReplyState(this.requireCronReplyOperationRow(operationId));
973
+ return { kind: "completed", receipt: this.cronReplyReceipt(this.requireCronReplyOperationRow(operationId), false) };
974
+ });
975
+ }
976
+ failCronReplyOperation(operationId, reason) {
977
+ return this.transaction(() => {
978
+ const row = this.requireCronReplyOperationRow(operationId);
979
+ if (row.state !== "pending")
980
+ return this.cronReplyState(row);
981
+ const now = this.now();
982
+ this.database.prepare(`
983
+ UPDATE cron_reply_operations SET state = 'failed', provenance_message_id = NULL,
984
+ result_message_id = NULL, snapshot_text = NULL, snapshot_sha256 = NULL,
985
+ title = NULL, run_model = NULL, run_effort = NULL,
986
+ failure_reason = ?, failed_at = ?
987
+ WHERE operation_id = ? AND state = 'pending'
988
+ `).run(reason.slice(0, 128), now, operationId);
989
+ return this.cronReplyState(this.requireCronReplyOperationRow(operationId));
990
+ });
713
991
  }
714
992
  reconcileCronRuns(sourceId, jobId, runs) {
715
993
  return [...this.reconcileCronRunsResult(sourceId, jobId, runs).messages];
@@ -727,10 +1005,15 @@ export class WebStore {
727
1005
  : parseStoredCronJob(jobRow.payload_json).conversationId;
728
1006
  const ordered = [...runs].sort(compareCronRuns);
729
1007
  if (ordered.length === 0)
730
- return { messages: [], changed: false };
1008
+ return { messages: [], changed: false, writtenMessageIds: [] };
731
1009
  const now = this.now();
732
1010
  const messageIds = [];
1011
+ // Only the rows whose parts this run actually moved -- see
1012
+ // {@link CronRunReconciliationResult.writtenMessageIds}.
1013
+ const written = new Set();
733
1014
  let changed = false;
1015
+ let storedChanged = false;
1016
+ let visibleActivityAt;
734
1017
  this.transaction(() => {
735
1018
  for (const run of ordered) {
736
1019
  if (run.jobId !== jobId) {
@@ -744,24 +1027,30 @@ export class WebStore {
744
1027
  if (mapped !== undefined && (mapped.ordered_at !== run.orderedAt || mapped.sequence !== run.sequence)) {
745
1028
  throw new WebConsoleError("invalid_cron_response", "Cron run ordering identity changed after admission.", 502);
746
1029
  }
747
- const delivered = mapped === undefined
748
- ? this.database.prepare(`
1030
+ const delivered = this.database.prepare(`
749
1031
  SELECT d.message_id, m.turn_id
750
1032
  FROM notification_deliveries d
751
1033
  JOIN messages m ON m.id = d.message_id
752
1034
  WHERE d.source_id = ? AND d.job_id = ? AND d.run_id = ?
753
1035
  AND d.thread_id = ? AND d.completed_at IS NOT NULL
1036
+ AND (? IS NULL OR d.message_id = ?)
754
1037
  ORDER BY d.completed_at DESC LIMIT 1
755
- `).get(sourceId, jobId, run.runId, channel.thread_id)
756
- : undefined;
1038
+ `).get(sourceId, jobId, run.runId, channel.thread_id, mapped?.message_id ?? null, mapped?.message_id ?? null);
757
1039
  const turnId = mapped?.turn_id ?? delivered?.turn_id ?? cronEntityId("turn", sourceId, jobId, run.runId);
758
1040
  const messageId = mapped?.message_id ?? delivered?.message_id ?? cronEntityId("message", sourceId, jobId, run.runId);
759
1041
  messageIds.push(messageId);
760
1042
  const prior = this.database.prepare(`
761
- SELECT thread_id, turn_id, parts_json, created_at, status FROM messages WHERE id = ?
1043
+ SELECT thread_id, turn_id, parts_json, created_at, status, cron_suppressed FROM messages WHERE id = ?
762
1044
  `).get(messageId);
763
1045
  const priorParts = prior === undefined ? [] : parseParts(prior.parts_json);
764
- const parts = cronRunParts(run, priorParts, conversationId);
1046
+ let parts = cronRunParts(prior?.cron_suppressed === 1 && run.status === "succeeded" && run.fieldsTruncated?.includes("text") === true
1047
+ ? { ...run, text: NOTHING_TO_REPORT_SENTINEL } : run, priorParts, conversationId, delivered !== undefined);
1048
+ const suppressed = delivered === undefined && !hasMeaningfulCronContent(parts)
1049
+ && this.database.prepare("SELECT 1 FROM attachments WHERE message_id = ? LIMIT 1").get(messageId) === undefined
1050
+ && (definitelySilentCronRun(run)
1051
+ || (run.status === "succeeded" && run.fieldsTruncated?.includes("text") === true && prior?.cron_suppressed === 1));
1052
+ if (!suppressed)
1053
+ parts = parts.map(clearSilentCronPart);
765
1054
  const serializedParts = serializeParts(parts);
766
1055
  const status = cronMessageStatus(run.status);
767
1056
  const turnStatus = status === "running" ? "running" : status;
@@ -797,7 +1086,7 @@ export class WebStore {
797
1086
  started_at, finished_at, error_code, error_message
798
1087
  ) VALUES (?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?)
799
1088
  `).run(turnId, channel.thread_id, turnStatus, turnText, messageId, run.orderedAt, finishedAt, turnErrorCode, turnErrorMessage);
800
- changed = true;
1089
+ storedChanged = true;
801
1090
  }
802
1091
  else if (existingTurn.thread_id !== channel.thread_id
803
1092
  || existingTurn.status !== turnStatus
@@ -812,26 +1101,39 @@ export class WebStore {
812
1101
  started_at = ?, finished_at = ?, error_code = ?, error_message = ?
813
1102
  WHERE id = ?
814
1103
  `).run(channel.thread_id, turnStatus, turnText, messageId, run.orderedAt, finishedAt, turnErrorCode, turnErrorMessage, turnId);
815
- changed = true;
1104
+ storedChanged = true;
816
1105
  }
817
1106
  if (prior === undefined) {
818
1107
  this.database.prepare(`
819
1108
  INSERT INTO messages (
820
- id, thread_id, turn_id, role, parts_json, created_at, updated_at, status
821
- ) VALUES (?, ?, ?, 'assistant', ?, ?, ?, ?)
822
- `).run(messageId, channel.thread_id, turnId, serializedParts, run.orderedAt, now, status);
823
- changed = true;
1109
+ id, thread_id, turn_id, role, parts_json, created_at, updated_at, status, cron_suppressed
1110
+ ) VALUES (?, ?, ?, 'assistant', ?, ?, ?, ?, ?)
1111
+ `).run(messageId, channel.thread_id, turnId, serializedParts, run.orderedAt, now, status, suppressed ? 1 : 0);
1112
+ written.add(messageId);
1113
+ storedChanged = true;
824
1114
  }
825
1115
  else if (prior.thread_id !== channel.thread_id
826
1116
  || prior.turn_id !== turnId
827
1117
  || prior.parts_json !== serializedParts
828
1118
  || prior.created_at !== run.orderedAt
829
- || prior.status !== status) {
830
- this.database.prepare(`
831
- UPDATE messages SET thread_id = ?, turn_id = ?, parts_json = ?,
832
- created_at = ?, updated_at = ?, status = ? WHERE id = ?
833
- `).run(channel.thread_id, turnId, serializedParts, run.orderedAt, now, status, messageId);
1119
+ || prior.status !== status
1120
+ || prior.cron_suppressed !== Number(suppressed)) {
1121
+ this.writeMessageParts(messageId, parts, now, {
1122
+ threadId: channel.thread_id,
1123
+ turnId,
1124
+ createdAt: run.orderedAt,
1125
+ status,
1126
+ });
1127
+ this.database.prepare("UPDATE messages SET cron_suppressed = ? WHERE id = ?").run(Number(suppressed), messageId);
1128
+ written.add(messageId);
1129
+ storedChanged = true;
1130
+ }
1131
+ if (written.has(messageId) && (!suppressed || prior?.cron_suppressed === 0))
834
1132
  changed = true;
1133
+ if (written.has(messageId) && !suppressed) {
1134
+ const activityAt = run.completedAt ?? run.startedAt ?? run.orderedAt;
1135
+ if (visibleActivityAt === undefined || activityAt > visibleActivityAt)
1136
+ visibleActivityAt = activityAt;
835
1137
  }
836
1138
  // Detail is a message projection, not a replacement for the compact
837
1139
  // page identity. Keeping the existing summary prevents the next
@@ -856,21 +1158,22 @@ export class WebStore {
856
1158
  payload_json = excluded.payload_json,
857
1159
  updated_at = excluded.updated_at
858
1160
  `).run(sourceId, jobId, run.runId, channel.thread_id, turnId, messageId, run.orderedAt, run.sequence, serializedRun, now);
859
- changed = true;
1161
+ storedChanged = true;
860
1162
  }
861
1163
  }
862
- if (changed) {
863
- const newest = ordered.at(-1);
864
- this.database.prepare(`
865
- UPDATE threads SET updated_at = MAX(updated_at, ?), revision = revision + 1 WHERE id = ?
866
- `).run(newest.completedAt ?? newest.startedAt ?? newest.orderedAt, channel.thread_id);
867
- this.recordThreadRevision(channel.thread_id, "cron_runs_reconciled", now);
1164
+ if (storedChanged) {
1165
+ if (changed) {
1166
+ this.database.prepare(`
1167
+ UPDATE threads SET updated_at = MAX(updated_at, COALESCE(?, updated_at)), revision = revision + 1 WHERE id = ?
1168
+ `).run(visibleActivityAt ?? null, channel.thread_id);
1169
+ this.recordThreadRevision(channel.thread_id, "cron_runs_reconciled", now);
1170
+ }
868
1171
  const excess = this.database.prepare(`
869
1172
  SELECT turn_id FROM (
870
- SELECT turn_id, ROW_NUMBER() OVER (
871
- PARTITION BY source_id, job_id ORDER BY ordered_at DESC, sequence DESC, run_id DESC
1173
+ SELECT r.turn_id, ROW_NUMBER() OVER (
1174
+ PARTITION BY r.source_id, r.job_id, m.cron_suppressed ORDER BY r.ordered_at DESC, r.sequence DESC, r.run_id DESC
872
1175
  ) AS retained_row
873
- FROM cron_run_messages WHERE source_id = ? AND job_id = ?
1176
+ FROM cron_run_messages r JOIN messages m ON m.id = r.message_id WHERE r.source_id = ? AND r.job_id = ?
874
1177
  ) WHERE retained_row > 500
875
1178
  `).all(sourceId, jobId);
876
1179
  const remove = this.database.prepare("DELETE FROM turns WHERE id = ?");
@@ -879,8 +1182,12 @@ export class WebStore {
879
1182
  }
880
1183
  });
881
1184
  return {
882
- messages: [...new Set(messageIds)].map((messageId) => this.requireMessage(messageId)),
1185
+ messages: [...new Set(messageIds)].flatMap((id) => { const message = this.getMessage(id); return message === undefined ? [] : [message]; }),
883
1186
  changed,
1187
+ writtenMessageIds: [...written].filter((id) => this.getMessage(id) !== undefined),
1188
+ suppressedRunIds: runs.filter((run) => this.database.prepare(`SELECT 1 FROM cron_run_messages r
1189
+ JOIN messages m ON m.id = r.message_id WHERE r.source_id = ? AND r.job_id = ? AND r.run_id = ? AND m.cron_suppressed = 1
1190
+ `).get(sourceId, jobId, run.runId) !== undefined).map((run) => run.runId),
884
1191
  };
885
1192
  }
886
1193
  /** Append or update exactly one retained card for a web-origin process job. */
@@ -925,7 +1232,7 @@ export class WebStore {
925
1232
  .run(now, input.threadId);
926
1233
  this.recordThreadRevision(input.threadId, "process_job_card_created", now);
927
1234
  });
928
- return { thread: this.requireThread(input.threadId), duplicate: false };
1235
+ return { thread: this.requireThread(input.threadId), duplicate: false, messageId };
929
1236
  }
930
1237
  if (existing.thread_id !== input.threadId || existing.delivery_key !== input.deliveryKey) {
931
1238
  throw new WebConsoleError("notification_idempotency_conflict", "The process job was already bound to a different web thread or delivery key.", 409);
@@ -965,12 +1272,10 @@ export class WebStore {
965
1272
  if (existing.projection_sha256 === projectionSha256
966
1273
  && responseText === (existing.response_text ?? undefined)
967
1274
  && !replyPartsChanged) {
968
- return { thread, duplicate: true };
1275
+ return { thread, duplicate: true, messageId: existing.message_id };
969
1276
  }
970
1277
  this.transaction(() => {
971
- this.database.prepare(`
972
- UPDATE messages SET parts_json = ?, updated_at = ?, status = ? WHERE id = ?
973
- `).run(serializeParts([processJobPart(projection, responseText), ...nextReplyParts]), now, isTerminalJobState(projection.state) ? "complete" : "running", existing.message_id);
1278
+ this.writeMessageParts(existing.message_id, [processJobPart(projection, responseText), ...nextReplyParts], now, { status: isTerminalJobState(projection.state) ? "complete" : "running" });
974
1279
  this.database.prepare(`
975
1280
  UPDATE process_job_cards
976
1281
  SET projection_sha256 = ?, response_text = ?, updated_at = ?
@@ -980,7 +1285,7 @@ export class WebStore {
980
1285
  .run(now, input.threadId);
981
1286
  this.recordThreadRevision(input.threadId, "process_job_card_updated", now);
982
1287
  });
983
- return { thread: this.requireThread(input.threadId), duplicate: false };
1288
+ return { thread: this.requireThread(input.threadId), duplicate: false, messageId: existing.message_id };
984
1289
  }
985
1290
  /** Durably claim one web process-job wake before touching the operator. */
986
1291
  reserveProcessJobWake(input) {
@@ -1011,6 +1316,11 @@ export class WebStore {
1011
1316
  `).run(input.sourceId, input.jobId, input.deliveryKey, input.threadId, now);
1012
1317
  return { kind: "new" };
1013
1318
  }
1319
+ /**
1320
+ * Confirm the wake's delivery path, independently of the associated turn's
1321
+ * eventual outcome. `completed/follow_up` means the exact turn was durably
1322
+ * admitted; it does not mean that turn completed successfully.
1323
+ */
1014
1324
  completeProcessJobWake(input) {
1015
1325
  const result = this.database.prepare(`
1016
1326
  UPDATE process_job_wake_deliveries
@@ -1020,13 +1330,54 @@ export class WebStore {
1020
1330
  if (result.changes !== 1) {
1021
1331
  throw new WebConsoleError("notification_reservation_lost", "The process-job wake reservation was lost.", 409);
1022
1332
  }
1333
+ if (input.turnId !== undefined) {
1334
+ const turn = this.requireTurn(input.turnId);
1335
+ if (turn.status === "complete" && this.hasProcessJobTurnAssociation(input.turnId)) {
1336
+ const message = this.requireMessage(turn.assistant_message_id);
1337
+ const normalized = normalizeMonitorTerminalReply(message.parts, true);
1338
+ if (normalized.changed)
1339
+ this.writeMessageParts(message.id, normalized.parts, this.now());
1340
+ this.repairMonitorResponsePush(input.turnId, normalized.parts);
1341
+ return normalized.changed ? this.requireMessage(message.id) : undefined;
1342
+ }
1343
+ }
1344
+ return undefined;
1023
1345
  }
1024
- /** Release a reservation only while no operator delivery has begun. */
1025
- abandonProcessJobWake(input) {
1346
+ /** Bind an accepted host wake to its exact follow-up before crossing the turn boundary. */
1347
+ associateProcessJobWakeTurn(deliveryKey, turnId, pending = true) {
1348
+ const result = this.database.prepare(`
1349
+ UPDATE process_job_wake_deliveries SET turn_id = ?
1350
+ WHERE delivery_key = ? AND state = 'accepted'
1351
+ AND thread_id = (SELECT turns.thread_id FROM turns JOIN threads ON threads.id = turns.thread_id
1352
+ WHERE turns.id = ? AND threads.source_id = process_job_wake_deliveries.source_id)
1353
+ `).run(pending ? turnId : null, deliveryKey, turnId);
1354
+ if (result.changes !== 1) {
1355
+ throw new WebConsoleError("notification_reservation_lost", "The process-job wake turn association was lost.", 409);
1356
+ }
1357
+ }
1358
+ hasProcessJobTurnAssociation(turnId, deliveryKey) {
1359
+ return this.database.prepare(`
1360
+ SELECT 1 FROM process_job_wake_deliveries AS deliveries
1361
+ JOIN turns ON turns.id = ? AND turns.thread_id = deliveries.thread_id
1362
+ JOIN threads ON threads.id = turns.thread_id AND threads.source_id = deliveries.source_id
1363
+ WHERE deliveries.turn_id = turns.id AND
1364
+ (deliveries.state = 'completed' OR (deliveries.state = 'accepted' AND deliveries.delivery_key = ?))
1365
+ `).get(turnId, deliveryKey ?? null) !== undefined;
1366
+ }
1367
+ /** Release only a pending notification hold; the ambiguous delivery reservation remains durable. */
1368
+ releaseProcessJobWakeTurn(deliveryKey, turnId) {
1026
1369
  this.database.prepare(`
1370
+ UPDATE process_job_wake_deliveries SET turn_id = NULL
1371
+ WHERE delivery_key = ? AND turn_id = ? AND state = 'accepted'
1372
+ `).run(deliveryKey, turnId);
1373
+ }
1374
+ /** Release a reservation only while no operator delivery has begun, proving the exact claim was removed. */
1375
+ abandonProcessJobWake(input) {
1376
+ const result = this.database.prepare(`
1027
1377
  DELETE FROM process_job_wake_deliveries
1028
1378
  WHERE source_id = ? AND job_id = ? AND delivery_key = ? AND state = 'accepted'
1029
1379
  `).run(input.sourceId, input.jobId, input.deliveryKey);
1380
+ return result.changes === 1;
1030
1381
  }
1031
1382
  /** Durably claim one Monitor wake before touching the operator. */
1032
1383
  reserveMonitorWake(input) {
@@ -1066,6 +1417,17 @@ export class WebStore {
1066
1417
  `).run(input.sourceId, input.monitorId, input.deliveryKey, input.threadId, input.payloadSha256, JSON.stringify(monitor), this.now());
1067
1418
  return { kind: "new" };
1068
1419
  }
1420
+ /** Hold terminal pushes while this exact turn's steering receipt is unresolved. */
1421
+ setMonitorWakeSteeringTurn(sourceId, deliveryKey, turnId, pending) {
1422
+ const turn = this.requireTurn(turnId);
1423
+ const changed = this.database.prepare(`
1424
+ UPDATE monitor_wake_deliveries SET turn_id = ?
1425
+ WHERE source_id = ? AND delivery_key = ? AND thread_id = ? AND state = 'accepted'
1426
+ `).run(pending ? turnId : null, sourceId, deliveryKey, turn.thread_id);
1427
+ if (changed.changes !== 1) {
1428
+ throw new WebConsoleError("notification_reservation_lost", "The Monitor steering reservation was lost.", 409);
1429
+ }
1430
+ }
1069
1431
  completeMonitorWake(input) {
1070
1432
  let messageId;
1071
1433
  this.transaction(() => {
@@ -1099,16 +1461,48 @@ export class WebStore {
1099
1461
  throw new WebConsoleError("storage_corrupt", "A retained Monitor wake projection is invalid.", 500);
1100
1462
  }
1101
1463
  const message = this.requireMessage(turn.assistant_message_id);
1102
- const parts = [...message.parts];
1103
- if (!upsertMonitorActivity(parts, projection, input.deliveryKey))
1464
+ const raw = this.database.prepare("SELECT parts_json FROM messages WHERE id = ?")
1465
+ .get(message.id);
1466
+ const original = parseParts(raw.parts_json);
1467
+ const normalized = turn.status === "complete" ? normalizeMonitorTerminalReply(original) : { parts: original, changed: false };
1468
+ const parts = normalized.parts;
1469
+ const activityChanged = upsertMonitorActivity(parts, projection, input.deliveryKey);
1470
+ if (turn.status === "complete")
1471
+ this.repairMonitorResponsePush(input.turnId, parts);
1472
+ if (!activityChanged && !normalized.changed)
1104
1473
  return;
1105
- const now = this.now();
1106
- this.database.prepare("UPDATE messages SET parts_json = ?, updated_at = ? WHERE id = ?")
1107
- .run(serializeParts(parts), now, message.id);
1474
+ this.writeMessageParts(message.id, parts, this.now());
1108
1475
  messageId = message.id;
1109
1476
  });
1110
1477
  return messageId === undefined ? undefined : this.requireMessage(messageId);
1111
1478
  }
1479
+ hasMonitorTurnAssociation(turnId, deliveryKey) {
1480
+ const rows = this.database.prepare(`
1481
+ SELECT deliveries.delivery_key FROM monitor_wake_deliveries AS deliveries
1482
+ JOIN turns ON turns.id = ? AND turns.thread_id = deliveries.thread_id
1483
+ JOIN threads ON threads.id = turns.thread_id AND threads.source_id = deliveries.source_id
1484
+ WHERE (deliveries.state = 'completed' AND deliveries.turn_id = turns.id
1485
+ AND deliveries.disposition IN ('steered', 'follow_up'))
1486
+ OR (deliveries.state = 'accepted' AND deliveries.delivery_key = ?)
1487
+ `).all(turnId, deliveryKey ?? null);
1488
+ return rows.length > 0;
1489
+ }
1490
+ repairMonitorResponsePush(turnId, parts) {
1491
+ const key = `turn:${turnId}:terminal`;
1492
+ if (hasMonitorReplyContent(parts)) {
1493
+ this.database.prepare("UPDATE push_events SET body = ? WHERE logical_key = ? AND kind = 'response.ready'")
1494
+ .run(webPushPreview(monitorReplyText(parts)), key);
1495
+ }
1496
+ else {
1497
+ const now = this.now();
1498
+ this.database.prepare(`
1499
+ UPDATE push_deliveries SET status = 'suppressed', updated_at = ?, finished_at = ?
1500
+ WHERE status = 'pending' AND event_id IN (
1501
+ SELECT id FROM push_events WHERE logical_key = ? AND kind = 'response.ready'
1502
+ )
1503
+ `).run(now, now, key);
1504
+ }
1505
+ }
1112
1506
  /** Resolve only an exact Monitor live-input receipt belonging to this turn. */
1113
1507
  monitorWakeProjection(turnId, deliveryKey) {
1114
1508
  const row = this.database.prepare(`
@@ -1130,6 +1524,20 @@ export class WebStore {
1130
1524
  throw new WebConsoleError("storage_corrupt", "A retained Monitor wake projection is invalid.", 500);
1131
1525
  }
1132
1526
  }
1527
+ /** Resolve only an exact process-job live-input receipt belonging to this turn. */
1528
+ processJobWakeForTurn(turnId, deliveryKey) {
1529
+ return this.database.prepare(`
1530
+ SELECT cards.job_id AS jobId, cards.delivery_key AS deliveryKey
1531
+ FROM process_job_cards AS cards
1532
+ JOIN process_job_wake_deliveries AS deliveries
1533
+ ON deliveries.source_id = cards.source_id
1534
+ AND deliveries.job_id = cards.job_id
1535
+ AND deliveries.delivery_key = cards.delivery_key
1536
+ JOIN turns ON turns.id = ? AND turns.thread_id = cards.thread_id
1537
+ JOIN threads ON threads.id = turns.thread_id AND threads.source_id = cards.source_id
1538
+ WHERE cards.delivery_key = ? AND deliveries.turn_id = turns.id
1539
+ `).get(turnId, deliveryKey);
1540
+ }
1133
1541
  /** Release a Monitor reservation only before any operator delivery begins. */
1134
1542
  abandonMonitorWake(input) {
1135
1543
  this.database.prepare(`
@@ -1144,7 +1552,7 @@ export class WebStore {
1144
1552
  WHERE source_id = ? AND thread_id = ? AND job_id = ?
1145
1553
  `).get(sourceId, threadId, jobId) !== undefined;
1146
1554
  }
1147
- createThread(sourceId) {
1555
+ createThread(sourceId, explicit = {}) {
1148
1556
  const agent = this.getAgent(sourceId);
1149
1557
  if (agent === undefined) {
1150
1558
  throw new WebConsoleError("agent_not_found", "The selected agent is no longer available.", 404);
@@ -1152,35 +1560,23 @@ export class WebStore {
1152
1560
  const id = randomUUID();
1153
1561
  const now = this.now();
1154
1562
  this.transaction(() => {
1563
+ const override = this.database.prepare(`
1564
+ SELECT model, effort FROM agent_run_overrides WHERE source_id = ?
1565
+ `).get(sourceId);
1566
+ const model = explicit.model === undefined ? override?.model ?? null : explicit.model;
1567
+ const effort = explicit.effort === undefined ? override?.effort ?? null : explicit.effort;
1155
1568
  this.database.prepare(`
1156
1569
  INSERT INTO threads (
1157
1570
  id, source_id, conversation_id, title, title_manual, archived_at,
1158
- created_at, updated_at, revision
1159
- ) VALUES (?, ?, ?, 'New conversation', 0, NULL, ?, ?, 1)
1160
- `).run(id, sourceId, `web:${id}`, now, now);
1571
+ created_at, updated_at, run_model, run_effort, revision
1572
+ ) VALUES (?, ?, ?, 'New conversation', 0, NULL, ?, ?, ?, ?, 1)
1573
+ `).run(id, sourceId, `web:${id}`, now, now, model, effort);
1161
1574
  this.database.prepare("INSERT INTO revisions (entity_kind, entity_id, revision, event, created_at) VALUES ('thread', ?, 1, 'created', ?)")
1162
1575
  .run(id, now);
1163
1576
  this.setSetting("current_thread_id", id);
1164
1577
  });
1165
1578
  return this.requireThread(id);
1166
1579
  }
1167
- /** Bounded bootstrap: at most one 200-row bucket per (source_id, archived). */
1168
- listThreads() {
1169
- const rows = this.database.prepare(threadSelectSql(`
1170
- WHERE t.id IN (
1171
- SELECT id FROM (
1172
- SELECT id,
1173
- ROW_NUMBER() OVER (
1174
- PARTITION BY source_id, CASE WHEN archived_at IS NULL THEN 0 ELSE 1 END
1175
- ORDER BY updated_at DESC, id DESC
1176
- ) AS bucket_row
1177
- FROM threads
1178
- ) WHERE bucket_row <= ${String(WEB_THREAD_PAGE_MAX)}
1179
- )
1180
- ORDER BY t.updated_at DESC, t.id DESC
1181
- `)).all();
1182
- return rows.map((row) => this.mapThread(row));
1183
- }
1184
1580
  listThreadsPage(input) {
1185
1581
  if (this.getAgent(input.sourceId) === undefined) {
1186
1582
  throw new WebConsoleError("agent_not_found", "Agent not found.", 404);
@@ -1236,7 +1632,7 @@ export class WebStore {
1236
1632
  FROM message_search
1237
1633
  JOIN messages m ON m.rowid = message_search.rowid
1238
1634
  JOIN threads t ON t.id = m.thread_id
1239
- WHERE message_search MATCH ? AND t.source_id = ?
1635
+ WHERE message_search MATCH ? AND t.source_id = ? AND ${visibleMessageSql("m")}
1240
1636
  ORDER BY rank
1241
1637
  LIMIT ?
1242
1638
  `).all(WEB_SEARCH_HIGHLIGHT_OPEN, WEB_SEARCH_HIGHLIGHT_CLOSE, match, input.sourceId, MESSAGE_SEARCH_SCAN_LIMIT + 1);
@@ -1332,12 +1728,12 @@ export class WebStore {
1332
1728
  const row = this.database.prepare(threadSelectSql("WHERE t.id = ?")).get(resolved);
1333
1729
  return row === undefined ? undefined : this.mapThread(row);
1334
1730
  }
1335
- getThreadDetail(id) {
1731
+ getThreadDetail(id, options = {}) {
1336
1732
  const resolved = this.resolveThreadId(id);
1337
1733
  const thread = this.getThread(resolved);
1338
1734
  if (thread === undefined)
1339
1735
  return undefined;
1340
- const page = this.listMessagesPage(resolved);
1736
+ const page = this.listMessagesPage(resolved, { limit: options.limit ?? WEB_MESSAGE_PAGE_DEFAULT });
1341
1737
  return {
1342
1738
  thread,
1343
1739
  messages: page.messages,
@@ -1345,7 +1741,7 @@ export class WebStore {
1345
1741
  };
1346
1742
  }
1347
1743
  getMessage(id) {
1348
- const row = this.database.prepare("SELECT * FROM messages WHERE id = ?")
1744
+ const row = this.database.prepare(`SELECT * FROM messages WHERE id = ? AND ${visibleMessageSql("messages")}`)
1349
1745
  .get(id);
1350
1746
  return row === undefined ? undefined : this.mapMessage(row);
1351
1747
  }
@@ -1370,10 +1766,11 @@ export class WebStore {
1370
1766
  }
1371
1767
  values.push(limit + 1);
1372
1768
  const rows = this.database.prepare(`
1373
- SELECT m.*, ${orderedAt} AS ordered_at, ${rank} AS role_rank, m.rowid AS storage_rowid
1769
+ SELECT m.*, ${orderedAt} AS ordered_at, ${rank} AS role_rank, m.rowid AS storage_rowid,
1770
+ t.finished_at AS turn_finished_at
1374
1771
  FROM messages m
1375
1772
  LEFT JOIN turns t ON t.id = m.turn_id
1376
- WHERE m.thread_id = ? ${beforeSql}
1773
+ WHERE m.thread_id = ? AND ${visibleMessageSql("m")} ${beforeSql}
1377
1774
  ORDER BY ordered_at DESC, role_rank DESC, m.created_at DESC, storage_rowid DESC
1378
1775
  LIMIT ?
1379
1776
  `).all(...values);
@@ -1515,7 +1912,7 @@ export class WebStore {
1515
1912
  });
1516
1913
  return changed ? this.requireThread(id) : undefined;
1517
1914
  }
1518
- async deleteArchivedThread(id) {
1915
+ async deleteArchivedThread(id, options = {}) {
1519
1916
  id = this.resolveThreadId(id);
1520
1917
  const thread = this.requireThread(id);
1521
1918
  if (thread.archivedAt === null) {
@@ -1529,8 +1926,33 @@ export class WebStore {
1529
1926
  const attachments = this.database.prepare("SELECT * FROM attachments WHERE thread_id = ?")
1530
1927
  .all(id);
1531
1928
  this.transaction(() => {
1929
+ if (options.emptyOnly === true) {
1930
+ const hasContent = thread.trigger !== undefined || this.database.prepare(`
1931
+ SELECT 1 FROM messages WHERE thread_id = ?
1932
+ UNION ALL SELECT 1 FROM turns WHERE thread_id = ?
1933
+ UNION ALL SELECT 1 FROM attachments WHERE thread_id = ?
1934
+ UNION ALL SELECT 1 FROM live_inputs WHERE thread_id = ?
1935
+ UNION ALL SELECT 1 FROM web_submissions WHERE thread_id = ?
1936
+ UNION ALL SELECT 1 FROM cron_reply_operations WHERE thread_id = ? AND state = 'completed'
1937
+ UNION ALL SELECT 1 FROM process_job_wake_deliveries WHERE thread_id = ?
1938
+ UNION ALL SELECT 1 FROM monitor_wake_deliveries WHERE thread_id = ?
1939
+ UNION ALL SELECT 1 FROM notification_deliveries WHERE thread_id = ?
1940
+ UNION ALL SELECT 1 FROM push_events WHERE thread_id = ?
1941
+ LIMIT 1
1942
+ `).get(id, id, id, id, id, id, id, id, id, id) !== undefined;
1943
+ if (hasContent) {
1944
+ throw new WebConsoleError("thread_not_empty", "The conversation now contains activity and was kept in Archived.", 409);
1945
+ }
1946
+ }
1532
1947
  const now = this.now();
1533
1948
  this.database.prepare(`
1949
+ UPDATE cron_reply_operations SET state = 'tombstoned', provenance_message_id = NULL,
1950
+ result_message_id = NULL, snapshot_text = NULL, snapshot_sha256 = NULL,
1951
+ title = NULL, run_model = NULL, run_effort = NULL, completed_at = NULL,
1952
+ failure_reason = 'thread_deleted', tombstoned_at = ?
1953
+ WHERE thread_id = ? AND state = 'completed'
1954
+ `).run(now, id);
1955
+ this.database.prepare(`
1534
1956
  UPDATE push_deliveries SET status = 'dropped', updated_at = ?, finished_at = ?, last_error_code = 'thread_deleted'
1535
1957
  WHERE event_id IN (SELECT id FROM push_events WHERE thread_id = ?)
1536
1958
  AND status IN ('pending', 'sending')
@@ -1731,7 +2153,7 @@ export class WebStore {
1731
2153
  if (input.quote.text.trim().length === 0 || input.quote.messageId.trim().length === 0) {
1732
2154
  throw new WebConsoleError("invalid_quote", "Quoted text and its source message are required.", 400);
1733
2155
  }
1734
- const source = this.database.prepare("SELECT id FROM messages WHERE id = ? AND thread_id = ?").get(input.quote.messageId, threadId);
2156
+ const source = this.database.prepare(`SELECT id FROM messages WHERE id = ? AND thread_id = ? AND ${visibleMessageSql("messages")}`).get(input.quote.messageId, threadId);
1735
2157
  if (source === undefined) {
1736
2158
  throw new WebConsoleError("invalid_quote", "The quoted message does not belong to this conversation.", 400);
1737
2159
  }
@@ -1743,10 +2165,10 @@ export class WebStore {
1743
2165
  this.transaction(() => {
1744
2166
  this.database.prepare(`
1745
2167
  INSERT INTO turns (
1746
- id, thread_id, status, text, model, effort, assistant_message_id,
2168
+ id, thread_id, status, text, model, effort, requested_model, requested_effort, assistant_message_id,
1747
2169
  started_at, finished_at, error_code, error_message
1748
- ) VALUES (?, ?, 'running', ?, ?, ?, ?, ?, NULL, NULL, NULL)
1749
- `).run(turnId, threadId, input.text, input.model ?? null, input.effort ?? null, assistantMessageId, now);
2170
+ ) VALUES (?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL)
2171
+ `).run(turnId, threadId, input.text, input.model ?? null, input.effort ?? null, input.requestedModel ?? null, input.requestedEffort ?? null, assistantMessageId, now);
1750
2172
  const userParts = [
1751
2173
  ...(input.quote === undefined
1752
2174
  ? []
@@ -1812,17 +2234,38 @@ export class WebStore {
1812
2234
  const turnId = randomUUID();
1813
2235
  const assistantMessageId = randomUUID();
1814
2236
  const now = this.now();
2237
+ if (input.processJobWake !== undefined) {
2238
+ const card = this.database.prepare(`
2239
+ SELECT 1 FROM process_job_cards AS cards
2240
+ JOIN threads ON threads.id = cards.thread_id AND threads.source_id = cards.source_id
2241
+ JOIN process_job_wake_deliveries AS deliveries
2242
+ ON deliveries.source_id = cards.source_id
2243
+ AND deliveries.job_id = cards.job_id
2244
+ AND deliveries.delivery_key = cards.delivery_key
2245
+ WHERE cards.thread_id = ? AND cards.job_id = ? AND cards.delivery_key = ?
2246
+ AND deliveries.state = 'accepted' AND deliveries.turn_id IS NULL
2247
+ `).get(threadId, input.processJobWake.jobId, input.processJobWake.deliveryKey);
2248
+ if (card === undefined) {
2249
+ throw new WebConsoleError("invalid_notification", "The process-job wake does not match its retained card.", 409);
2250
+ }
2251
+ }
2252
+ const initialParts = input.processJobWake === undefined
2253
+ ? []
2254
+ : [{ type: "process-job-wake", ...input.processJobWake }];
1815
2255
  this.transaction(() => {
1816
2256
  this.database.prepare(`
1817
2257
  INSERT INTO turns (
1818
- id, thread_id, status, text, model, effort, assistant_message_id,
2258
+ id, thread_id, status, text, model, effort, requested_model, requested_effort, assistant_message_id,
1819
2259
  started_at, finished_at, error_code, error_message
1820
- ) VALUES (?, ?, 'running', ?, NULL, NULL, ?, ?, NULL, NULL, NULL)
1821
- `).run(turnId, threadId, input.storedPrompt ?? input.prompt, assistantMessageId, now);
2260
+ ) VALUES (?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL)
2261
+ `).run(turnId, threadId, input.storedPrompt ?? input.prompt, input.model ?? null, input.effort ?? null, input.requestedModel ?? null, input.requestedEffort ?? null, assistantMessageId, now);
1822
2262
  this.database.prepare(`
1823
2263
  INSERT INTO messages (id, thread_id, turn_id, role, parts_json, created_at, updated_at, status)
1824
- VALUES (?, ?, ?, 'assistant', '[]', ?, ?, 'running')
1825
- `).run(assistantMessageId, threadId, turnId, now, now);
2264
+ VALUES (?, ?, ?, 'assistant', ?, ?, ?, 'running')
2265
+ `).run(assistantMessageId, threadId, turnId, serializeParts(initialParts), now, now);
2266
+ if (input.processJobWake !== undefined) {
2267
+ this.associateProcessJobWakeTurn(input.processJobWake.deliveryKey, turnId);
2268
+ }
1826
2269
  this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?").run(now, threadId);
1827
2270
  this.recordThreadRevision(threadId, "background_follow_up_started", now);
1828
2271
  this.setSetting("current_thread_id", threadId);
@@ -1836,7 +2279,7 @@ export class WebStore {
1836
2279
  thread: this.requireThread(threadId),
1837
2280
  };
1838
2281
  }
1839
- reserveLiveInput(threadId, text) {
2282
+ reserveLiveInput(threadId, text, quote, operatorText = text) {
1840
2283
  threadId = this.resolveThreadId(threadId);
1841
2284
  const thread = this.requireThread(threadId);
1842
2285
  if (thread.archivedAt !== null) {
@@ -1850,9 +2293,15 @@ export class WebStore {
1850
2293
  if (text.trim().length === 0) {
1851
2294
  throw new WebConsoleError("empty_turn", "Enter a message.", 400);
1852
2295
  }
1853
- if (text.length > AGENT_LIVE_INPUT_MAX_CHARACTERS) {
2296
+ if (operatorText.length > AGENT_LIVE_INPUT_MAX_CHARACTERS) {
1854
2297
  throw new WebConsoleError("turn_text_too_large", `A live follow-up may contain at most ${AGENT_LIVE_INPUT_MAX_CHARACTERS} characters.`, 413);
1855
2298
  }
2299
+ if (quote !== undefined) {
2300
+ const source = this.database.prepare(`SELECT id FROM messages WHERE id = ? AND thread_id = ? AND ${visibleMessageSql("messages")}`).get(quote.messageId, threadId);
2301
+ if (quote.text.trim().length === 0 || source === undefined) {
2302
+ throw new WebConsoleError("invalid_quote", "The quoted message does not belong to this conversation.", 400);
2303
+ }
2304
+ }
1856
2305
  const usage = this.database.prepare("SELECT COUNT(*) AS count FROM live_inputs WHERE thread_id = ?").get(threadId);
1857
2306
  if (usage.count >= WEB_MAX_LIVE_INPUTS_PER_THREAD) {
1858
2307
  throw new WebConsoleError("live_input_queue_full", "Too many follow-up messages are waiting.", 429);
@@ -1862,8 +2311,18 @@ export class WebStore {
1862
2311
  const messageId = randomUUID();
1863
2312
  const now = this.now();
1864
2313
  const status = active === undefined ? "queued" : "offered";
2314
+ // An idle forced steer becomes an ordinary turn, so freeze the thread's
2315
+ // selected route now rather than consulting a potentially changed
2316
+ // override when the queue drains. An active turn owns its own route,
2317
+ // including explicit null/default values; never fall through from those
2318
+ // nulls to the thread override.
2319
+ const model = active === undefined ? thread.runModel : active.model;
2320
+ const effort = active === undefined ? thread.runEffort : active.effort;
1865
2321
  const parts = [
1866
2322
  liveInputTelemetry(status === "offered" ? "pending" : "queued"),
2323
+ ...(quote === undefined
2324
+ ? []
2325
+ : [{ type: "telemetry", event: QUOTE_TELEMETRY_EVENT, data: quote }]),
1867
2326
  { type: "text", text },
1868
2327
  ];
1869
2328
  this.transaction(() => {
@@ -1875,7 +2334,7 @@ export class WebStore {
1875
2334
  INSERT INTO live_inputs (
1876
2335
  id, thread_id, message_id, active_turn_id, text, model, effort, status, created_at, updated_at
1877
2336
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1878
- `).run(id, threadId, messageId, active?.id ?? null, text, active?.model ?? null, active?.effort ?? null, status, now, now);
2337
+ `).run(id, threadId, messageId, active?.id ?? null, operatorText, model, effort, status, now, now);
1879
2338
  const title = deriveAutomaticTitle(text, []);
1880
2339
  this.database.prepare(`
1881
2340
  UPDATE threads
@@ -1901,8 +2360,7 @@ export class WebStore {
1901
2360
  const message = this.requireMessage(row.message_id);
1902
2361
  const now = this.now();
1903
2362
  this.transaction(() => {
1904
- this.database.prepare("UPDATE messages SET parts_json = ?, updated_at = ? WHERE id = ?")
1905
- .run(serializeParts(withLiveInputStatus(message.parts, "applied")), now, row.message_id);
2363
+ this.writeMessageParts(row.message_id, withLiveInputStatus(message.parts, "applied"), now);
1906
2364
  this.database.prepare("DELETE FROM live_inputs WHERE id = ?").run(id);
1907
2365
  this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
1908
2366
  .run(now, row.thread_id);
@@ -1910,7 +2368,36 @@ export class WebStore {
1910
2368
  });
1911
2369
  return this.requireMessage(row.message_id);
1912
2370
  }
1913
- queueLiveInput(id) {
2371
+ storedLiveInput(id) {
2372
+ const row = this.getLiveInput(id);
2373
+ return row === undefined ? undefined : mapLiveInput(row);
2374
+ }
2375
+ markLiveInputUncertain(id) {
2376
+ const row = this.getLiveInput(id);
2377
+ if (row === undefined)
2378
+ return undefined;
2379
+ const message = this.requireMessage(row.message_id);
2380
+ const now = this.now();
2381
+ this.transaction(() => {
2382
+ this.writeMessageParts(row.message_id, withLiveInputStatus(message.parts, "uncertain"), now, { turnId: null });
2383
+ this.database.prepare("DELETE FROM live_inputs WHERE id = ?").run(id);
2384
+ this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
2385
+ .run(now, row.thread_id);
2386
+ this.recordThreadRevision(row.thread_id, "live_input_uncertain", now);
2387
+ });
2388
+ return this.requireMessage(row.message_id);
2389
+ }
2390
+ markLiveInputDispatchStarted(id, activeTurnId) {
2391
+ const now = this.now();
2392
+ return this.transaction(() => {
2393
+ const result = this.database.prepare(`
2394
+ UPDATE live_inputs SET dispatch_started_at = ?, updated_at = ?
2395
+ WHERE id = ? AND status = 'offered' AND active_turn_id = ? AND dispatch_started_at IS NULL
2396
+ `).run(now, now, id, activeTurnId);
2397
+ return result.changes === 1;
2398
+ });
2399
+ }
2400
+ queueLiveInput(id, submissionReason) {
1914
2401
  const row = this.getLiveInput(id);
1915
2402
  if (row === undefined)
1916
2403
  return undefined;
@@ -1918,10 +2405,17 @@ export class WebStore {
1918
2405
  const now = this.now();
1919
2406
  this.transaction(() => {
1920
2407
  this.database.prepare(`
1921
- UPDATE live_inputs SET status = 'queued', active_turn_id = NULL, updated_at = ? WHERE id = ?
2408
+ UPDATE live_inputs
2409
+ SET status = 'queued', active_turn_id = NULL, dispatch_started_at = NULL, updated_at = ?
2410
+ WHERE id = ?
1922
2411
  `).run(now, id);
1923
- this.database.prepare("UPDATE messages SET turn_id = NULL, parts_json = ?, updated_at = ? WHERE id = ?")
1924
- .run(serializeParts(withLiveInputStatus(message.parts, "queued")), now, row.message_id);
2412
+ if (submissionReason !== undefined) {
2413
+ this.database.prepare(`
2414
+ UPDATE web_submissions SET reason = ?
2415
+ WHERE input_id = ? AND outcome = 'live-input' AND reason IS NULL
2416
+ `).run(submissionReason, id);
2417
+ }
2418
+ this.writeMessageParts(row.message_id, withLiveInputStatus(message.parts, "queued"), now, { turnId: null });
1925
2419
  this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
1926
2420
  .run(now, row.thread_id);
1927
2421
  this.recordThreadRevision(row.thread_id, "live_input_queued", now);
@@ -1935,8 +2429,7 @@ export class WebStore {
1935
2429
  const message = this.requireMessage(row.message_id);
1936
2430
  const now = this.now();
1937
2431
  this.transaction(() => {
1938
- this.database.prepare("UPDATE messages SET turn_id = NULL, parts_json = ?, updated_at = ? WHERE id = ?")
1939
- .run(serializeParts(withLiveInputStatus(message.parts, "cancelled")), now, row.message_id);
2432
+ this.writeMessageParts(row.message_id, withLiveInputStatus(message.parts, "cancelled"), now, { turnId: null });
1940
2433
  this.database.prepare("DELETE FROM live_inputs WHERE id = ?").run(id);
1941
2434
  this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
1942
2435
  .run(now, row.thread_id);
@@ -1951,15 +2444,16 @@ export class WebStore {
1951
2444
  return [];
1952
2445
  const now = this.now();
1953
2446
  this.transaction(() => {
1954
- const update = this.database.prepare("UPDATE messages SET turn_id = NULL, parts_json = ?, updated_at = ? WHERE id = ?");
1955
2447
  for (const row of rows) {
1956
2448
  const message = this.requireMessage(row.message_id);
1957
- update.run(serializeParts(withLiveInputStatus(message.parts, "cancelled")), now, row.message_id);
2449
+ this.writeMessageParts(row.message_id, withLiveInputStatus(message.parts, row.dispatch_started_at === null ? "cancelled" : "uncertain"), now, { turnId: null });
1958
2450
  }
1959
2451
  this.database.prepare("DELETE FROM live_inputs WHERE thread_id = ?").run(threadId);
1960
2452
  this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
1961
2453
  .run(now, threadId);
1962
- this.recordThreadRevision(threadId, "live_inputs_cancelled", now);
2454
+ this.recordThreadRevision(threadId, rows.some((row) => row.dispatch_started_at !== null)
2455
+ ? "live_inputs_cancelled_with_uncertainty"
2456
+ : "live_inputs_cancelled", now);
1963
2457
  });
1964
2458
  return rows.map((row) => this.requireMessage(row.message_id));
1965
2459
  }
@@ -1991,12 +2485,11 @@ export class WebStore {
1991
2485
  this.transaction(() => {
1992
2486
  this.database.prepare(`
1993
2487
  INSERT INTO turns (
1994
- id, thread_id, status, text, model, effort, assistant_message_id,
2488
+ id, thread_id, status, text, model, effort, requested_model, requested_effort, assistant_message_id,
1995
2489
  started_at, finished_at, error_code, error_message
1996
- ) VALUES (?, ?, 'running', ?, ?, ?, ?, ?, NULL, NULL, NULL)
1997
- `).run(turnId, threadId, row.text, row.model, row.effort, assistantMessageId, now);
1998
- this.database.prepare("UPDATE messages SET turn_id = ?, parts_json = ?, updated_at = ? WHERE id = ?")
1999
- .run(turnId, serializeParts(withoutLiveInputTelemetry(userMessage.parts)), now, row.message_id);
2490
+ ) VALUES (?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL)
2491
+ `).run(turnId, threadId, row.text, row.model, row.effort, row.model, row.effort, assistantMessageId, now);
2492
+ this.writeMessageParts(row.message_id, withoutLiveInputTelemetry(userMessage.parts), now, { turnId });
2000
2493
  this.database.prepare(`
2001
2494
  INSERT INTO messages (id, thread_id, turn_id, role, parts_json, created_at, updated_at, status)
2002
2495
  VALUES (?, ?, ?, 'assistant', '[]', ?, ?, 'running')
@@ -2016,55 +2509,131 @@ export class WebStore {
2016
2509
  thread: this.requireThread(threadId),
2017
2510
  };
2018
2511
  }
2019
- applyStreamFrame(turnId, frame) {
2020
- return this.applyStreamFrames(turnId, [frame]);
2021
- }
2022
2512
  applyStreamFrames(turnId, frames) {
2023
2513
  const turn = this.requireTurn(turnId);
2514
+ // Nothing is written for a turn that already settled, so there is no
2515
+ // version for a delta to name.
2024
2516
  if (turn.status !== "running")
2025
- return this.requireMessage(turn.assistant_message_id);
2026
- const message = this.requireMessage(turn.assistant_message_id);
2027
- const parts = [...message.parts];
2028
- let actualModel;
2029
- let actualEffort;
2030
- for (const frame of frames) {
2031
- if (frame.kind === "status") {
2032
- parts.push({ type: "telemetry", event: "status", data: { text: frame.text } });
2033
- }
2034
- else if (frame.kind === "append") {
2035
- appendTextPart(parts, "text", frame.delta);
2036
- }
2037
- else if (frame.kind === "replace") {
2038
- replaceWholeText(parts, frame.text);
2039
- }
2040
- else if (frame.kind === "event") {
2041
- applyEvent(parts, frame.event, (deliveryKey) => this.monitorWakeProjection(turnId, deliveryKey));
2042
- if (frame.event.type === "runtime_telemetry" && frame.event.kind === "run_config") {
2043
- if (typeof frame.event.data?.model === "string")
2044
- actualModel = frame.event.data.model;
2045
- if (typeof frame.event.data?.effort === "string")
2046
- actualEffort = frame.event.data.effort;
2517
+ return { message: this.requireMessage(turn.assistant_message_id) };
2518
+ // The base read, the frames applied to it and the write are ONE atomic
2519
+ // span, as they already are on the finish path. A delta whose ops were
2520
+ // diffed against a version other than the one its `baseSeq` names is
2521
+ // self-consistent and WRONG -- the one corruption a sequence number cannot
2522
+ // expose, because the console would apply it without complaint.
2523
+ const write = this.transaction(() => {
2524
+ const message = this.requireMessage(turn.assistant_message_id);
2525
+ const parts = [...message.parts];
2526
+ let actualModel;
2527
+ let actualEffort;
2528
+ let actualEffectiveEffort;
2529
+ let clearEffort = false;
2530
+ let clearEffectiveEffort = false;
2531
+ let routing = parseRoutingState(turn.routing_json);
2532
+ let attributionChanged = false;
2533
+ for (const frame of frames) {
2534
+ if (frame.kind === "status") {
2535
+ parts.push({ type: "telemetry", event: "status", data: { text: frame.text } });
2536
+ }
2537
+ else if (frame.kind === "append") {
2538
+ appendTextPart(parts, "text", frame.delta);
2539
+ }
2540
+ else if (frame.kind === "replace") {
2541
+ replaceWholeText(parts, frame.text);
2542
+ }
2543
+ else if (frame.kind === "event") {
2544
+ applyEvent(parts, frame.event, (deliveryKey) => this.monitorWakeProjection(turnId, deliveryKey), (deliveryKey) => this.processJobWakeForTurn(turnId, deliveryKey));
2545
+ if (frame.event.type === "runtime_telemetry" && frame.event.kind === "run_config") {
2546
+ const model = canonicalRouteString(frame.event.data?.model);
2547
+ const effort = canonicalRouteString(frame.event.data?.effort, 64);
2548
+ if (model !== undefined)
2549
+ actualModel = model;
2550
+ if (effort !== undefined) {
2551
+ actualEffort = effort;
2552
+ clearEffort = false;
2553
+ }
2554
+ attributionChanged ||= model !== undefined || effort !== undefined;
2555
+ }
2556
+ if (frame.event.type === "runtime_telemetry" && frame.event.kind === "provider_execution_config") {
2557
+ const model = canonicalRouteString(frame.event.data?.model);
2558
+ const effort = canonicalRouteString(frame.event.data?.effort, 64);
2559
+ const effectiveEffort = canonicalRouteString(frame.event.data?.effectiveEffort, 64);
2560
+ if (model !== undefined)
2561
+ actualModel = model;
2562
+ if (effort !== undefined)
2563
+ actualEffort = effort;
2564
+ if (effectiveEffort !== undefined)
2565
+ actualEffectiveEffort = effectiveEffort;
2566
+ clearEffort = effort === undefined;
2567
+ clearEffectiveEffort = effectiveEffort === undefined;
2568
+ attributionChanged ||= model !== undefined || effort !== undefined || effectiveEffort !== undefined;
2569
+ }
2570
+ if (frame.event.type === "provider_status") {
2571
+ if (frame.event.kind === "failover_started") {
2572
+ const from = canonicalRouteString(frame.event.from);
2573
+ const to = canonicalRouteString(frame.event.to);
2574
+ if (from !== undefined && to !== undefined) {
2575
+ const attemptIndex = canonicalRouteIndex(frame.event.attemptIndex);
2576
+ const reason = canonicalRouteString(frame.event.reason, 128);
2577
+ routing = appendRouteTransition(routing, {
2578
+ from,
2579
+ to,
2580
+ ...(attemptIndex === undefined ? {} : { attemptIndex }),
2581
+ ...(reason === undefined ? {} : { reason }),
2582
+ });
2583
+ actualModel = to;
2584
+ clearEffort = true;
2585
+ clearEffectiveEffort = true;
2586
+ attributionChanged = true;
2587
+ }
2588
+ }
2589
+ else if (frame.event.kind === "retry_started") {
2590
+ const model = canonicalRouteString(frame.event.model);
2591
+ const retryIndex = canonicalRouteIndex(frame.event.retryIndex);
2592
+ const reason = canonicalRouteString(frame.event.reason, 128);
2593
+ routing = appendRouteRetry(routing, {
2594
+ ...(model === undefined ? {} : { model }),
2595
+ ...(retryIndex === undefined ? {} : { retryIndex }),
2596
+ ...(reason === undefined ? {} : { reason }),
2597
+ });
2598
+ attributionChanged = true;
2599
+ }
2600
+ else if (frame.event.kind === "failover_completed") {
2601
+ const model = canonicalRouteString(frame.event.model);
2602
+ if (model !== undefined) {
2603
+ actualModel = model;
2604
+ attributionChanged = true;
2605
+ }
2606
+ }
2607
+ else if (frame.event.kind === "request_started") {
2608
+ const model = canonicalRouteString(frame.event.model);
2609
+ if (model !== undefined) {
2610
+ actualModel = model;
2611
+ attributionChanged = true;
2612
+ }
2613
+ }
2614
+ }
2047
2615
  }
2048
2616
  }
2049
- }
2050
- const now = this.now();
2051
- this.transaction(() => {
2052
- this.database.prepare("UPDATE messages SET parts_json = ?, updated_at = ? WHERE id = ?")
2053
- .run(serializeParts(parts), now, message.id);
2054
- if (actualModel !== undefined || actualEffort !== undefined) {
2617
+ if (actualModel !== undefined || actualEffort !== undefined || actualEffectiveEffort !== undefined || attributionChanged) {
2055
2618
  this.database.prepare(`
2056
2619
  UPDATE turns SET
2057
2620
  model = CASE WHEN ? IS NULL THEN model ELSE ? END,
2058
- effort = CASE WHEN ? IS NULL THEN effort ELSE ? END
2621
+ effort = CASE WHEN ? = 1 THEN NULL WHEN ? IS NULL THEN effort ELSE ? END,
2622
+ effective_effort = CASE WHEN ? = 1 THEN NULL WHEN ? IS NULL THEN effective_effort ELSE ? END,
2623
+ routing_json = ?
2059
2624
  WHERE id = ?
2060
- `).run(actualModel ?? null, actualModel ?? null, actualEffort ?? null, actualEffort ?? null, turnId);
2625
+ `).run(actualModel ?? null, actualModel ?? null, clearEffort ? 1 : 0, actualEffort ?? null, actualEffort ?? null, clearEffectiveEffort ? 1 : 0, actualEffectiveEffort ?? null, actualEffectiveEffort ?? null, serializeRoutingState(routing), turnId);
2061
2626
  }
2627
+ return {
2628
+ delta: this.writeMessageDelta(message, parts, this.now()),
2629
+ ...(attributionChanged ? { attributionChanged: true } : {}),
2630
+ };
2062
2631
  });
2063
- return this.requireMessage(message.id);
2632
+ return { message: this.requireMessage(turn.assistant_message_id), ...write };
2064
2633
  }
2065
2634
  completeTurn(turnId, finalText, metadata, replyParts, options = {}) {
2066
2635
  const runtime = runtimeMetadata(metadata);
2067
- return this.finishTurn(turnId, "complete", finalText, undefined, undefined, runtime, replyParts, options.suppressResponsePush === true);
2636
+ return this.finishTurn(turnId, "complete", finalText, undefined, undefined, runtime, replyParts, options.suppressResponsePush === true, options.monitorWakeDeliveryKey);
2068
2637
  }
2069
2638
  failTurn(turnId, error) {
2070
2639
  return this.finishTurn(turnId, error.cancelled === true ? "cancelled" : "failed", undefined, error.code, error.message, undefined);
@@ -2297,6 +2866,16 @@ export class WebStore {
2297
2866
  JOIN push_events e ON e.id = d.event_id
2298
2867
  JOIN push_subscriptions s ON s.id = d.subscription_id
2299
2868
  WHERE d.status = 'pending' AND d.next_attempt_at <= ? AND e.expires_at > ? AND s.state = 'active'
2869
+ AND NOT EXISTS (
2870
+ SELECT 1 FROM monitor_wake_deliveries m
2871
+ WHERE e.kind = 'response.ready' AND m.state = 'accepted' AND m.turn_id IS NOT NULL
2872
+ AND e.logical_key = 'turn:' || m.turn_id || ':terminal'
2873
+ )
2874
+ AND NOT EXISTS (
2875
+ SELECT 1 FROM process_job_wake_deliveries j
2876
+ WHERE e.kind = 'response.ready' AND j.state = 'accepted' AND j.turn_id IS NOT NULL
2877
+ AND e.logical_key = 'turn:' || j.turn_id || ':terminal'
2878
+ )
2300
2879
  ORDER BY d.next_attempt_at, d.created_at, d.rowid
2301
2880
  LIMIT ?
2302
2881
  `).all(now, now, limit);
@@ -2419,6 +2998,7 @@ export class WebStore {
2419
2998
  if (versionRow.user_version < 0) {
2420
2999
  throw new WebConsoleError("storage_corrupt", "Web state schema version is invalid.", 500);
2421
3000
  }
3001
+ validateWebStorageMigrationRegistry();
2422
3002
  const migrating = versionRow.user_version < WEB_STORAGE_SCHEMA_VERSION;
2423
3003
  if (migrating)
2424
3004
  this.database.exec("BEGIN IMMEDIATE");
@@ -2428,8 +3008,10 @@ export class WebStore {
2428
3008
  source_id TEXT PRIMARY KEY,
2429
3009
  label TEXT NOT NULL,
2430
3010
  status TEXT NOT NULL,
3011
+ discovered INTEGER NOT NULL DEFAULT 1 CHECK (discovered IN (0, 1)),
2431
3012
  health TEXT,
2432
3013
  supports_attachments INTEGER NOT NULL DEFAULT 0,
3014
+ supports_provider_auth INTEGER NOT NULL DEFAULT 0 CHECK (supports_provider_auth IN (0, 1)),
2433
3015
  models_json TEXT,
2434
3016
  default_model TEXT,
2435
3017
  default_effort TEXT,
@@ -2441,6 +3023,13 @@ export class WebStore {
2441
3023
  ask_by_id INTEGER NOT NULL DEFAULT 0,
2442
3024
  updated_at TEXT NOT NULL
2443
3025
  );
3026
+ CREATE TABLE IF NOT EXISTS agent_run_overrides (
3027
+ source_id TEXT PRIMARY KEY REFERENCES agents(source_id) ON DELETE CASCADE,
3028
+ model TEXT,
3029
+ effort TEXT,
3030
+ updated_at TEXT NOT NULL,
3031
+ CHECK (model IS NOT NULL OR effort IS NOT NULL)
3032
+ );
2444
3033
  CREATE TABLE IF NOT EXISTS threads (
2445
3034
  id TEXT PRIMARY KEY,
2446
3035
  source_id TEXT NOT NULL REFERENCES agents(source_id),
@@ -2462,6 +3051,10 @@ export class WebStore {
2462
3051
  text TEXT NOT NULL,
2463
3052
  model TEXT,
2464
3053
  effort TEXT,
3054
+ requested_model TEXT,
3055
+ requested_effort TEXT,
3056
+ effective_effort TEXT,
3057
+ routing_json TEXT NOT NULL DEFAULT '{"transitions":[],"retries":[]}',
2465
3058
  assistant_message_id TEXT NOT NULL,
2466
3059
  started_at TEXT NOT NULL,
2467
3060
  finished_at TEXT,
@@ -2470,6 +3063,7 @@ export class WebStore {
2470
3063
  );
2471
3064
  CREATE UNIQUE INDEX IF NOT EXISTS turns_one_active_per_thread
2472
3065
  ON turns(thread_id) WHERE status = 'running';
3066
+ CREATE INDEX IF NOT EXISTS turns_by_thread_started ON turns(thread_id, started_at);
2473
3067
  CREATE TABLE IF NOT EXISTS messages (
2474
3068
  id TEXT PRIMARY KEY,
2475
3069
  thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
@@ -2478,9 +3072,11 @@ export class WebStore {
2478
3072
  parts_json TEXT NOT NULL,
2479
3073
  created_at TEXT NOT NULL,
2480
3074
  updated_at TEXT NOT NULL,
2481
- status TEXT NOT NULL
3075
+ status TEXT NOT NULL,
3076
+ seq INTEGER NOT NULL DEFAULT 0
2482
3077
  );
2483
3078
  CREATE INDEX IF NOT EXISTS messages_by_thread ON messages(thread_id, created_at);
3079
+ CREATE INDEX IF NOT EXISTS messages_by_turn ON messages(turn_id);
2484
3080
  CREATE TABLE IF NOT EXISTS live_inputs (
2485
3081
  id TEXT PRIMARY KEY,
2486
3082
  thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
@@ -2490,11 +3086,63 @@ export class WebStore {
2490
3086
  model TEXT,
2491
3087
  effort TEXT,
2492
3088
  status TEXT NOT NULL CHECK (status IN ('offered', 'queued')),
3089
+ dispatch_started_at TEXT,
2493
3090
  created_at TEXT NOT NULL,
2494
3091
  updated_at TEXT NOT NULL
2495
3092
  );
2496
3093
  CREATE INDEX IF NOT EXISTS live_inputs_by_thread
2497
3094
  ON live_inputs(thread_id, status, created_at);
3095
+ CREATE TABLE IF NOT EXISTS web_submissions (
3096
+ thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
3097
+ submission_id TEXT NOT NULL,
3098
+ payload_sha256 TEXT NOT NULL,
3099
+ outcome TEXT NOT NULL CHECK (outcome IN ('turn', 'live-input', 'rejected')),
3100
+ reason TEXT CHECK (reason IN (
3101
+ 'active_attachments_unsupported', 'unsupported_targeting', 'closed_before_dispatch',
3102
+ 'operator_inactive', 'operator_unsupported', 'operator_too_large', 'operator_full', 'operator_invalid',
3103
+ 'mailbox_unsupported', 'mailbox_closed', 'mailbox_failed'
3104
+ )),
3105
+ message_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
3106
+ turn_id TEXT REFERENCES turns(id) ON DELETE SET NULL,
3107
+ input_id TEXT REFERENCES live_inputs(id) ON DELETE SET NULL,
3108
+ created_at TEXT NOT NULL,
3109
+ PRIMARY KEY (thread_id, submission_id)
3110
+ );
3111
+ CREATE TABLE IF NOT EXISTS cron_reply_operations (
3112
+ operation_id TEXT PRIMARY KEY,
3113
+ source_id TEXT NOT NULL REFERENCES agents(source_id),
3114
+ job_id TEXT NOT NULL,
3115
+ run_id TEXT NOT NULL,
3116
+ thread_id TEXT NOT NULL UNIQUE,
3117
+ conversation_id TEXT NOT NULL UNIQUE,
3118
+ provenance_message_id TEXT UNIQUE,
3119
+ result_message_id TEXT UNIQUE,
3120
+ idempotency_key TEXT NOT NULL UNIQUE,
3121
+ state TEXT NOT NULL CHECK (state IN ('pending', 'completed', 'failed', 'tombstoned')),
3122
+ snapshot_kind TEXT NOT NULL CHECK (snapshot_kind IN ('summary', 'detail')),
3123
+ snapshot_text TEXT,
3124
+ snapshot_sha256 TEXT,
3125
+ title TEXT,
3126
+ run_model TEXT,
3127
+ run_effort TEXT,
3128
+ canonical_status TEXT CHECK (canonical_status IN ('appended', 'duplicate')),
3129
+ failure_reason TEXT,
3130
+ created_at TEXT NOT NULL,
3131
+ completed_at TEXT,
3132
+ failed_at TEXT,
3133
+ tombstoned_at TEXT,
3134
+ CHECK (
3135
+ (state IN ('pending', 'completed') AND snapshot_text IS NOT NULL AND snapshot_sha256 IS NOT NULL
3136
+ AND title IS NOT NULL AND provenance_message_id IS NOT NULL AND result_message_id IS NOT NULL)
3137
+ OR (state IN ('failed', 'tombstoned') AND snapshot_text IS NULL AND snapshot_sha256 IS NULL
3138
+ AND title IS NULL AND provenance_message_id IS NULL AND result_message_id IS NULL)
3139
+ ),
3140
+ CHECK ((state = 'completed') = (completed_at IS NOT NULL)),
3141
+ CHECK ((state = 'failed') = (failed_at IS NOT NULL)),
3142
+ CHECK ((state = 'tombstoned') = (tombstoned_at IS NOT NULL))
3143
+ );
3144
+ CREATE UNIQUE INDEX IF NOT EXISTS cron_reply_operations_one_pending_run
3145
+ ON cron_reply_operations(source_id, job_id, run_id) WHERE state = 'pending';
2498
3146
  CREATE TABLE IF NOT EXISTS attachments (
2499
3147
  id TEXT PRIMARY KEY,
2500
3148
  thread_id TEXT REFERENCES threads(id) ON DELETE CASCADE,
@@ -2693,72 +3341,14 @@ export class WebStore {
2693
3341
  ON push_deliveries(status, next_attempt_at, created_at);
2694
3342
  ${MESSAGE_SEARCH_SCHEMA_SQL}
2695
3343
  `);
2696
- if (versionRow.user_version === 1) {
2697
- const columns = this.database.prepare("PRAGMA table_info(threads)").all();
2698
- if (!columns.some((column) => column.name === "trigger_kind")) {
2699
- this.database.exec("ALTER TABLE threads ADD COLUMN trigger_kind TEXT CHECK (trigger_kind IN ('cron', 'webhook'))");
2700
- }
2701
- }
2702
- if (versionRow.user_version < 5)
2703
- this.migrateCronChannels();
2704
- if (versionRow.user_version < 6) {
2705
- const columns = new Set(this.database.prepare("PRAGMA table_info(cron_overviews)").all()
2706
- .map((column) => column.name));
2707
- if (!columns.has("jobs_truncated")) {
2708
- this.database.exec("ALTER TABLE cron_overviews ADD COLUMN jobs_truncated INTEGER NOT NULL DEFAULT 0 CHECK (jobs_truncated IN (0, 1))");
2709
- }
2710
- }
2711
- // The search index derives from parts_json, so an existing database has
2712
- // to be swept once. Migration writes no message, so the triggers above
2713
- // cannot have fired yet; the clear-then-insert is a no-op on a fresh
2714
- // database and idempotent if the sweep is ever re-run.
2715
- if (versionRow.user_version < 9)
2716
- this.database.exec(MESSAGE_SEARCH_BACKFILL_SQL);
2717
- // Durable copies of agent-published images share the upload table so that
2718
- // thread deletion, archival file cleanup, and every existing purge surface
2719
- // reach them without a second store to keep in sync.
2720
- if (versionRow.user_version < 10) {
2721
- const columns = new Set(this.database.prepare("PRAGMA table_info(attachments)").all()
2722
- .map((column) => column.name));
2723
- if (!columns.has("origin")) {
2724
- this.database.exec("ALTER TABLE attachments ADD COLUMN origin TEXT NOT NULL DEFAULT 'upload' CHECK (origin IN ('upload', 'reply'))");
2725
- }
2726
- }
2727
- // Per-conversation model/effort overrides are server-persisted columns.
2728
- // Guard on PRAGMA table_info so the ALTER is skipped when the columns
2729
- // already exist, keeping the migration re-runnable.
2730
- if (versionRow.user_version < 11) {
2731
- const columns = new Set(this.database.prepare("PRAGMA table_info(threads)").all()
2732
- .map((column) => column.name));
2733
- if (!columns.has("run_model")) {
2734
- this.database.exec("ALTER TABLE threads ADD COLUMN run_model TEXT");
2735
- }
2736
- if (!columns.has("run_effort")) {
2737
- this.database.exec("ALTER TABLE threads ADD COLUMN run_effort TEXT");
2738
- }
2739
- }
2740
- // The provider summary an agent advertises. Guarded on PRAGMA
2741
- // table_info so the ALTER is skipped when the column already exists,
2742
- // keeping the migration re-runnable after an interrupted upgrade.
2743
- if (versionRow.user_version < 12) {
2744
- const columns = new Set(this.database.prepare("PRAGMA table_info(agents)").all()
2745
- .map((column) => column.name));
2746
- if (!columns.has("providers_json")) {
2747
- this.database.exec("ALTER TABLE agents ADD COLUMN providers_json TEXT");
2748
- }
2749
- }
2750
- // Schema v13 tied the Monitor idempotency ledger to the conversation
2751
- // with ON DELETE CASCADE. That erased the tombstone and allowed a
2752
- // delivery key to name different content after thread deletion.
2753
- if (versionRow.user_version === 13)
2754
- this.migrateMonitorWakeDeliveries();
2755
- if (versionRow.user_version < 15) {
2756
- const columns = new Set(this.database.prepare("PRAGMA table_info(monitor_wake_deliveries)").all()
2757
- .map((column) => column.name));
2758
- if (!columns.has("projection_json")) {
2759
- this.database.exec("ALTER TABLE monitor_wake_deliveries ADD COLUMN projection_json TEXT");
2760
- }
2761
- }
3344
+ runWebStorageMigrations({
3345
+ database: this.database,
3346
+ originalVersion: versionRow.user_version,
3347
+ migrateCronChannels: () => this.migrateCronChannels(),
3348
+ migrateMonitorWakeDeliveries: () => this.migrateMonitorWakeDeliveries(),
3349
+ suppressSilentCronHistory: () => this.suppressSilentCronHistory(),
3350
+ backfillMessageSearch: () => this.database.exec(MESSAGE_SEARCH_BACKFILL_SQL),
3351
+ });
2762
3352
  if (migrating)
2763
3353
  this.database.exec(`PRAGMA user_version = ${WEB_STORAGE_SCHEMA_VERSION}; COMMIT`);
2764
3354
  }
@@ -2775,8 +3365,55 @@ export class WebStore {
2775
3365
  throw new WebConsoleError("storage_corrupt", `Unable to initialize web state: ${error instanceof Error ? error.message : String(error)}`, 500);
2776
3366
  }
2777
3367
  }
3368
+ suppressSilentCronHistory() {
3369
+ const affected = new Set();
3370
+ let after = 0;
3371
+ while (true) {
3372
+ const rows = this.database.prepare(`
3373
+ SELECT r.rowid AS cursor, r.*, m.parts_json, m.cron_suppressed
3374
+ FROM cron_run_messages r JOIN messages m ON m.id = r.message_id
3375
+ WHERE r.rowid > ? ORDER BY r.rowid LIMIT 100
3376
+ `).all(after);
3377
+ if (rows.length === 0)
3378
+ break;
3379
+ for (const row of rows) {
3380
+ const run = parseStoredCronRun(row.payload_json);
3381
+ const parts = parseParts(row.parts_json);
3382
+ if (run.runId !== row.run_id || run.jobId !== row.job_id)
3383
+ throw new Error("Invalid cron mapping identity.");
3384
+ const delivered = this.database.prepare(`SELECT 1 FROM notification_deliveries
3385
+ WHERE source_id = ? AND job_id = ? AND run_id = ? AND message_id = ? AND completed_at IS NOT NULL
3386
+ `).get(row.source_id, row.job_id, row.run_id, row.message_id);
3387
+ const suppressed = definitelySilentCronRun(run) && delivered === undefined && !hasMeaningfulCronContent(parts)
3388
+ && this.database.prepare("SELECT 1 FROM attachments WHERE message_id = ? LIMIT 1").get(row.message_id) === undefined;
3389
+ if (row.cron_suppressed === 0 && suppressed) {
3390
+ this.database.prepare("UPDATE messages SET cron_suppressed = 1 WHERE id = ?").run(row.message_id);
3391
+ affected.add(row.thread_id);
3392
+ }
3393
+ else if (!suppressed) {
3394
+ // Visible ambiguous/delivered rows must not be hidden by old browser
3395
+ // telemetry guards. Keep their content, remove only the stale flag.
3396
+ const visibleParts = parts.map(clearSilentCronPart);
3397
+ if (visibleParts.some((part, index) => part !== parts[index])) {
3398
+ this.database.prepare("UPDATE messages SET parts_json = ? WHERE id = ?").run(serializeParts(visibleParts), row.message_id);
3399
+ affected.add(row.thread_id);
3400
+ }
3401
+ }
3402
+ after = row.cursor;
3403
+ }
3404
+ }
3405
+ for (const id of affected)
3406
+ this.database.prepare("UPDATE threads SET revision = revision + 1 WHERE id = ?").run(id);
3407
+ }
2778
3408
  /** Preserve Monitor delivery tombstones while making deleted threads threadless. */
2779
3409
  migrateMonitorWakeDeliveries() {
3410
+ const foreignKeys = this.database.prepare("PRAGMA foreign_key_list(monitor_wake_deliveries)")
3411
+ .all();
3412
+ if (!foreignKeys.some((key) => key.from === "thread_id" && key.on_delete === "CASCADE"))
3413
+ return;
3414
+ const columns = this.database.prepare("PRAGMA table_info(monitor_wake_deliveries)")
3415
+ .all();
3416
+ const projection = columns.some((column) => column.name === "projection_json") ? "projection_json" : "NULL";
2780
3417
  this.database.exec(`
2781
3418
  CREATE TABLE monitor_wake_deliveries_v14 (
2782
3419
  source_id TEXT NOT NULL REFERENCES agents(source_id),
@@ -2796,7 +3433,7 @@ export class WebStore {
2796
3433
  source_id, monitor_id, delivery_key, thread_id, payload_sha256, projection_json,
2797
3434
  state, disposition, turn_id, created_at, completed_at
2798
3435
  ) SELECT
2799
- source_id, monitor_id, delivery_key, thread_id, payload_sha256, NULL,
3436
+ source_id, monitor_id, delivery_key, thread_id, payload_sha256, ${projection},
2800
3437
  state, disposition, turn_id, created_at, completed_at
2801
3438
  FROM monitor_wake_deliveries;
2802
3439
  DROP TABLE monitor_wake_deliveries;
@@ -2946,6 +3583,7 @@ export class WebStore {
2946
3583
  }
2947
3584
  const requiredTables = new Set([
2948
3585
  "agents",
3586
+ "agent_run_overrides",
2949
3587
  "threads",
2950
3588
  "turns",
2951
3589
  "messages",
@@ -3009,6 +3647,46 @@ export class WebStore {
3009
3647
  this.database.exec(MESSAGE_SEARCH_UNSETTLED_SQL);
3010
3648
  });
3011
3649
  }
3650
+ /** Repair only the derived search projection of verified legacy Monitor replies.
3651
+ * New completions already normalize before the ordinary indexing triggers run.
3652
+ * Page by rowid so opening a large retained history does not materialize it all.
3653
+ */
3654
+ reindexLegacyMonitorMessages() {
3655
+ const select = this.database.prepare(`
3656
+ SELECT m.rowid AS row_id, m.parts_json FROM messages m
3657
+ JOIN turns ON turns.id = m.turn_id AND turns.thread_id = m.thread_id
3658
+ JOIN threads ON threads.id = m.thread_id
3659
+ WHERE m.rowid > ? AND m.role = 'assistant' AND m.status = 'complete'
3660
+ AND EXISTS (
3661
+ SELECT 1 FROM monitor_wake_deliveries d
3662
+ WHERE d.turn_id = turns.id AND d.thread_id = turns.thread_id
3663
+ AND d.source_id = threads.source_id AND d.state = 'completed'
3664
+ AND d.disposition IN ('steered', 'follow_up')
3665
+ )
3666
+ ORDER BY m.rowid LIMIT 100
3667
+ `);
3668
+ const remove = this.database.prepare("DELETE FROM message_search WHERE rowid = ?");
3669
+ const insert = this.database.prepare(`
3670
+ INSERT INTO message_search(rowid, body)
3671
+ SELECT ?, (${messageSearchBody("m")}) FROM (SELECT ? AS parts_json) m
3672
+ `);
3673
+ let after = 0;
3674
+ while (true) {
3675
+ const rows = select.all(after);
3676
+ if (rows.length === 0)
3677
+ return;
3678
+ this.transaction(() => {
3679
+ for (const row of rows) {
3680
+ const normalized = normalizeMonitorTerminalReply(parseParts(row.parts_json));
3681
+ if (!normalized.changed)
3682
+ continue;
3683
+ remove.run(row.row_id);
3684
+ insert.run(row.row_id, serializeParts(normalized.parts));
3685
+ }
3686
+ });
3687
+ after = rows[rows.length - 1].row_id;
3688
+ }
3689
+ }
3012
3690
  recoverInterruptedTurns() {
3013
3691
  const active = this.listActiveTurnIds();
3014
3692
  for (const turnId of active) {
@@ -3029,17 +3707,28 @@ export class WebStore {
3029
3707
  const threadIds = new Set(rows.map((row) => row.thread_id));
3030
3708
  this.transaction(() => {
3031
3709
  const updateInput = this.database.prepare(`
3032
- UPDATE live_inputs SET status = 'queued', active_turn_id = NULL, updated_at = ? WHERE id = ?
3710
+ UPDATE live_inputs
3711
+ SET status = 'queued', active_turn_id = NULL, dispatch_started_at = NULL, updated_at = ?
3712
+ WHERE id = ?
3033
3713
  `);
3034
- const updateMessage = this.database.prepare("UPDATE messages SET turn_id = NULL, parts_json = ?, updated_at = ? WHERE id = ?");
3035
3714
  for (const row of rows) {
3036
3715
  const persisted = this.database.prepare("SELECT parts_json FROM messages WHERE id = ?")
3037
3716
  .get(row.message_id);
3038
3717
  if (persisted === undefined) {
3039
3718
  throw new WebConsoleError("storage_corrupt", `Live input ${row.id} has no message.`, 500);
3040
3719
  }
3041
- updateInput.run(now, row.id);
3042
- updateMessage.run(serializeParts(withLiveInputStatus(parseParts(persisted.parts_json), "queued")), now, row.message_id);
3720
+ if (row.dispatch_started_at === null) {
3721
+ updateInput.run(now, row.id);
3722
+ this.database.prepare(`
3723
+ UPDATE web_submissions SET reason = 'closed_before_dispatch'
3724
+ WHERE input_id = ? AND outcome = 'live-input' AND reason IS NULL
3725
+ `).run(row.id);
3726
+ this.writeMessageParts(row.message_id, withLiveInputStatus(parseParts(persisted.parts_json), "queued"), now, { turnId: null });
3727
+ }
3728
+ else {
3729
+ this.writeMessageParts(row.message_id, withLiveInputStatus(parseParts(persisted.parts_json), "uncertain"), now, { turnId: null });
3730
+ this.database.prepare("DELETE FROM live_inputs WHERE id = ?").run(row.id);
3731
+ }
3043
3732
  }
3044
3733
  for (const threadId of threadIds) {
3045
3734
  this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
@@ -3055,20 +3744,21 @@ export class WebStore {
3055
3744
  WHERE status = 'sending'
3056
3745
  `).run(now, now);
3057
3746
  }
3058
- finishTurn(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts, suppressResponsePush = false) {
3747
+ finishTurn(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts, suppressResponsePush = false, monitorWakeDeliveryKey) {
3059
3748
  const turn = this.requireTurn(turnId);
3060
3749
  if (turn.status !== "running") {
3061
3750
  return this.requireThreadDetail(turn.thread_id);
3062
3751
  }
3063
- this.transaction(() => {
3064
- this.finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts, suppressResponsePush);
3065
- });
3066
- return this.requireThreadDetail(turn.thread_id);
3752
+ const write = this.transaction(() => this.finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts, suppressResponsePush, monitorWakeDeliveryKey));
3753
+ return {
3754
+ ...this.requireThreadDetail(turn.thread_id),
3755
+ ...(write === undefined ? {} : { write }),
3756
+ };
3067
3757
  }
3068
- finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts, suppressResponsePush = false) {
3758
+ finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts, suppressResponsePush = false, monitorWakeDeliveryKey) {
3069
3759
  const turn = this.requireTurn(turnId);
3070
3760
  if (turn.status !== "running")
3071
- return;
3761
+ return undefined;
3072
3762
  const existing = this.requireMessage(turn.assistant_message_id);
3073
3763
  let parts = [...existing.parts];
3074
3764
  if (finalText !== undefined && finalText.length > 0)
@@ -3078,17 +3768,30 @@ export class WebStore {
3078
3768
  if (errorMessage !== undefined) {
3079
3769
  parts.push({ type: "error", ...(errorCode === undefined ? {} : { code: errorCode }), message: errorMessage });
3080
3770
  }
3771
+ const monitorAssociated = status === "complete" && this.hasMonitorTurnAssociation(turnId, monitorWakeDeliveryKey);
3772
+ const processJobAssociated = status === "complete" && this.hasProcessJobTurnAssociation(turnId, monitorWakeDeliveryKey);
3773
+ if (processJobAssociated) {
3774
+ parts = normalizeMonitorTerminalReply(parts, true).parts;
3775
+ suppressResponsePush = !hasMonitorReplyContent(parts);
3776
+ }
3777
+ if (monitorAssociated) {
3778
+ const normalized = normalizeMonitorTerminalReply(parts);
3779
+ parts = normalized.parts;
3780
+ // A suppressed callback may still have streamed the sentinel. Preserve a
3781
+ // preceding answer and rich output even when its terminal reply was empty.
3782
+ suppressResponsePush = !hasMonitorReplyContent(parts);
3783
+ }
3081
3784
  const now = this.now();
3082
3785
  const thread = this.requireThread(turn.thread_id);
3083
- const agent = this.getAgent(thread.sourceId);
3786
+ const agent = this.getStoredAgent(thread.sourceId);
3084
3787
  this.database.prepare(`
3085
3788
  UPDATE turns SET status = ?, finished_at = ?, error_code = ?, error_message = ?,
3086
3789
  model = CASE WHEN ? IS NULL THEN model ELSE ? END,
3087
- effort = CASE WHEN ? IS NULL THEN effort ELSE ? END
3790
+ effort = CASE WHEN ? IS NULL THEN effort ELSE ? END,
3791
+ effective_effort = CASE WHEN ? IS NULL THEN effective_effort ELSE ? END
3088
3792
  WHERE id = ?
3089
- `).run(status, now, errorCode ?? null, errorMessage ?? null, runtime?.model ?? null, runtime?.model ?? null, runtime?.effort ?? null, runtime?.effort ?? null, turnId);
3090
- this.database.prepare("UPDATE messages SET parts_json = ?, status = ?, updated_at = ? WHERE id = ?")
3091
- .run(serializeParts(parts), status, now, existing.id);
3793
+ `).run(status, now, errorCode ?? null, errorMessage ?? null, runtime?.model ?? null, runtime?.model ?? null, runtime?.effort ?? null, runtime?.effort ?? null, runtime?.effectiveEffort ?? null, runtime?.effectiveEffort ?? null, turnId);
3794
+ const delta = this.writeMessageDelta(existing, parts, now, { status });
3092
3795
  this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
3093
3796
  .run(now, turn.thread_id);
3094
3797
  this.recordThreadRevision(turn.thread_id, `turn_${status}`, now);
@@ -3109,7 +3812,7 @@ export class WebStore {
3109
3812
  : "run.failed";
3110
3813
  const label = agent?.label ?? "mono-agent";
3111
3814
  const body = status === "complete"
3112
- ? parts.filter((part) => part.type === "text")
3815
+ ? monitorAssociated ? monitorReplyText(parts) : parts.filter((part) => part.type === "text")
3113
3816
  .map((part) => part.text)
3114
3817
  .join(" ")
3115
3818
  : status === "cancelled"
@@ -3134,10 +3837,14 @@ export class WebStore {
3134
3837
  notBefore: new Date(new Date(now).getTime() + 3_000).toISOString(),
3135
3838
  });
3136
3839
  }
3840
+ // Re-read rather than reuse `existing`: the settled row is what Task 6
3841
+ // pushes beside the delta, and it carries the turn's finish stamp.
3842
+ return { message: this.requireMessage(existing.id), delta };
3137
3843
  }
3138
3844
  mapThread(row) {
3139
3845
  const runState = this.latestRunState(row.id);
3140
3846
  const preview = this.lastMessagePreview(row.id);
3847
+ const jobActivity = this.jobActivity(row.id);
3141
3848
  return {
3142
3849
  id: row.id,
3143
3850
  sourceId: row.source_id,
@@ -3160,65 +3867,268 @@ export class WebStore {
3160
3867
  ...(preview === undefined ? {} : { lastMessagePreview: preview }),
3161
3868
  messageCount: row.message_count,
3162
3869
  runState,
3870
+ ...(jobActivity === undefined ? {} : { jobActivity }),
3163
3871
  canSend: row.can_send === 1,
3164
3872
  canUpload: row.can_upload === 1,
3165
3873
  runModel: row.run_model,
3166
3874
  runEffort: row.run_effort,
3167
3875
  };
3168
3876
  }
3877
+ /**
3878
+ * The ONE statement that persists a message's parts.
3879
+ *
3880
+ * Every parts write bumps `seq`, and that count is what makes a content
3881
+ * delta safe to apply: a console holding version N can tell the write that
3882
+ * follows it from one that skipped ahead, and re-read the message instead of
3883
+ * guessing. A write that went straight to `parts_json` would mint content no
3884
+ * delta describes and no sequence number covers, so this is the only place in
3885
+ * the store that names the column. A caller that also moves the row passes
3886
+ * the columns it changes here rather than issuing a second statement.
3887
+ *
3888
+ * Only the two paths a console watches live -- streaming frames and the write
3889
+ * that settles a turn -- go on to build a {@link WebMessageDelta} from this.
3890
+ * Every other caller (a live-input transition, restart recovery, and the
3891
+ * notification, cron-run, process-job and Monitor reconciliations) bumps the
3892
+ * version without describing the change: those writes reach the browser as an
3893
+ * invalidation it answers by re-reading the message, and the new `seq` is what
3894
+ * tells it the re-read is newer than the delta stream it was applying.
3895
+ *
3896
+ * EVERY caller here owes its console a message event -- a delta, or the
3897
+ * `message.changed` naming this row. A subscribed console no longer answers a
3898
+ * conversation summary by re-reading the transcript, so a write announced
3899
+ * only as a summary is one it never sees. `recoverLiveInputs` is the single
3900
+ * exception, and only because it runs at open with no subscriber to tell.
3901
+ */
3902
+ writeMessageParts(id, parts, now, columns = {}) {
3903
+ const assignments = [];
3904
+ const values = [];
3905
+ if (columns.threadId !== undefined) {
3906
+ assignments.push("thread_id = ?");
3907
+ values.push(columns.threadId);
3908
+ }
3909
+ if (columns.turnId !== undefined) {
3910
+ assignments.push("turn_id = ?");
3911
+ values.push(columns.turnId);
3912
+ }
3913
+ assignments.push("parts_json = ?");
3914
+ values.push(serializeParts(parts));
3915
+ if (columns.createdAt !== undefined) {
3916
+ assignments.push("created_at = ?");
3917
+ values.push(columns.createdAt);
3918
+ }
3919
+ assignments.push("updated_at = ?");
3920
+ values.push(now);
3921
+ if (columns.status !== undefined) {
3922
+ assignments.push("status = ?");
3923
+ values.push(columns.status);
3924
+ }
3925
+ const sql = `UPDATE messages SET ${assignments.join(", ")}, seq = seq + 1 WHERE id = ? RETURNING seq`;
3926
+ let statement = this.partsWriteStatements.get(sql);
3927
+ if (statement === undefined) {
3928
+ statement = this.database.prepare(sql);
3929
+ this.partsWriteStatements.set(sql, statement);
3930
+ }
3931
+ const row = statement.get(...values, id);
3932
+ if (row === undefined) {
3933
+ throw new WebConsoleError("storage_corrupt", `Message ${id} is missing from this conversation.`, 500);
3934
+ }
3935
+ return { baseSeq: row.seq - 1, seq: row.seq };
3936
+ }
3937
+ /**
3938
+ * Persist a message's parts and describe the write as a content delta.
3939
+ *
3940
+ * `message` must be the message the new parts were DERIVED from: the diff
3941
+ * compares by reference, so a delta computed against any other read of the
3942
+ * same row would call every part changed.
3943
+ */
3944
+ writeMessageDelta(message, parts, now, columns = {}) {
3945
+ const { baseSeq, seq } = this.writeMessageParts(message.id, parts, now, columns);
3946
+ // The ops describe `message.parts`; `baseSeq` is what the row actually held
3947
+ // when this statement ran. Every caller reads and writes inside one
3948
+ // transaction on a single-writer database, so these agree -- and if they
3949
+ // ever stopped agreeing, the delta would be a self-consistent description
3950
+ // of a version that never existed, which a console applies in silence.
3951
+ // Refusing here turns that into a failure the caller can see.
3952
+ if (baseSeq !== message.seq) {
3953
+ throw new WebConsoleError("storage_corrupt", `Message ${message.id} moved from ${String(message.seq)} to ${String(baseSeq)} while its delta was built.`, 500);
3954
+ }
3955
+ const attribution = message.turnId === undefined
3956
+ ? undefined
3957
+ : runAttribution(this.requireTurn(message.turnId));
3958
+ return {
3959
+ messageId: message.id,
3960
+ baseSeq,
3961
+ seq,
3962
+ status: columns.status ?? message.status,
3963
+ updatedAt: now,
3964
+ ...(attribution === undefined ? {} : { attribution }),
3965
+ ops: diffParts(message.parts, parts),
3966
+ };
3967
+ }
3169
3968
  mapMessage(row) {
3170
3969
  const attachments = this.database
3171
3970
  .prepare("SELECT * FROM attachments WHERE message_id = ? AND origin = 'upload' ORDER BY created_at, id")
3172
3971
  .all(row.id);
3173
- const storedParts = parseParts(row.parts_json);
3972
+ const rawParts = parseParts(row.parts_json);
3973
+ const storedParts = row.role === "assistant" && row.status === "complete" && row.turn_id !== null
3974
+ && this.hasMonitorTurnAssociation(row.turn_id)
3975
+ ? normalizeMonitorTerminalReply(rawParts).parts : rawParts;
3174
3976
  const quote = quoteFromParts(storedParts);
3175
3977
  const liveInputStatus = liveInputStatusFromParts(storedParts);
3978
+ const role = normalizeRole(row.role);
3979
+ const finishedAt = role === "assistant" ? this.turnFinishedAt(row) : undefined;
3980
+ const attribution = role === "assistant" && row.turn_id !== null
3981
+ ? runAttribution(this.requireTurn(row.turn_id))
3982
+ : undefined;
3176
3983
  return {
3177
3984
  id: row.id,
3178
3985
  threadId: row.thread_id,
3179
3986
  ...(row.turn_id === null ? {} : { turnId: row.turn_id }),
3180
- role: normalizeRole(row.role),
3987
+ role,
3181
3988
  ...(quote === undefined ? {} : { quote }),
3182
3989
  parts: storedParts.filter((part) => part.type !== "telemetry"
3183
3990
  || (part.event !== QUOTE_TELEMETRY_EVENT && part.event !== LIVE_INPUT_TELEMETRY_EVENT)),
3184
3991
  attachments: attachments.map((attachment) => toWebAttachment(mapStoredAttachment(attachment))),
3185
3992
  createdAt: row.created_at,
3186
3993
  updatedAt: row.updated_at,
3994
+ ...(finishedAt === undefined ? {} : { finishedAt }),
3187
3995
  status: normalizeMessageStatus(row.status),
3188
3996
  ...(liveInputStatus === undefined ? {} : { liveInputStatus }),
3997
+ ...(attribution === undefined ? {} : { attribution }),
3998
+ seq: row.seq,
3189
3999
  };
3190
4000
  }
4001
+ /**
4002
+ * The turn's terminal stamp, which `finishTurnInTransaction` writes with the
4003
+ * message status. A page query projects it from its own join; only a
4004
+ * single-row read pays for a lookup.
4005
+ */
4006
+ turnFinishedAt(row) {
4007
+ if (row.turn_id === null)
4008
+ return undefined;
4009
+ if (row.turn_finished_at !== undefined)
4010
+ return row.turn_finished_at ?? undefined;
4011
+ const turn = this.database.prepare("SELECT finished_at FROM turns WHERE id = ?")
4012
+ .get(row.turn_id);
4013
+ return turn?.finished_at ?? undefined;
4014
+ }
3191
4015
  latestRunState(threadId) {
3192
4016
  const row = this.database.prepare("SELECT * FROM turns WHERE thread_id = ? ORDER BY started_at DESC, rowid DESC LIMIT 1")
3193
4017
  .get(threadId);
3194
4018
  if (row === undefined)
3195
4019
  return { status: "idle" };
3196
4020
  const status = normalizeRunStatus(row.status);
4021
+ const attribution = runAttribution(row);
4022
+ // Successful assistant-only turns without visible reply content are host
4023
+ // no-ops, not a new conversation outcome. Keep their real run state while
4024
+ // projecting the prior meaningful outcome for status priority. Derive this
4025
+ // from retained provenance/normalized parts so old stores need no migration.
4026
+ // Match hasMonitorReplyContent without loading transcript bodies into lists.
4027
+ const candidates = status === "complete" ? this.database.prepare(`
4028
+ SELECT t.id, t.status, t.finished_at, t.assistant_message_id,
4029
+ EXISTS (SELECT 1 FROM messages m WHERE m.turn_id = t.id AND m.role = 'user') AS has_user
4030
+ FROM turns t
4031
+ WHERE t.thread_id = ? AND t.status <> 'running' AND (
4032
+ t.status <> 'complete'
4033
+ OR EXISTS (SELECT 1 FROM messages m WHERE m.turn_id = t.id AND m.role = 'user')
4034
+ OR EXISTS (
4035
+ SELECT 1 FROM messages m, json_each(m.parts_json) p
4036
+ WHERE m.id = t.assistant_message_id AND (
4037
+ json_extract(p.value, '$.type') IN ('attachment', 'mcp_app', 'failure')
4038
+ OR (json_extract(p.value, '$.type') = 'text'
4039
+ AND length(trim(json_extract(p.value, '$.text'), ?)) > 0)
4040
+ )
4041
+ )
4042
+ ) ORDER BY t.started_at DESC, t.rowid DESC
4043
+ `).iterate(threadId, "\u0009\u000a\u000b\u000c\u000d\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff") : [];
4044
+ let outcome;
4045
+ for (const rawCandidate of candidates) {
4046
+ const candidate = rawCandidate;
4047
+ // Legacy Monitor rows retain raw sentinel bytes and normalize only on
4048
+ // read. Inspect one associated candidate at a time, never materialize the
4049
+ // transcript or rewrite history merely to derive sidebar status.
4050
+ if (candidate.status === "complete" && candidate.has_user === 0 && this.hasMonitorTurnAssociation(candidate.id)) {
4051
+ const message = this.database.prepare("SELECT parts_json FROM messages WHERE id = ?")
4052
+ .get(candidate.assistant_message_id);
4053
+ if (!hasMonitorReplyContent(normalizeMonitorTerminalReply(parseParts(message.parts_json)).parts))
4054
+ continue;
4055
+ }
4056
+ outcome = candidate;
4057
+ break;
4058
+ }
4059
+ const lastOutcome = status !== "complete" || outcome?.id === row.id ? undefined
4060
+ : outcome === undefined ? null : {
4061
+ status: normalizeRunStatus(outcome.status),
4062
+ ...(outcome.finished_at === null ? {} : { finishedAt: outcome.finished_at }),
4063
+ };
3197
4064
  return {
3198
4065
  id: row.id,
3199
4066
  status,
3200
4067
  startedAt: row.started_at,
4068
+ ...(lastOutcome === undefined ? {} : { lastOutcome }),
3201
4069
  ...(row.finished_at === null ? {} : { finishedAt: row.finished_at }),
3202
4070
  ...(row.error_message === null
3203
4071
  ? {}
3204
4072
  : { error: { ...(row.error_code === null ? {} : { code: row.error_code }), message: row.error_message } }),
3205
4073
  ...(row.model === null ? {} : { model: row.model }),
3206
4074
  ...(row.effort === null ? {} : { effort: row.effort }),
4075
+ ...(attribution === undefined ? {} : { attribution }),
3207
4076
  };
3208
4077
  }
3209
4078
  lastMessagePreview(threadId) {
3210
- const row = this.database.prepare("SELECT parts_json FROM messages WHERE thread_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1")
4079
+ const row = this.database.prepare(`SELECT * FROM messages WHERE thread_id = ? AND ${visibleMessageSql("messages")} ORDER BY created_at DESC, rowid DESC LIMIT 1`)
3211
4080
  .get(threadId);
3212
4081
  if (row === undefined)
3213
4082
  return undefined;
3214
- const text = parseParts(row.parts_json)
3215
- .filter((part) => part.type === "text" || part.type === "reasoning")
3216
- .map((part) => part.text)
4083
+ const text = this.mapMessage(row).parts
4084
+ .flatMap((part) => part.type === "text" ? [part.text]
4085
+ : part.type === "process-job" && part.responseText !== undefined ? [part.responseText] : [])
3217
4086
  .join(" ")
3218
4087
  .replace(/\s+/gu, " ")
3219
4088
  .trim();
3220
4089
  return text.length === 0 ? undefined : text.slice(0, 160);
3221
4090
  }
4091
+ jobActivity(threadId) {
4092
+ // Read only retained job cards, including those behind the message page.
4093
+ // Aggregate in SQLite so neither transcripts nor output tails are loaded
4094
+ // into the listing. These rows already advance the thread's revision.
4095
+ const row = this.database.prepare(`
4096
+ WITH jobs AS (
4097
+ SELECT json_extract(part.value, '$.job.state') AS state,
4098
+ json_extract(part.value, '$.job.timestamps.completedAt') AS completed_at,
4099
+ c.response_text, m.rowid AS ordinal
4100
+ FROM messages m
4101
+ JOIN process_job_cards c ON c.message_id = m.id AND c.thread_id = m.thread_id
4102
+ JOIN json_each(m.parts_json) part
4103
+ WHERE m.thread_id = ? AND json_extract(part.value, '$.type') = 'process-job'
4104
+ )
4105
+ SELECT count(*) AS total,
4106
+ count(*) FILTER (WHERE state = 'queued') AS queued,
4107
+ count(*) FILTER (WHERE state = 'starting') AS starting,
4108
+ count(*) FILTER (WHERE state = 'running') AS running,
4109
+ (SELECT json_object('state', state, 'completedAt', completed_at, 'reply', response_text)
4110
+ FROM jobs WHERE completed_at IS NOT NULL
4111
+ ORDER BY julianday(completed_at) DESC, (state <> 'succeeded') DESC, ordinal DESC
4112
+ LIMIT 1) AS latest_terminal
4113
+ FROM jobs
4114
+ `).get(threadId);
4115
+ if (row.total === 0)
4116
+ return undefined;
4117
+ const terminal = row.latest_terminal === null ? undefined : JSON.parse(row.latest_terminal);
4118
+ const replyPreview = terminal?.reply?.replace(/\s+/gu, " ").trim().slice(0, 160);
4119
+ return {
4120
+ queued: row.queued,
4121
+ starting: row.starting,
4122
+ running: row.running,
4123
+ ...(terminal === undefined ? {} : {
4124
+ latestTerminal: {
4125
+ state: terminal.state,
4126
+ completedAt: terminal.completedAt,
4127
+ ...(replyPreview ? { replyPreview } : {}),
4128
+ },
4129
+ }),
4130
+ };
4131
+ }
3222
4132
  cronChannel(sourceId, jobId) {
3223
4133
  return this.database.prepare(`
3224
4134
  SELECT * FROM cron_channels WHERE source_id = ? AND job_id = ?
@@ -3364,8 +4274,85 @@ export class WebStore {
3364
4274
  this.database.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
3365
4275
  .run(key, value);
3366
4276
  }
4277
+ transactionDepth = 0;
4278
+ claimWebSubmission(input) {
4279
+ return this.transaction(() => {
4280
+ const threadId = this.resolveThreadId(input.threadId);
4281
+ this.requireThread(threadId);
4282
+ const existing = this.database.prepare("SELECT * FROM web_submissions WHERE thread_id = ? AND submission_id = ?").get(threadId, input.submissionId);
4283
+ if (existing !== undefined) {
4284
+ if (existing.payload_sha256 !== input.payloadSha256) {
4285
+ throw new WebConsoleError("submission_conflict", "Submission id was already used for different content.", 409);
4286
+ }
4287
+ return { created: false, submission: mapWebSubmission(existing) };
4288
+ }
4289
+ const created = input.create();
4290
+ const now = this.now();
4291
+ this.database.prepare(`
4292
+ INSERT INTO web_submissions (
4293
+ thread_id, submission_id, payload_sha256, outcome, reason, message_id, turn_id, input_id, created_at
4294
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
4295
+ `).run(threadId, input.submissionId, input.payloadSha256, created.outcome, created.reason ?? null, created.messageId ?? null, created.turnId ?? null, created.inputId ?? null, now);
4296
+ return {
4297
+ created: true,
4298
+ submission: mapWebSubmission(this.database.prepare("SELECT * FROM web_submissions WHERE thread_id = ? AND submission_id = ?").get(threadId, input.submissionId)),
4299
+ };
4300
+ });
4301
+ }
4302
+ webSubmission(threadId, submissionId) {
4303
+ threadId = this.resolveThreadId(threadId);
4304
+ this.requireThread(threadId);
4305
+ const row = this.database.prepare("SELECT * FROM web_submissions WHERE thread_id = ? AND submission_id = ?").get(threadId, submissionId);
4306
+ return row === undefined ? undefined : mapWebSubmission(row);
4307
+ }
4308
+ requireCronReplyOperationRow(operationId) {
4309
+ const row = this.database.prepare("SELECT * FROM cron_reply_operations WHERE operation_id = ?")
4310
+ .get(operationId);
4311
+ if (row === undefined) {
4312
+ throw new WebConsoleError("cron_reply_operation_not_found", "Cron reply operation not found.", 404);
4313
+ }
4314
+ return row;
4315
+ }
4316
+ assertCronReplyIdentity(row, sourceId, jobId, runId) {
4317
+ if (row.source_id !== sourceId || row.job_id !== jobId || row.run_id !== runId) {
4318
+ throw new WebConsoleError("cron_reply_operation_conflict", "Cron reply operation id was used for another run.", 409);
4319
+ }
4320
+ }
4321
+ cronReplyState(row) {
4322
+ if (row.state === "completed") {
4323
+ return { kind: "completed", receipt: this.cronReplyReceipt(row, true) };
4324
+ }
4325
+ if (row.state === "failed")
4326
+ return { kind: "failed", operation: mapCronReplyOperation(row) };
4327
+ if (row.state === "tombstoned")
4328
+ return { kind: "tombstoned", operation: mapCronReplyOperation(row) };
4329
+ return { kind: "pending", operation: mapCronReplyOperation(row) };
4330
+ }
4331
+ cronReplyReceipt(row, duplicate) {
4332
+ if (row.provenance_message_id === null || row.result_message_id === null) {
4333
+ throw new WebConsoleError("storage_corrupt", "Completed cron reply is missing its projected messages.", 500);
4334
+ }
4335
+ const thread = this.getThread(row.thread_id);
4336
+ const provenance = this.getMessage(row.provenance_message_id);
4337
+ const result = this.getMessage(row.result_message_id);
4338
+ if (thread === undefined || provenance === undefined || result === undefined) {
4339
+ throw new WebConsoleError("storage_corrupt", "Completed cron reply projection is missing.", 500);
4340
+ }
4341
+ return {
4342
+ operationId: row.operation_id,
4343
+ sourceId: row.source_id,
4344
+ jobId: row.job_id,
4345
+ runId: row.run_id,
4346
+ duplicate,
4347
+ thread,
4348
+ messages: [provenance, result],
4349
+ };
4350
+ }
3367
4351
  transaction(operation) {
4352
+ if (this.transactionDepth > 0)
4353
+ return operation();
3368
4354
  this.database.exec("BEGIN IMMEDIATE");
4355
+ this.transactionDepth += 1;
3369
4356
  try {
3370
4357
  const result = operation();
3371
4358
  this.database.exec("COMMIT");
@@ -3375,11 +4362,42 @@ export class WebStore {
3375
4362
  this.database.exec("ROLLBACK");
3376
4363
  throw error;
3377
4364
  }
4365
+ finally {
4366
+ this.transactionDepth -= 1;
4367
+ }
3378
4368
  }
3379
4369
  now() {
3380
4370
  return this.clock().toISOString();
3381
4371
  }
3382
4372
  }
4373
+ function mapWebSubmission(row) {
4374
+ return {
4375
+ threadId: row.thread_id,
4376
+ submissionId: row.submission_id,
4377
+ payloadSha256: row.payload_sha256,
4378
+ outcome: row.outcome,
4379
+ ...(row.reason === null ? {} : { reason: row.reason }),
4380
+ ...(row.message_id === null ? {} : { messageId: row.message_id }),
4381
+ ...(row.turn_id === null ? {} : { turnId: row.turn_id }),
4382
+ ...(row.input_id === null ? {} : { inputId: row.input_id }),
4383
+ };
4384
+ }
4385
+ function mapCronReplyOperation(row) {
4386
+ return {
4387
+ operationId: row.operation_id,
4388
+ sourceId: row.source_id,
4389
+ jobId: row.job_id,
4390
+ runId: row.run_id,
4391
+ threadId: row.thread_id,
4392
+ conversationId: row.conversation_id,
4393
+ idempotencyKey: row.idempotency_key,
4394
+ state: row.state,
4395
+ snapshotKind: row.snapshot_kind,
4396
+ ...(row.snapshot_text === null ? {} : { snapshotText: row.snapshot_text }),
4397
+ ...(row.snapshot_sha256 === null ? {} : { snapshotSha256: row.snapshot_sha256 }),
4398
+ ...(row.failure_reason === null ? {} : { failureReason: row.failure_reason }),
4399
+ };
4400
+ }
3383
4401
  function mapPushSubscriptionStatus(row) {
3384
4402
  const state = row.state === "disabled" || row.state === "expired"
3385
4403
  ? row.state
@@ -3463,7 +4481,7 @@ function threadSelectSql(suffix) {
3463
4481
  WHEN a.status = 'online' OR a.status = 'degraded' THEN 1 ELSE 0 END AS can_send,
3464
4482
  CASE WHEN t.trigger_kind = 'cron' THEN 0
3465
4483
  WHEN (a.status = 'online' OR a.status = 'degraded') AND a.supports_attachments = 1 THEN 1 ELSE 0 END AS can_upload,
3466
- (SELECT COUNT(*) FROM messages m WHERE m.thread_id = t.id) AS message_count
4484
+ (SELECT COUNT(*) FROM messages m WHERE m.thread_id = t.id AND ${visibleMessageSql("m")}) AS message_count
3467
4485
  FROM threads t JOIN agents a ON a.source_id = t.source_id
3468
4486
  LEFT JOIN cron_channels cc ON cc.thread_id = t.id
3469
4487
  ${suffix}
@@ -3472,11 +4490,14 @@ function threadSelectSql(suffix) {
3472
4490
  function agentSelectSql(suffix) {
3473
4491
  return `
3474
4492
  SELECT a.*,
4493
+ o.model AS override_model,
4494
+ o.effort AS override_effort,
3475
4495
  CASE WHEN EXISTS (
3476
4496
  SELECT 1 FROM settings s
3477
4497
  WHERE s.key = 'agent_pin:' || a.source_id AND s.value = '1'
3478
4498
  ) THEN 1 ELSE 0 END AS pinned
3479
4499
  FROM agents a
4500
+ LEFT JOIN agent_run_overrides o ON o.source_id = a.source_id
3480
4501
  ${suffix}
3481
4502
  `;
3482
4503
  }
@@ -3558,8 +4579,36 @@ function cronMessageStatus(status) {
3558
4579
  return "cancelled";
3559
4580
  return "complete";
3560
4581
  }
3561
- function cronRunParts(run, prior, conversationId) {
3562
- const silent = run.status === "succeeded" && classifyNotifySuppression(run.text) !== "none";
4582
+ function isTerminalCronRun(status) {
4583
+ return status === "succeeded"
4584
+ || status === "failed"
4585
+ || status === "cancelled"
4586
+ || status === "skipped_overlap"
4587
+ || status === "dropped";
4588
+ }
4589
+ /** Presentation queries only; storage validation/recovery and retention stay raw. */
4590
+ function visibleMessageSql(alias) { return `${alias}.cron_suppressed = 0`; }
4591
+ function withoutCronSilentFlag(data) {
4592
+ const { silent: _silent, ...rest } = record(data) ?? {};
4593
+ return rest;
4594
+ }
4595
+ function clearSilentCronPart(part) {
4596
+ return part.type === "telemetry" && part.event === "cron_run" && record(part.data)?.silent === true
4597
+ ? { ...part, data: withoutCronSilentFlag(part.data) } : part;
4598
+ }
4599
+ function definitelySilentCronRun(run) {
4600
+ return run.status === "succeeded" && run.fieldsTruncated?.includes("text") !== true
4601
+ && classifyNotifySuppression(run.text) !== "none";
4602
+ }
4603
+ function hasMeaningfulCronContent(parts) {
4604
+ return parts.some((part) => part.type === "text"
4605
+ ? !isSyntheticCronStateText(part.text) && classifyNotifySuppression(part.text) === "none"
4606
+ : part.type === "attachment" || part.type === "error" || part.type === "mcp_app"
4607
+ || part.type === "failure" || part.type === "process-job" || part.type === "process-job-wake"
4608
+ || part.type === "monitor-activity");
4609
+ }
4610
+ function cronRunParts(run, prior, conversationId, notificationBacked = false) {
4611
+ const silent = definitelySilentCronRun(run);
3563
4612
  const priorCron = prior.find((part) => part.type === "telemetry" && part.event === "cron_run");
3564
4613
  const priorCronData = record(priorCron?.data);
3565
4614
  const priorActivityLoaded = priorCronData?.activityLoaded === true;
@@ -3569,6 +4618,10 @@ function cronRunParts(run, prior, conversationId) {
3569
4618
  const priorActivityEventCount = Number.isSafeInteger(priorCronData?.activityEventCount)
3570
4619
  ? Number(priorCronData?.activityEventCount)
3571
4620
  : priorLoadedEventCount;
4621
+ const priorDetailFieldsTruncated = Array.isArray(priorCronData?.detailFieldsTruncated)
4622
+ && priorCronData.detailFieldsTruncated.every((field) => typeof field === "string")
4623
+ ? priorCronData.detailFieldsTruncated
4624
+ : undefined;
3572
4625
  const activityLoaded = run.projection === "detail"
3573
4626
  || (priorActivityLoaded && priorActivityEventCount === run.eventCount);
3574
4627
  const activityStale = run.projection === "summary"
@@ -3581,10 +4634,12 @@ function cronRunParts(run, prior, conversationId) {
3581
4634
  // synthetic state/identity before rebuilding it, while retaining a genuine
3582
4635
  // notification text that arrived before the operator run projection.
3583
4636
  const retained = prior.filter((part) => !(part.type === "telemetry" && part.event === "cron_run")
3584
- && !(part.type === "text" && isSyntheticCronStateText(part.text)));
4637
+ && !(part.type === "text" && !notificationBacked && isSyntheticCronStateText(part.text)));
3585
4638
  const parts = run.projection === "summary"
3586
4639
  ? [...retained]
3587
- : retained.filter((part) => part.type === "text" || part.type === "error");
4640
+ : retained.filter((part) => part.type === "text" || part.type === "error" || part.type === "attachment"
4641
+ || part.type === "mcp_app" || part.type === "failure" || part.type === "process-job"
4642
+ || part.type === "process-job-wake" || part.type === "monitor-activity");
3588
4643
  for (const event of run.projection === "detail" ? run.events : [])
3589
4644
  applyEvent(parts, event);
3590
4645
  const preserveLoadedText = run.projection === "summary"
@@ -3594,7 +4649,7 @@ function cronRunParts(run, prior, conversationId) {
3594
4649
  && priorActivityLoaded
3595
4650
  && (run.fieldsTruncated?.includes("error") === true
3596
4651
  || run.fieldsTruncated?.includes("failureKind") === true);
3597
- if (!silent && !preserveLoadedText && run.text !== undefined && run.text.length > 0) {
4652
+ if (!notificationBacked && !silent && !preserveLoadedText && run.text !== undefined && run.text.length > 0) {
3598
4653
  reconcileFinalText(parts, run.text);
3599
4654
  }
3600
4655
  const hasText = parts.some((part) => part.type === "text" && part.text.trim().length > 0);
@@ -3638,7 +4693,7 @@ function cronRunParts(run, prior, conversationId) {
3638
4693
  sequence: run.sequence,
3639
4694
  trigger: run.trigger,
3640
4695
  status: run.status,
3641
- ...(silent ? { silent: true } : {}),
4696
+ ...(silent && !hasMeaningfulCronContent(parts) ? { silent: true } : {}),
3642
4697
  ...(run.startedAt === undefined ? {} : { startedAt: run.startedAt }),
3643
4698
  ...(run.completedAt === undefined ? {} : { completedAt: run.completedAt }),
3644
4699
  ...(run.artifactRunId === undefined ? {} : { artifactRunId: run.artifactRunId }),
@@ -3653,6 +4708,9 @@ function cronRunParts(run, prior, conversationId) {
3653
4708
  : {}),
3654
4709
  ...(activityStale ? { activityStale: true, loadedEventCount: priorLoadedEventCount } : {}),
3655
4710
  ...(eventsTruncated ? { eventsTruncated: true } : {}),
4711
+ ...(run.projection === "detail"
4712
+ ? { detailFieldsTruncated: run.fieldsTruncated ?? [] }
4713
+ : priorDetailFieldsTruncated === undefined ? {} : { detailFieldsTruncated: priorDetailFieldsTruncated }),
3656
4714
  ...(run.fieldsTruncated === undefined ? {} : { fieldsTruncated: run.fieldsTruncated }),
3657
4715
  },
3658
4716
  });
@@ -3724,7 +4782,9 @@ function parseStoredCronRun(serialized) {
3724
4782
  ...(legacyEvents === undefined ? {} : { eventsTruncated: true }),
3725
4783
  }
3726
4784
  : raw);
3727
- if (run.projection !== "summary"
4785
+ if ((run.text !== undefined && typeof run.text !== "string")
4786
+ || (run.fieldsTruncated !== undefined && (!Array.isArray(run.fieldsTruncated) || run.fieldsTruncated.some((field) => typeof field !== "string")))
4787
+ || run.projection !== "summary"
3728
4788
  || typeof run.runId !== "string"
3729
4789
  || typeof run.jobId !== "string"
3730
4790
  || typeof run.scheduledAt !== "string"
@@ -3795,6 +4855,7 @@ function mapAgent(row) {
3795
4855
  const efforts = parseStringArray(row.efforts_json);
3796
4856
  const modelOptions = parseRecord(row.model_options_json);
3797
4857
  const providers = parseProviderSummary(row.providers_json);
4858
+ const runSettings = agentRunSettings(row);
3798
4859
  return {
3799
4860
  sourceId: row.source_id,
3800
4861
  label: row.label,
@@ -3802,11 +4863,13 @@ function mapAgent(row) {
3802
4863
  pinned: row.pinned === 1,
3803
4864
  ...(row.health === null ? {} : { health: row.health }),
3804
4865
  supportsAttachments: row.supports_attachments === 1,
4866
+ ...(row.supports_provider_auth === 1 ? { supportsProviderAuth: true } : {}),
3805
4867
  ...(models === undefined ? {} : { models }),
3806
4868
  ...(row.default_model === null ? {} : { defaultModel: row.default_model }),
3807
4869
  ...(row.default_effort === null ? {} : { defaultEffort: row.default_effort }),
3808
4870
  ...(efforts === undefined ? {} : { efforts }),
3809
4871
  ...(modelOptions === undefined ? {} : { modelOptions }),
4872
+ runSettings,
3810
4873
  ...(providers === undefined ? {} : { providers }),
3811
4874
  ...(row.cron_read === 1
3812
4875
  ? { cron: { read: true, actions: row.cron_actions === 1 } }
@@ -3815,6 +4878,30 @@ function mapAgent(row) {
3815
4878
  updatedAt: row.updated_at,
3816
4879
  };
3817
4880
  }
4881
+ function agentRunSettings(row) {
4882
+ const effectiveModel = row.override_model ?? row.default_model;
4883
+ const effectiveEffort = row.override_effort ?? row.default_effort;
4884
+ const config = {
4885
+ ...(row.default_model === null ? {} : { model: row.default_model }),
4886
+ ...(row.default_effort === null ? {} : { effort: row.default_effort }),
4887
+ };
4888
+ const override = row.override_model === null && row.override_effort === null
4889
+ ? null
4890
+ : {
4891
+ ...(row.override_model === null ? {} : { model: row.override_model }),
4892
+ ...(row.override_effort === null ? {} : { effort: row.override_effort }),
4893
+ };
4894
+ return {
4895
+ config,
4896
+ override,
4897
+ effective: {
4898
+ ...(effectiveModel === null ? {} : { model: effectiveModel }),
4899
+ modelSource: row.override_model === null ? "config" : "override",
4900
+ ...(effectiveEffort === null ? {} : { effort: effectiveEffort }),
4901
+ effortSource: row.override_effort === null ? "config" : "override",
4902
+ },
4903
+ };
4904
+ }
3818
4905
  function mapStoredAttachment(row) {
3819
4906
  return {
3820
4907
  id: row.id,
@@ -3867,7 +4954,7 @@ function appliedMonitorWake(event, resolveMonitorWake) {
3867
4954
  ? undefined
3868
4955
  : { deliveryKey: event.metadata.inputId, projection };
3869
4956
  }
3870
- function applyEvent(parts, event, resolveMonitorWake) {
4957
+ function applyEvent(parts, event, resolveMonitorWake, resolveProcessJobWake) {
3871
4958
  if (event.type === "assistant_thought") {
3872
4959
  appendTextPart(parts, "reasoning", event.text);
3873
4960
  return;
@@ -3875,6 +4962,8 @@ function applyEvent(parts, event, resolveMonitorWake) {
3875
4962
  if (event.type === "tool_call_started") {
3876
4963
  if (appliedMonitorWake(event, resolveMonitorWake) !== undefined)
3877
4964
  return;
4965
+ if (appliedProcessJobWake(event, resolveProcessJobWake) !== undefined)
4966
+ return;
3878
4967
  const historyUpdate = canonicalEventHistoryUpdate(event.history);
3879
4968
  const subagent = subagentOf(event);
3880
4969
  if (subagent !== undefined) {
@@ -3921,6 +5010,14 @@ function applyEvent(parts, event, resolveMonitorWake) {
3921
5010
  upsertMonitorActivity(parts, monitorWake.projection, monitorWake.deliveryKey);
3922
5011
  return;
3923
5012
  }
5013
+ const processJobWake = appliedProcessJobWake(event, resolveProcessJobWake);
5014
+ if (processJobWake !== undefined) {
5015
+ if (!parts.some((part) => part.type === "process-job-wake"
5016
+ && part.deliveryKey === processJobWake.deliveryKey)) {
5017
+ parts.push({ type: "process-job-wake", ...processJobWake, disposition: "steered" });
5018
+ }
5019
+ return;
5020
+ }
3924
5021
  const status = event.isError === true ? "failed" : "complete";
3925
5022
  const historyUpdate = canonicalEventHistoryUpdate(event.history);
3926
5023
  const executionMs = canonicalExecutionMs(event.executionMs);
@@ -3933,6 +5030,7 @@ function applyEvent(parts, event, resolveMonitorWake) {
3933
5030
  status,
3934
5031
  ...(executionMs === undefined ? {} : { executionMs }),
3935
5032
  ...(subagent.costUsd === undefined ? {} : { costUsd: subagent.costUsd }),
5033
+ ...(subagent.attribution === undefined ? {} : { attribution: subagent.attribution }),
3936
5034
  }, historyUpdate));
3937
5035
  return;
3938
5036
  }
@@ -3965,10 +5063,10 @@ function applyEvent(parts, event, resolveMonitorWake) {
3965
5063
  toolName: event.name ?? existingToolName(parts, event.id) ?? "Tool",
3966
5064
  ...(event.arguments === undefined ? {} : { args: event.arguments }),
3967
5065
  ...(event.content === undefined ? {} : { result: event.content }),
3968
- // `result` is the model-facing text and cannot answer "what did this tool
3969
- // actually decide". The AskUser card needs `interactionId`/`answered` to
3970
- // re-render an answered question after a reload, so keep the structured
3971
- // payload beside the prose rather than reparsing the sentence.
5066
+ // `result` is model-facing and cannot answer "what did this tool actually
5067
+ // decide". Keep bounded MCP and canonical host outcomes beside the prose:
5068
+ // AskUser needs its answer identity after reload, and process-job launches
5069
+ // need their exact causal receipt rather than reparsing a sentence.
3972
5070
  ...(event.structuredContent === undefined ? {} : { structuredResult: event.structuredContent }),
3973
5071
  ...(executionMs === undefined ? {} : { executionMs }),
3974
5072
  status,
@@ -3981,6 +5079,15 @@ function applyEvent(parts, event, resolveMonitorWake) {
3981
5079
  }
3982
5080
  parts.push({ type: "telemetry", event: event.type, data: event });
3983
5081
  }
5082
+ function appliedProcessJobWake(event, resolveProcessJobWake) {
5083
+ if (resolveProcessJobWake === undefined
5084
+ || event.metadata?.liveInput !== true
5085
+ || event.metadata?.synthetic !== true
5086
+ || typeof event.metadata.inputId !== "string") {
5087
+ return undefined;
5088
+ }
5089
+ return resolveProcessJobWake(event.metadata.inputId);
5090
+ }
3984
5091
  function upsertMonitorActivity(parts, projection, deliveryKey) {
3985
5092
  const index = parts.findIndex((part) => part.type === "monitor-activity");
3986
5093
  const previous = index < 0 ? undefined : parts[index];
@@ -4079,11 +5186,13 @@ function subagentOf(event) {
4079
5186
  const costUsd = typeof record.costUsd === "number" && Number.isFinite(record.costUsd) && record.costUsd > 0
4080
5187
  ? record.costUsd
4081
5188
  : undefined;
5189
+ const attribution = canonicalRunAttribution(record.attribution);
4082
5190
  return {
4083
5191
  id: canonicalId,
4084
5192
  name: name.length === 0 ? "subagent" : name,
4085
5193
  ...(label.length === 0 ? {} : { label }),
4086
5194
  ...(costUsd === undefined ? {} : { costUsd }),
5195
+ ...(attribution === undefined ? {} : { attribution }),
4087
5196
  };
4088
5197
  }
4089
5198
  /** Drop the `<profile>▸` prefix: the group header already names the profile. */
@@ -4311,6 +5420,81 @@ function replaceWholeText(parts, text) {
4311
5420
  parts.splice(index, 1);
4312
5421
  }
4313
5422
  }
5423
+ /**
5424
+ * The ops that turn `prev` into `next`.
5425
+ *
5426
+ * Parts are compared by REFERENCE, which is sound only because every helper
5427
+ * above replaces the slot it touches with a new object instead of editing the
5428
+ * one already there — `store.test.ts` freezes a write path's previous parts to
5429
+ * keep it that way. A truncate leads, so no later op can name an index the
5430
+ * shortened array no longer has; the rest ascend, so a replay that walks them
5431
+ * in order only ever extends the array by one slot at a time.
5432
+ */
5433
+ export function diffParts(prev, next) {
5434
+ const ops = [];
5435
+ if (next.length < prev.length)
5436
+ ops.push({ op: "truncate", length: next.length });
5437
+ for (let index = 0; index < next.length; index += 1) {
5438
+ const after = next[index];
5439
+ if (after === undefined)
5440
+ continue;
5441
+ const before = prev[index];
5442
+ if (before === after)
5443
+ continue;
5444
+ // A streaming answer grows by its tail, which is the whole point of the
5445
+ // exercise: sending the delta rather than the message again is what takes
5446
+ // a long answer's per-frame cost off the wire.
5447
+ if (before !== undefined
5448
+ && (after.type === "text" || after.type === "reasoning")
5449
+ && before.type === after.type
5450
+ && after.text.startsWith(before.text)) {
5451
+ const delta = after.text.slice(before.text.length);
5452
+ if (delta.length > 0)
5453
+ ops.push({ op: "append", index, delta });
5454
+ continue;
5455
+ }
5456
+ ops.push({ op: "set", index, part: after });
5457
+ }
5458
+ return ops;
5459
+ }
5460
+ /**
5461
+ * Replay `ops` onto `parts`, the exact inverse of {@link diffParts}.
5462
+ *
5463
+ * This is the shared definition of what an op MEANS, so the console can apply a
5464
+ * delta without inventing its own reading of one. Anything the ops cannot mean
5465
+ * against these parts throws rather than producing a plausible transcript: a
5466
+ * client that lands here has missed a write and must re-read the message.
5467
+ *
5468
+ * The throws are plain `RangeError`/`TypeError` rather than `WebConsoleError`:
5469
+ * this is a pure function that runs on both sides of the wire, so it has no
5470
+ * request to answer and no HTTP status to pick. Its caller re-reads the message
5471
+ * rather than mapping a code.
5472
+ */
5473
+ export function applyDeltaOps(parts, ops) {
5474
+ let next = [...parts];
5475
+ for (const op of ops) {
5476
+ if (op.op === "truncate") {
5477
+ if (!Number.isInteger(op.length) || op.length < 0 || op.length > next.length) {
5478
+ throw new RangeError(`A message delta truncates to ${String(op.length)} parts, which is out of range for ${String(next.length)}.`);
5479
+ }
5480
+ next = next.slice(0, op.length);
5481
+ continue;
5482
+ }
5483
+ if (!Number.isInteger(op.index) || op.index < 0 || op.index > next.length) {
5484
+ throw new RangeError(`A message delta names part ${String(op.index)}, which is out of range for ${String(next.length)}.`);
5485
+ }
5486
+ if (op.op === "set") {
5487
+ next[op.index] = op.part;
5488
+ continue;
5489
+ }
5490
+ const target = next[op.index];
5491
+ if (target === undefined || (target.type !== "text" && target.type !== "reasoning")) {
5492
+ throw new TypeError(`A message delta appends to part ${String(op.index)}, which cannot be appended to.`);
5493
+ }
5494
+ next[op.index] = { type: target.type, text: `${target.text}${op.delta}` };
5495
+ }
5496
+ return next;
5497
+ }
4314
5498
  function deriveAutomaticTitle(text, attachments) {
4315
5499
  const candidate = text.trim().length > 0 ? text : attachments[0]?.name ?? "New conversation";
4316
5500
  return normalizeTitle(candidate.replace(/\s+/gu, " ").slice(0, 80));
@@ -4622,6 +5806,10 @@ function parseParts(value) {
4622
5806
  if (parts.filter((part) => part.type === "monitor-activity").length > 1) {
4623
5807
  throw new WebConsoleError("storage_corrupt", "Persisted Monitor activity is duplicated.", 500);
4624
5808
  }
5809
+ const wakeKeys = parts.flatMap((part) => part.type === "process-job-wake" ? [part.deliveryKey] : []);
5810
+ if (new Set(wakeKeys).size !== wakeKeys.length) {
5811
+ throw new WebConsoleError("storage_corrupt", "Persisted process-job wake activity is duplicated.", 500);
5812
+ }
4625
5813
  quoteFromParts(parts);
4626
5814
  return parts;
4627
5815
  }
@@ -4648,7 +5836,7 @@ function liveInputStatusFromParts(parts) {
4648
5836
  throw new WebConsoleError("storage_corrupt", "Persisted live-input metadata is invalid.", 500);
4649
5837
  }
4650
5838
  const status = data.status;
4651
- if (status !== "pending" && status !== "applied" && status !== "queued" && status !== "cancelled") {
5839
+ if (status !== "pending" && status !== "applied" && status !== "queued" && status !== "cancelled" && status !== "uncertain") {
4652
5840
  throw new WebConsoleError("storage_corrupt", "Persisted live-input status is invalid.", 500);
4653
5841
  }
4654
5842
  return status;
@@ -4701,7 +5889,9 @@ function canonicalSessionToolHistoryMetadata(value) {
4701
5889
  if (typeof value !== "object" || value === null || Array.isArray(value))
4702
5890
  return undefined;
4703
5891
  const history = value;
4704
- const valid = (history.persistence === "persisted" || history.persistence === "failed")
5892
+ const valid = (history.persistence === "persisted"
5893
+ || history.persistence === "deferred"
5894
+ || history.persistence === "failed")
4705
5895
  && history.untrusted === true
4706
5896
  && (history.recordId === undefined || boundedHistoryString(history.recordId, 4_096))
4707
5897
  && (history.sequence === undefined || positiveSafeInteger(history.sequence))
@@ -4768,8 +5958,11 @@ function canonicalizePersistedPartHistory(value) {
4768
5958
  if (part.type !== "subagent")
4769
5959
  return value;
4770
5960
  const canonicalPart = canonicalizePersistedObjectHistory(part);
5961
+ const { attribution: _rawAttribution, ...withoutAttribution } = canonicalPart;
5962
+ const attribution = canonicalRunAttribution(part.attribution);
4771
5963
  return {
4772
- ...canonicalPart,
5964
+ ...withoutAttribution,
5965
+ ...(attribution === undefined ? {} : { attribution }),
4773
5966
  ...(Array.isArray(part.calls)
4774
5967
  ? { calls: part.calls.map((call) => canonicalizePersistedHistoryRecord(call)) }
4775
5968
  : {}),
@@ -4827,6 +6020,7 @@ function isWebMessagePart(value) {
4827
6020
  && (part.label === undefined || typeof part.label === "string")
4828
6021
  && (part.executionMs == null || typeof part.executionMs === "number")
4829
6022
  && (part.costUsd === undefined || typeof part.costUsd === "number")
6023
+ && (part.attribution === undefined || canonicalRunAttribution(part.attribution) !== undefined)
4830
6024
  && (part.history === undefined || isSessionToolHistoryMetadata(part.history))
4831
6025
  && isWebToolCallStatus(part.status)
4832
6026
  && Array.isArray(part.calls)
@@ -4845,6 +6039,15 @@ function isWebMessagePart(value) {
4845
6039
  return false;
4846
6040
  }
4847
6041
  }
6042
+ if (part.type === "process-job-wake") {
6043
+ return hasOnlyKeys(part, new Set(["type", "jobId", "deliveryKey", "disposition"]))
6044
+ && validRichId(part.jobId)
6045
+ && typeof part.deliveryKey === "string"
6046
+ && part.deliveryKey.length > 0
6047
+ && part.deliveryKey.length <= 1_024
6048
+ && !/[\u0000-\u001f\u007f]/u.test(part.deliveryKey)
6049
+ && (part.disposition === "steered" || part.disposition === "follow_up");
6050
+ }
4848
6051
  if (part.type === "monitor-activity") {
4849
6052
  if (!hasOnlyKeys(part, new Set(["type", "monitors"]))
4850
6053
  || !Array.isArray(part.monitors)
@@ -5072,16 +6275,187 @@ function normalizeRunStatus(value) {
5072
6275
  ? value
5073
6276
  : "complete";
5074
6277
  }
6278
+ const WEB_ROUTE_HISTORY_MAX_ENTRIES = 32;
6279
+ function canonicalRouteString(value, maxCharacters = 256) {
6280
+ if (typeof value !== "string")
6281
+ return undefined;
6282
+ const normalized = value.trim().slice(0, maxCharacters);
6283
+ return normalized.length === 0 ? undefined : normalized;
6284
+ }
6285
+ function canonicalRouteIndex(value) {
6286
+ return Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : undefined;
6287
+ }
6288
+ function canonicalRouteTransition(value) {
6289
+ if (typeof value !== "object" || value === null || Array.isArray(value))
6290
+ return undefined;
6291
+ const record = value;
6292
+ const from = canonicalRouteString(record.from);
6293
+ const to = canonicalRouteString(record.to);
6294
+ if (from === undefined || to === undefined)
6295
+ return undefined;
6296
+ const attemptIndex = canonicalRouteIndex(record.attemptIndex);
6297
+ const reason = canonicalRouteString(record.reason, 128);
6298
+ return {
6299
+ from,
6300
+ to,
6301
+ ...(attemptIndex === undefined ? {} : { attemptIndex }),
6302
+ ...(reason === undefined ? {} : { reason }),
6303
+ };
6304
+ }
6305
+ function canonicalRouteRetry(value) {
6306
+ if (typeof value !== "object" || value === null || Array.isArray(value))
6307
+ return undefined;
6308
+ const record = value;
6309
+ const model = canonicalRouteString(record.model);
6310
+ const retryIndex = canonicalRouteIndex(record.retryIndex);
6311
+ const attempts = canonicalRouteIndex(record.attempts);
6312
+ const reason = canonicalRouteString(record.reason, 128);
6313
+ return {
6314
+ ...(model === undefined ? {} : { model }),
6315
+ ...(retryIndex === undefined ? {} : { retryIndex }),
6316
+ ...(attempts === undefined ? {} : { attempts }),
6317
+ ...(reason === undefined ? {} : { reason }),
6318
+ };
6319
+ }
6320
+ function boundedRouteEntries(entries) {
6321
+ if (entries.length <= WEB_ROUTE_HISTORY_MAX_ENTRIES)
6322
+ return { entries, truncated: false };
6323
+ return {
6324
+ entries: [entries[0], ...entries.slice(-(WEB_ROUTE_HISTORY_MAX_ENTRIES - 1))],
6325
+ truncated: true,
6326
+ };
6327
+ }
6328
+ function canonicalRoutingState(value) {
6329
+ const record = typeof value === "object" && value !== null && !Array.isArray(value)
6330
+ ? value
6331
+ : {};
6332
+ const transitions = boundedRouteEntries((Array.isArray(record.transitions) ? record.transitions : [])
6333
+ .map(canonicalRouteTransition)
6334
+ .filter((entry) => entry !== undefined));
6335
+ const retries = boundedRouteEntries((Array.isArray(record.retries) ? record.retries : [])
6336
+ .map(canonicalRouteRetry)
6337
+ .filter((entry) => entry !== undefined));
6338
+ return {
6339
+ transitions: transitions.entries,
6340
+ retries: retries.entries,
6341
+ ...(record.truncated === true || transitions.truncated || retries.truncated ? { truncated: true } : {}),
6342
+ };
6343
+ }
6344
+ function parseRoutingState(value) {
6345
+ try {
6346
+ return canonicalRoutingState(JSON.parse(value));
6347
+ }
6348
+ catch {
6349
+ throw new WebConsoleError("storage_corrupt", "Persisted turn routing metadata is not valid JSON.", 500);
6350
+ }
6351
+ }
6352
+ function serializeRoutingState(value) {
6353
+ return JSON.stringify(canonicalRoutingState(value));
6354
+ }
6355
+ function appendRouteTransition(state, entry) {
6356
+ const bounded = boundedRouteEntries([...state.transitions, entry]);
6357
+ return {
6358
+ ...state,
6359
+ transitions: bounded.entries,
6360
+ ...(state.truncated === true || bounded.truncated ? { truncated: true } : {}),
6361
+ };
6362
+ }
6363
+ function appendRouteRetry(state, entry) {
6364
+ const bounded = boundedRouteEntries([...state.retries, entry]);
6365
+ return {
6366
+ ...state,
6367
+ retries: bounded.entries,
6368
+ ...(state.truncated === true || bounded.truncated ? { truncated: true } : {}),
6369
+ };
6370
+ }
6371
+ function canonicalRunSelection(value) {
6372
+ if (typeof value !== "object" || value === null || Array.isArray(value))
6373
+ return undefined;
6374
+ const record = value;
6375
+ const model = canonicalRouteString(record.model);
6376
+ const effort = canonicalRouteString(record.effort, 64);
6377
+ return {
6378
+ ...(model === undefined ? {} : { model }),
6379
+ ...(effort === undefined ? {} : { effort }),
6380
+ };
6381
+ }
6382
+ function canonicalRunExecution(value) {
6383
+ const selection = canonicalRunSelection(value);
6384
+ if (selection === undefined)
6385
+ return undefined;
6386
+ const effectiveEffort = canonicalRouteString(value.effectiveEffort, 64);
6387
+ return {
6388
+ ...selection,
6389
+ ...(effectiveEffort === undefined ? {} : { effectiveEffort }),
6390
+ };
6391
+ }
6392
+ function canonicalRunAttribution(value) {
6393
+ if (typeof value !== "object" || value === null || Array.isArray(value))
6394
+ return undefined;
6395
+ const record = value;
6396
+ const requested = canonicalRunSelection(record.requested);
6397
+ if (requested === undefined
6398
+ || (record.disposition !== "requested" && record.disposition !== "fallback" && record.disposition !== "unknown")) {
6399
+ return undefined;
6400
+ }
6401
+ const attempted = canonicalRunExecution(record.attempted);
6402
+ const executed = canonicalRunExecution(record.executed);
6403
+ const routing = canonicalRoutingState(record);
6404
+ return {
6405
+ requested,
6406
+ ...(attempted === undefined ? {} : { attempted }),
6407
+ ...(executed === undefined ? {} : { executed }),
6408
+ disposition: record.disposition,
6409
+ transitions: routing.transitions,
6410
+ retries: routing.retries,
6411
+ ...(record.truncated === true || routing.truncated === true ? { truncated: true } : {}),
6412
+ };
6413
+ }
6414
+ function runAttribution(row) {
6415
+ const requestedModel = canonicalRouteString(row.requested_model);
6416
+ const requestedEffort = canonicalRouteString(row.requested_effort, 64);
6417
+ const requested = {
6418
+ ...(requestedModel === undefined ? {} : { model: requestedModel }),
6419
+ ...(requestedEffort === undefined ? {} : { effort: requestedEffort }),
6420
+ };
6421
+ const attemptedModel = canonicalRouteString(row.model);
6422
+ const attemptedEffort = canonicalRouteString(row.effort, 64);
6423
+ const effectiveEffort = canonicalRouteString(row.effective_effort, 64);
6424
+ const attempted = {
6425
+ ...(attemptedModel === undefined ? {} : { model: attemptedModel }),
6426
+ ...(attemptedEffort === undefined ? {} : { effort: attemptedEffort }),
6427
+ ...(effectiveEffort === undefined ? {} : { effectiveEffort }),
6428
+ };
6429
+ const routing = parseRoutingState(row.routing_json);
6430
+ const hasRequested = requested.model !== undefined || requested.effort !== undefined;
6431
+ const hasAttempted = attempted.model !== undefined || attempted.effort !== undefined || attempted.effectiveEffort !== undefined;
6432
+ if (!hasRequested && !hasAttempted && routing.transitions.length === 0 && routing.retries.length === 0)
6433
+ return undefined;
6434
+ const executed = row.status === "complete" && hasAttempted ? attempted : undefined;
6435
+ const fallback = routing.transitions.length > 0
6436
+ || (executed?.model !== undefined && requested.model !== undefined && executed.model !== requested.model);
6437
+ return {
6438
+ requested,
6439
+ ...(hasAttempted ? { attempted } : {}),
6440
+ ...(executed === undefined ? {} : { executed }),
6441
+ disposition: requested.model === undefined ? "unknown" : fallback ? "fallback" : "requested",
6442
+ transitions: routing.transitions,
6443
+ retries: routing.retries,
6444
+ ...(routing.truncated === true ? { truncated: true } : {}),
6445
+ };
6446
+ }
5075
6447
  function runtimeMetadata(metadata) {
5076
6448
  const runtime = metadata?.runtime;
5077
6449
  if (typeof runtime !== "object" || runtime === null || Array.isArray(runtime))
5078
6450
  return undefined;
5079
6451
  const record = runtime;
5080
- const model = typeof record.model === "string" ? record.model : undefined;
5081
- const effort = typeof record.effort === "string" ? record.effort : undefined;
5082
- return model === undefined && effort === undefined ? undefined : {
6452
+ const model = canonicalRouteString(record.model);
6453
+ const effort = canonicalRouteString(record.effort, 64);
6454
+ const effectiveEffort = canonicalRouteString(record.effectiveEffort, 64);
6455
+ return model === undefined && effort === undefined && effectiveEffort === undefined ? undefined : {
5083
6456
  ...(model === undefined ? {} : { model }),
5084
6457
  ...(effort === undefined ? {} : { effort }),
6458
+ ...(effectiveEffort === undefined ? {} : { effectiveEffort }),
5085
6459
  };
5086
6460
  }
5087
6461
  function ignoreMissing(error) {