@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.
- package/README.md +388 -70
- package/dist/contracts.d.ts +364 -8
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +2 -0
- package/dist/contracts.js.map +1 -1
- package/dist/cron-reply-context.d.ts +27 -0
- package/dist/cron-reply-context.d.ts.map +1 -0
- package/dist/cron-reply-context.js +241 -0
- package/dist/cron-reply-context.js.map +1 -0
- package/dist/discovery.d.ts +1 -0
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +8 -6
- package/dist/discovery.js.map +1 -1
- package/dist/effort-ladder.d.ts +14 -0
- package/dist/effort-ladder.d.ts.map +1 -1
- package/dist/effort-ladder.js +41 -0
- package/dist/effort-ladder.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/long-lived-fetch.d.ts +5 -0
- package/dist/long-lived-fetch.d.ts.map +1 -0
- package/dist/long-lived-fetch.js +22 -0
- package/dist/long-lived-fetch.js.map +1 -0
- package/dist/monitor-reply.d.ts +10 -0
- package/dist/monitor-reply.d.ts.map +1 -0
- package/dist/monitor-reply.js +67 -0
- package/dist/monitor-reply.js.map +1 -0
- package/dist/notification-client.d.ts.map +1 -1
- package/dist/notification-client.js +5 -4
- package/dist/notification-client.js.map +1 -1
- package/dist/operator-client.d.ts +25 -1
- package/dist/operator-client.d.ts.map +1 -1
- package/dist/operator-client.js +167 -8
- package/dist/operator-client.js.map +1 -1
- package/dist/server.d.ts +42 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +627 -49
- package/dist/server.js.map +1 -1
- package/dist/service.d.ts +201 -8
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +1425 -136
- package/dist/service.js.map +1 -1
- package/dist/store-migrations.d.ts +23 -0
- package/dist/store-migrations.d.ts.map +1 -0
- package/dist/store-migrations.js +212 -0
- package/dist/store-migrations.js.map +1 -0
- package/dist/store.d.ts +294 -17
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +1637 -263
- package/dist/store.js.map +1 -1
- package/package.json +8 -5
- package/webapp/dist/assets/{assistant-ui-BzN2E6n6.js → assistant-ui-pZmGxIp2.js} +16 -16
- package/webapp/dist/assets/index-6uQ7TVQe.css +1 -0
- package/webapp/dist/assets/index-CsSMjSgW.js +156 -0
- package/webapp/dist/assets/{markdown-Vq23xgh7.js → markdown-Du5t10ja.js} +1 -1
- package/webapp/dist/badge-96.png +0 -0
- package/webapp/dist/index.html +26 -6
- package/webapp/dist/manifest.webmanifest +1 -1
- package/webapp/dist/notification-sw.js +3 -1
- package/webapp/dist/sw.js +1 -1
- package/webapp/dist/{workbox-9c191d2f.js → workbox-2fbc6a65.js} +1 -1
- package/webapp/dist/assets/index-C4a2Dv1W.js +0 -155
- package/webapp/dist/assets/index-mhMBLGB0.css +0 -1
package/dist/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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
491
|
-
|
|
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.
|
|
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 {
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
|
|
1161
|
+
storedChanged = true;
|
|
860
1162
|
}
|
|
861
1163
|
}
|
|
862
|
-
if (
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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)].
|
|
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.
|
|
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
|
-
/**
|
|
1025
|
-
|
|
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
|
|
1103
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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', ?,
|
|
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',
|
|
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 (
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1924
|
-
.
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
if (frame.
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
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(
|
|
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
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
3042
|
-
|
|
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
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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(
|
|
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 =
|
|
3215
|
-
.
|
|
3216
|
-
.
|
|
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
|
|
3562
|
-
|
|
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.
|
|
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
|
|
3969
|
-
//
|
|
3970
|
-
//
|
|
3971
|
-
//
|
|
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"
|
|
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
|
-
...
|
|
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 =
|
|
5081
|
-
const effort =
|
|
5082
|
-
|
|
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) {
|