@mono-agent/web 0.20.11 → 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 +397 -68
- package/dist/contracts.d.ts +437 -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 +111 -0
- package/dist/effort-ladder.d.ts.map +1 -0
- package/dist/effort-ladder.js +155 -0
- package/dist/effort-ladder.js.map +1 -0
- package/dist/index.d.ts +3 -3
- 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 +11 -2
- 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/notification-ingress.d.ts +8 -1
- package/dist/notification-ingress.d.ts.map +1 -1
- package/dist/notification-ingress.js +28 -6
- package/dist/notification-ingress.js.map +1 -1
- package/dist/operator-client.d.ts +35 -2
- package/dist/operator-client.d.ts.map +1 -1
- package/dist/operator-client.js +276 -9
- 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 +679 -52
- package/dist/server.js.map +1 -1
- package/dist/service.d.ts +299 -18
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +1915 -160
- 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 +389 -21
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +2087 -245
- package/dist/store.js.map +1 -1
- package/package.json +9 -8
- 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-BT463dRM.css +0 -1
- package/webapp/dist/assets/index-Co-qDQPq.js +0 -155
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 = 10;
|
|
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;
|
|
@@ -133,6 +154,22 @@ export class WebStore {
|
|
|
133
154
|
database;
|
|
134
155
|
clock;
|
|
135
156
|
closed = false;
|
|
157
|
+
/**
|
|
158
|
+
* The agent PROCESS generation each live summary was built from, by source
|
|
159
|
+
* id. Deliberately not a column: it describes the process behind a source id
|
|
160
|
+
* right now, so a value read back from disk after a console restart would be
|
|
161
|
+
* a claim about a process nobody probed. `replaceAgents` is the only writer,
|
|
162
|
+
* and it drops ids discovery no longer reports.
|
|
163
|
+
*/
|
|
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();
|
|
136
173
|
constructor(database, paths, clock) {
|
|
137
174
|
this.database = database;
|
|
138
175
|
this.paths = paths;
|
|
@@ -166,6 +203,7 @@ export class WebStore {
|
|
|
166
203
|
// After recovery, so anything the recovery settled is already indexed by
|
|
167
204
|
// its own trigger and this only sweeps what genuinely stayed running.
|
|
168
205
|
store.reindexUnsettledMessages();
|
|
206
|
+
store.reindexLegacyMonitorMessages();
|
|
169
207
|
return store;
|
|
170
208
|
}
|
|
171
209
|
catch (error) {
|
|
@@ -177,54 +215,147 @@ export class WebStore {
|
|
|
177
215
|
if (this.closed)
|
|
178
216
|
return;
|
|
179
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();
|
|
180
221
|
this.database.close();
|
|
181
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Persist the discovered agent list. Returns whether the change is worth
|
|
225
|
+
* telling clients about.
|
|
226
|
+
*
|
|
227
|
+
* `updatedAt` is a heartbeat timestamp that moves on every discovery poll, so
|
|
228
|
+
* comparing it made this return `true` roughly once every five seconds. At the
|
|
229
|
+
* time, `agents.changed` carried the whole agent list, every model, every model
|
|
230
|
+
* option and every provider. Measured against a 67-agent fleet that was 60 KB
|
|
231
|
+
* per event and 99.5% of all SSE traffic: ~42 MiB/hour to an idle console with
|
|
232
|
+
* nobody using it. The event is now a compact invalidation, but a heartbeat is
|
|
233
|
+
* still not a state change worth making every browser re-bootstrap for.
|
|
234
|
+
*
|
|
235
|
+
* So the two questions are separated. Any difference at all is still written,
|
|
236
|
+
* because the store should hold the freshest heartbeat; only a difference that
|
|
237
|
+
* survives normalizing `updatedAt` is broadcast. Nothing in the console reads
|
|
238
|
+
* an agent's `updatedAt` --- it stays on the wire for clients that want it, it
|
|
239
|
+
* just no longer triggers a fleet-sized frame on its own. `agentGeneration()`
|
|
240
|
+
* already excludes it for the same reason.
|
|
241
|
+
*/
|
|
182
242
|
replaceAgents(agents) {
|
|
183
243
|
const current = this.listAgents();
|
|
184
244
|
const currentById = new Map(current.map((agent) => [agent.sourceId, agent]));
|
|
185
245
|
const incomingIds = new Set(agents.map((agent) => agent.sourceId));
|
|
186
|
-
|
|
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));
|
|
250
|
+
const differs = (agent, ignoreHeartbeat) => {
|
|
187
251
|
const prior = currentById.get(agent.sourceId);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
252
|
+
if (prior === undefined)
|
|
253
|
+
return true;
|
|
254
|
+
// `pinned` is store-owned and never arrives from discovery; normalizing it
|
|
255
|
+
// keeps a locally pinned agent from looking like an incoming change.
|
|
256
|
+
const next = { ...agent, pinned: prior.pinned, runSettings: prior.runSettings };
|
|
257
|
+
if (!ignoreHeartbeat)
|
|
258
|
+
return !isDeepStrictEqual(prior, next);
|
|
259
|
+
return !isDeepStrictEqual({ ...prior, updatedAt: "" }, { ...next, updatedAt: "" });
|
|
260
|
+
};
|
|
261
|
+
const changed = agents.some((agent) => differs(agent, false)) || departed;
|
|
262
|
+
const notable = agents.some((agent) => differs(agent, true)) || departed;
|
|
263
|
+
// After `current` is read (it carries the PREVIOUS generations) and after
|
|
264
|
+
// both comparisons, so a restart behind an otherwise identical summary
|
|
265
|
+
// still reads as a change and still reaches the browser.
|
|
266
|
+
//
|
|
267
|
+
// And only once the row it describes is actually on disk. Advancing before
|
|
268
|
+
// the transaction made the map a claim the database had not agreed to: a
|
|
269
|
+
// transaction that threw left the NEW generation stitched onto the OLD row,
|
|
270
|
+
// so the retry compared the new summary against a prior that already
|
|
271
|
+
// carried its generation, found only `updatedAt` different, and returned
|
|
272
|
+
// `notable === false`. The restart broadcast was then lost permanently ---
|
|
273
|
+
// not deferred --- and an open console stayed stale until it reconnected.
|
|
274
|
+
const adoptGenerations = () => {
|
|
275
|
+
this.agentGenerations.clear();
|
|
276
|
+
for (const agent of agents) {
|
|
277
|
+
if (agent.generation !== undefined)
|
|
278
|
+
this.agentGenerations.set(agent.sourceId, agent.generation);
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
if (!changed) {
|
|
282
|
+
// Nothing to persist, so there is nothing for the adoption to outrun:
|
|
283
|
+
// every incoming generation already equals the one it is replacing, or
|
|
284
|
+
// `changed` would be true. The map is still swept so a departed agent
|
|
285
|
+
// does not leave one behind.
|
|
286
|
+
adoptGenerations();
|
|
191
287
|
return false;
|
|
288
|
+
}
|
|
192
289
|
this.transaction(() => {
|
|
193
|
-
|
|
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();
|
|
194
295
|
const statement = this.database.prepare(`
|
|
195
296
|
INSERT INTO agents (
|
|
196
|
-
source_id, label, status, health, supports_attachments, models_json,
|
|
297
|
+
source_id, label, status, discovered, health, supports_attachments, supports_provider_auth, models_json,
|
|
197
298
|
default_model, default_effort, efforts_json, model_options_json,
|
|
198
|
-
cron_read, cron_actions, ask_by_id, updated_at
|
|
199
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
299
|
+
providers_json, cron_read, cron_actions, ask_by_id, updated_at
|
|
300
|
+
) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
200
301
|
ON CONFLICT(source_id) DO UPDATE SET
|
|
201
302
|
label = excluded.label,
|
|
202
303
|
status = excluded.status,
|
|
304
|
+
discovered = 1,
|
|
203
305
|
health = excluded.health,
|
|
204
306
|
supports_attachments = excluded.supports_attachments,
|
|
307
|
+
supports_provider_auth = excluded.supports_provider_auth,
|
|
205
308
|
models_json = excluded.models_json,
|
|
206
309
|
default_model = excluded.default_model,
|
|
207
310
|
default_effort = excluded.default_effort,
|
|
208
311
|
efforts_json = excluded.efforts_json,
|
|
209
312
|
model_options_json = excluded.model_options_json,
|
|
313
|
+
providers_json = excluded.providers_json,
|
|
210
314
|
cron_read = excluded.cron_read,
|
|
211
315
|
cron_actions = excluded.cron_actions,
|
|
212
316
|
ask_by_id = excluded.ask_by_id,
|
|
213
317
|
updated_at = excluded.updated_at
|
|
214
318
|
`);
|
|
215
319
|
for (const agent of agents) {
|
|
216
|
-
statement.run(agent.sourceId, agent.label, agent.status, agent.health ?? null, agent.supportsAttachments ? 1 : 0, stringifyOptional(agent.models), agent.defaultModel ?? null, agent.defaultEffort ?? null, stringifyOptional(agent.efforts), stringifyOptional(agent.modelOptions), agent.cron?.read === true ? 1 : 0, agent.cron?.actions === true ? 1 : 0, agent.supportsAskById === true ? 1 : 0, agent.updatedAt);
|
|
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);
|
|
217
321
|
}
|
|
218
322
|
});
|
|
323
|
+
adoptGenerations();
|
|
324
|
+
return notable;
|
|
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();
|
|
219
340
|
return true;
|
|
220
341
|
}
|
|
221
342
|
listAgents() {
|
|
222
|
-
const rows = this.database.prepare(agentSelectSql("ORDER BY pinned DESC, a.label COLLATE NOCASE, a.source_id")).all();
|
|
223
|
-
return rows.map(mapAgent);
|
|
343
|
+
const rows = this.database.prepare(agentSelectSql("WHERE a.discovered = 1 ORDER BY pinned DESC, a.label COLLATE NOCASE, a.source_id")).all();
|
|
344
|
+
return rows.map((row) => this.withGeneration(mapAgent(row)));
|
|
224
345
|
}
|
|
225
346
|
getAgent(sourceId) {
|
|
226
|
-
const row = this.database.prepare(agentSelectSql("WHERE a.source_id = ?")).get(sourceId);
|
|
227
|
-
return row === undefined ? undefined : mapAgent(row);
|
|
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);
|
|
353
|
+
return row === undefined ? undefined : this.withGeneration(mapAgent(row));
|
|
354
|
+
}
|
|
355
|
+
/** Stitch the live generation onto a row read back from disk. */
|
|
356
|
+
withGeneration(agent) {
|
|
357
|
+
const generation = this.agentGenerations.get(agent.sourceId);
|
|
358
|
+
return generation === undefined ? agent : { ...agent, generation };
|
|
228
359
|
}
|
|
229
360
|
setAgentPinned(sourceId, pinned) {
|
|
230
361
|
if (this.getAgent(sourceId) === undefined) {
|
|
@@ -240,6 +371,30 @@ export class WebStore {
|
|
|
240
371
|
throw new WebConsoleError("agent_not_found", "The selected agent is no longer available.", 404);
|
|
241
372
|
return agent;
|
|
242
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
|
+
}
|
|
243
398
|
reserveNotification(input) {
|
|
244
399
|
if (this.getAgent(input.sourceId) === undefined) {
|
|
245
400
|
throw new WebConsoleError("agent_not_found", "The notification agent is no longer available.", 404);
|
|
@@ -356,6 +511,14 @@ export class WebStore {
|
|
|
356
511
|
let turnId = randomUUID();
|
|
357
512
|
let assistantMessageId = randomUUID();
|
|
358
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;
|
|
359
522
|
this.transaction(() => {
|
|
360
523
|
const cronChannel = reservation.jobId === undefined
|
|
361
524
|
? undefined
|
|
@@ -406,21 +569,26 @@ export class WebStore {
|
|
|
406
569
|
id, thread_id, turn_id, role, parts_json, created_at, updated_at, status
|
|
407
570
|
) VALUES (?, ?, ?, 'assistant', ?, ?, ?, 'complete')
|
|
408
571
|
`).run(assistantMessageId, completedThreadId, turnId, serializeParts([{ type: "text", text: reservation.text }]), now, now);
|
|
572
|
+
wroteMessage = true;
|
|
409
573
|
}
|
|
410
574
|
else {
|
|
411
575
|
turnId = mappedRun.turn_id;
|
|
412
576
|
assistantMessageId = mappedRun.message_id;
|
|
413
577
|
const message = this.database.prepare(`
|
|
414
|
-
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 = ?
|
|
415
579
|
`).get(assistantMessageId, completedThreadId, turnId);
|
|
416
580
|
if (message === undefined) {
|
|
417
581
|
throw new WebConsoleError("storage_corrupt", "A cron run mapping is missing its message.", 500);
|
|
418
582
|
}
|
|
419
|
-
const parts = parseParts(message.parts_json)
|
|
420
|
-
|
|
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))
|
|
421
587
|
parts.push({ type: "text", text: reservation.text });
|
|
422
|
-
|
|
423
|
-
|
|
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;
|
|
424
592
|
}
|
|
425
593
|
}
|
|
426
594
|
this.database.prepare(`
|
|
@@ -431,7 +599,7 @@ export class WebStore {
|
|
|
431
599
|
UPDATE notification_deliveries SET thread_id = ?, message_id = ?, completed_at = ?
|
|
432
600
|
WHERE source_id = ? AND delivery_key = ? AND completed_at IS NULL
|
|
433
601
|
`).run(completedThreadId, assistantMessageId, now, reservation.sourceId, reservation.deliveryKey);
|
|
434
|
-
const agent = this.
|
|
602
|
+
const agent = this.getStoredAgent(reservation.sourceId);
|
|
435
603
|
this.enqueueWebPushEventInTransaction({
|
|
436
604
|
logicalKey: notificationPushLogicalKey(reservation.sourceId, reservation.deliveryKey),
|
|
437
605
|
kind: "response.ready",
|
|
@@ -443,7 +611,12 @@ export class WebStore {
|
|
|
443
611
|
notBefore: new Date(new Date(now).getTime() + 3_000).toISOString(),
|
|
444
612
|
});
|
|
445
613
|
});
|
|
446
|
-
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
|
+
};
|
|
447
620
|
}
|
|
448
621
|
/** Persist an agent-authoritative overview without deriving scheduler facts in the console. */
|
|
449
622
|
syncCronOverviewResult(overview) {
|
|
@@ -637,11 +810,184 @@ export class WebStore {
|
|
|
637
810
|
storedCronRuns(sourceId, jobId, limit = 100) {
|
|
638
811
|
const bounded = boundedPageLimit(limit, 100);
|
|
639
812
|
const rows = this.database.prepare(`
|
|
640
|
-
SELECT payload_json FROM cron_run_messages
|
|
813
|
+
SELECT payload_json, message_id FROM cron_run_messages
|
|
641
814
|
WHERE source_id = ? AND job_id = ?
|
|
642
815
|
ORDER BY ordered_at DESC, sequence DESC, run_id DESC LIMIT ?
|
|
643
816
|
`).all(sourceId, jobId, bounded);
|
|
644
|
-
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
|
+
});
|
|
645
991
|
}
|
|
646
992
|
reconcileCronRuns(sourceId, jobId, runs) {
|
|
647
993
|
return [...this.reconcileCronRunsResult(sourceId, jobId, runs).messages];
|
|
@@ -659,10 +1005,15 @@ export class WebStore {
|
|
|
659
1005
|
: parseStoredCronJob(jobRow.payload_json).conversationId;
|
|
660
1006
|
const ordered = [...runs].sort(compareCronRuns);
|
|
661
1007
|
if (ordered.length === 0)
|
|
662
|
-
return { messages: [], changed: false };
|
|
1008
|
+
return { messages: [], changed: false, writtenMessageIds: [] };
|
|
663
1009
|
const now = this.now();
|
|
664
1010
|
const messageIds = [];
|
|
1011
|
+
// Only the rows whose parts this run actually moved -- see
|
|
1012
|
+
// {@link CronRunReconciliationResult.writtenMessageIds}.
|
|
1013
|
+
const written = new Set();
|
|
665
1014
|
let changed = false;
|
|
1015
|
+
let storedChanged = false;
|
|
1016
|
+
let visibleActivityAt;
|
|
666
1017
|
this.transaction(() => {
|
|
667
1018
|
for (const run of ordered) {
|
|
668
1019
|
if (run.jobId !== jobId) {
|
|
@@ -676,24 +1027,30 @@ export class WebStore {
|
|
|
676
1027
|
if (mapped !== undefined && (mapped.ordered_at !== run.orderedAt || mapped.sequence !== run.sequence)) {
|
|
677
1028
|
throw new WebConsoleError("invalid_cron_response", "Cron run ordering identity changed after admission.", 502);
|
|
678
1029
|
}
|
|
679
|
-
const delivered =
|
|
680
|
-
? this.database.prepare(`
|
|
1030
|
+
const delivered = this.database.prepare(`
|
|
681
1031
|
SELECT d.message_id, m.turn_id
|
|
682
1032
|
FROM notification_deliveries d
|
|
683
1033
|
JOIN messages m ON m.id = d.message_id
|
|
684
1034
|
WHERE d.source_id = ? AND d.job_id = ? AND d.run_id = ?
|
|
685
1035
|
AND d.thread_id = ? AND d.completed_at IS NOT NULL
|
|
1036
|
+
AND (? IS NULL OR d.message_id = ?)
|
|
686
1037
|
ORDER BY d.completed_at DESC LIMIT 1
|
|
687
|
-
`).get(sourceId, jobId, run.runId, channel.thread_id)
|
|
688
|
-
: undefined;
|
|
1038
|
+
`).get(sourceId, jobId, run.runId, channel.thread_id, mapped?.message_id ?? null, mapped?.message_id ?? null);
|
|
689
1039
|
const turnId = mapped?.turn_id ?? delivered?.turn_id ?? cronEntityId("turn", sourceId, jobId, run.runId);
|
|
690
1040
|
const messageId = mapped?.message_id ?? delivered?.message_id ?? cronEntityId("message", sourceId, jobId, run.runId);
|
|
691
1041
|
messageIds.push(messageId);
|
|
692
1042
|
const prior = this.database.prepare(`
|
|
693
|
-
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 = ?
|
|
694
1044
|
`).get(messageId);
|
|
695
1045
|
const priorParts = prior === undefined ? [] : parseParts(prior.parts_json);
|
|
696
|
-
|
|
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);
|
|
697
1054
|
const serializedParts = serializeParts(parts);
|
|
698
1055
|
const status = cronMessageStatus(run.status);
|
|
699
1056
|
const turnStatus = status === "running" ? "running" : status;
|
|
@@ -729,7 +1086,7 @@ export class WebStore {
|
|
|
729
1086
|
started_at, finished_at, error_code, error_message
|
|
730
1087
|
) VALUES (?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?)
|
|
731
1088
|
`).run(turnId, channel.thread_id, turnStatus, turnText, messageId, run.orderedAt, finishedAt, turnErrorCode, turnErrorMessage);
|
|
732
|
-
|
|
1089
|
+
storedChanged = true;
|
|
733
1090
|
}
|
|
734
1091
|
else if (existingTurn.thread_id !== channel.thread_id
|
|
735
1092
|
|| existingTurn.status !== turnStatus
|
|
@@ -744,26 +1101,39 @@ export class WebStore {
|
|
|
744
1101
|
started_at = ?, finished_at = ?, error_code = ?, error_message = ?
|
|
745
1102
|
WHERE id = ?
|
|
746
1103
|
`).run(channel.thread_id, turnStatus, turnText, messageId, run.orderedAt, finishedAt, turnErrorCode, turnErrorMessage, turnId);
|
|
747
|
-
|
|
1104
|
+
storedChanged = true;
|
|
748
1105
|
}
|
|
749
1106
|
if (prior === undefined) {
|
|
750
1107
|
this.database.prepare(`
|
|
751
1108
|
INSERT INTO messages (
|
|
752
|
-
id, thread_id, turn_id, role, parts_json, created_at, updated_at, status
|
|
753
|
-
) VALUES (?, ?, ?, 'assistant', ?, ?, ?, ?)
|
|
754
|
-
`).run(messageId, channel.thread_id, turnId, serializedParts, run.orderedAt, now, status);
|
|
755
|
-
|
|
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;
|
|
756
1114
|
}
|
|
757
1115
|
else if (prior.thread_id !== channel.thread_id
|
|
758
1116
|
|| prior.turn_id !== turnId
|
|
759
1117
|
|| prior.parts_json !== serializedParts
|
|
760
1118
|
|| prior.created_at !== run.orderedAt
|
|
761
|
-
|| prior.status !== status
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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))
|
|
766
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;
|
|
767
1137
|
}
|
|
768
1138
|
// Detail is a message projection, not a replacement for the compact
|
|
769
1139
|
// page identity. Keeping the existing summary prevents the next
|
|
@@ -788,21 +1158,22 @@ export class WebStore {
|
|
|
788
1158
|
payload_json = excluded.payload_json,
|
|
789
1159
|
updated_at = excluded.updated_at
|
|
790
1160
|
`).run(sourceId, jobId, run.runId, channel.thread_id, turnId, messageId, run.orderedAt, run.sequence, serializedRun, now);
|
|
791
|
-
|
|
1161
|
+
storedChanged = true;
|
|
792
1162
|
}
|
|
793
1163
|
}
|
|
794
|
-
if (
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
+
}
|
|
800
1171
|
const excess = this.database.prepare(`
|
|
801
1172
|
SELECT turn_id FROM (
|
|
802
|
-
SELECT turn_id, ROW_NUMBER() OVER (
|
|
803
|
-
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
|
|
804
1175
|
) AS retained_row
|
|
805
|
-
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 = ?
|
|
806
1177
|
) WHERE retained_row > 500
|
|
807
1178
|
`).all(sourceId, jobId);
|
|
808
1179
|
const remove = this.database.prepare("DELETE FROM turns WHERE id = ?");
|
|
@@ -811,8 +1182,12 @@ export class WebStore {
|
|
|
811
1182
|
}
|
|
812
1183
|
});
|
|
813
1184
|
return {
|
|
814
|
-
messages: [...new Set(messageIds)].
|
|
1185
|
+
messages: [...new Set(messageIds)].flatMap((id) => { const message = this.getMessage(id); return message === undefined ? [] : [message]; }),
|
|
815
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),
|
|
816
1191
|
};
|
|
817
1192
|
}
|
|
818
1193
|
/** Append or update exactly one retained card for a web-origin process job. */
|
|
@@ -857,7 +1232,7 @@ export class WebStore {
|
|
|
857
1232
|
.run(now, input.threadId);
|
|
858
1233
|
this.recordThreadRevision(input.threadId, "process_job_card_created", now);
|
|
859
1234
|
});
|
|
860
|
-
return { thread: this.requireThread(input.threadId), duplicate: false };
|
|
1235
|
+
return { thread: this.requireThread(input.threadId), duplicate: false, messageId };
|
|
861
1236
|
}
|
|
862
1237
|
if (existing.thread_id !== input.threadId || existing.delivery_key !== input.deliveryKey) {
|
|
863
1238
|
throw new WebConsoleError("notification_idempotency_conflict", "The process job was already bound to a different web thread or delivery key.", 409);
|
|
@@ -897,12 +1272,10 @@ export class WebStore {
|
|
|
897
1272
|
if (existing.projection_sha256 === projectionSha256
|
|
898
1273
|
&& responseText === (existing.response_text ?? undefined)
|
|
899
1274
|
&& !replyPartsChanged) {
|
|
900
|
-
return { thread, duplicate: true };
|
|
1275
|
+
return { thread, duplicate: true, messageId: existing.message_id };
|
|
901
1276
|
}
|
|
902
1277
|
this.transaction(() => {
|
|
903
|
-
this.
|
|
904
|
-
UPDATE messages SET parts_json = ?, updated_at = ?, status = ? WHERE id = ?
|
|
905
|
-
`).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" });
|
|
906
1279
|
this.database.prepare(`
|
|
907
1280
|
UPDATE process_job_cards
|
|
908
1281
|
SET projection_sha256 = ?, response_text = ?, updated_at = ?
|
|
@@ -912,7 +1285,7 @@ export class WebStore {
|
|
|
912
1285
|
.run(now, input.threadId);
|
|
913
1286
|
this.recordThreadRevision(input.threadId, "process_job_card_updated", now);
|
|
914
1287
|
});
|
|
915
|
-
return { thread: this.requireThread(input.threadId), duplicate: false };
|
|
1288
|
+
return { thread: this.requireThread(input.threadId), duplicate: false, messageId: existing.message_id };
|
|
916
1289
|
}
|
|
917
1290
|
/** Durably claim one web process-job wake before touching the operator. */
|
|
918
1291
|
reserveProcessJobWake(input) {
|
|
@@ -943,6 +1316,11 @@ export class WebStore {
|
|
|
943
1316
|
`).run(input.sourceId, input.jobId, input.deliveryKey, input.threadId, now);
|
|
944
1317
|
return { kind: "new" };
|
|
945
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
|
+
*/
|
|
946
1324
|
completeProcessJobWake(input) {
|
|
947
1325
|
const result = this.database.prepare(`
|
|
948
1326
|
UPDATE process_job_wake_deliveries
|
|
@@ -952,13 +1330,220 @@ export class WebStore {
|
|
|
952
1330
|
if (result.changes !== 1) {
|
|
953
1331
|
throw new WebConsoleError("notification_reservation_lost", "The process-job wake reservation was lost.", 409);
|
|
954
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;
|
|
955
1345
|
}
|
|
956
|
-
/**
|
|
957
|
-
|
|
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) {
|
|
958
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(`
|
|
959
1377
|
DELETE FROM process_job_wake_deliveries
|
|
960
1378
|
WHERE source_id = ? AND job_id = ? AND delivery_key = ? AND state = 'accepted'
|
|
961
1379
|
`).run(input.sourceId, input.jobId, input.deliveryKey);
|
|
1380
|
+
return result.changes === 1;
|
|
1381
|
+
}
|
|
1382
|
+
/** Durably claim one Monitor wake before touching the operator. */
|
|
1383
|
+
reserveMonitorWake(input) {
|
|
1384
|
+
const monitor = parseMonitorProjection(input.monitor);
|
|
1385
|
+
if (monitor.monitorId !== input.monitorId) {
|
|
1386
|
+
throw new WebConsoleError("invalid_notification", "The Monitor projection does not match its delivery identity.", 409);
|
|
1387
|
+
}
|
|
1388
|
+
const thread = this.database.prepare("SELECT source_id, archived_at, trigger_kind FROM threads WHERE id = ?")
|
|
1389
|
+
.get(input.threadId);
|
|
1390
|
+
if (thread === undefined || thread.source_id !== input.sourceId) {
|
|
1391
|
+
throw new WebConsoleError("invalid_notification", "The Monitor wake does not match its web thread.", 409);
|
|
1392
|
+
}
|
|
1393
|
+
if (thread.archived_at !== null || thread.trigger_kind !== null) {
|
|
1394
|
+
throw new WebConsoleError("thread_archived", "The Monitor wake destination is not an active web conversation.", 409);
|
|
1395
|
+
}
|
|
1396
|
+
const existing = this.database.prepare(`
|
|
1397
|
+
SELECT monitor_id, thread_id, payload_sha256, state, disposition
|
|
1398
|
+
FROM monitor_wake_deliveries WHERE source_id = ? AND delivery_key = ?
|
|
1399
|
+
`).get(input.sourceId, input.deliveryKey);
|
|
1400
|
+
if (existing !== undefined) {
|
|
1401
|
+
if (existing.monitor_id !== input.monitorId
|
|
1402
|
+
|| existing.thread_id !== input.threadId
|
|
1403
|
+
|| existing.payload_sha256 !== input.payloadSha256) {
|
|
1404
|
+
throw new WebConsoleError("notification_idempotency_conflict", "The Monitor delivery key was already used for a different wake.", 409);
|
|
1405
|
+
}
|
|
1406
|
+
if (existing.state === "completed"
|
|
1407
|
+
&& (existing.disposition === "steered" || existing.disposition === "follow_up")) {
|
|
1408
|
+
return { kind: "completed", disposition: existing.disposition };
|
|
1409
|
+
}
|
|
1410
|
+
return { kind: "uncertain" };
|
|
1411
|
+
}
|
|
1412
|
+
this.database.prepare(`
|
|
1413
|
+
INSERT INTO monitor_wake_deliveries (
|
|
1414
|
+
source_id, monitor_id, delivery_key, thread_id, payload_sha256, projection_json,
|
|
1415
|
+
state, disposition, turn_id, created_at, completed_at
|
|
1416
|
+
) VALUES (?, ?, ?, ?, ?, ?, 'accepted', NULL, NULL, ?, NULL)
|
|
1417
|
+
`).run(input.sourceId, input.monitorId, input.deliveryKey, input.threadId, input.payloadSha256, JSON.stringify(monitor), this.now());
|
|
1418
|
+
return { kind: "new" };
|
|
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
|
+
}
|
|
1431
|
+
completeMonitorWake(input) {
|
|
1432
|
+
let messageId;
|
|
1433
|
+
this.transaction(() => {
|
|
1434
|
+
const result = this.database.prepare(`
|
|
1435
|
+
UPDATE monitor_wake_deliveries
|
|
1436
|
+
SET state = 'completed', disposition = ?, turn_id = ?, completed_at = ?
|
|
1437
|
+
WHERE source_id = ? AND monitor_id = ? AND delivery_key = ? AND state = 'accepted'
|
|
1438
|
+
`).run(input.disposition, input.turnId ?? null, this.now(), input.sourceId, input.monitorId, input.deliveryKey);
|
|
1439
|
+
if (result.changes !== 1) {
|
|
1440
|
+
throw new WebConsoleError("notification_reservation_lost", "The Monitor wake reservation was lost.", 409);
|
|
1441
|
+
}
|
|
1442
|
+
if (input.turnId === undefined)
|
|
1443
|
+
return;
|
|
1444
|
+
const turn = this.requireTurn(input.turnId);
|
|
1445
|
+
const reservation = this.database.prepare(`
|
|
1446
|
+
SELECT thread_id, projection_json FROM monitor_wake_deliveries
|
|
1447
|
+
WHERE source_id = ? AND monitor_id = ? AND delivery_key = ?
|
|
1448
|
+
`).get(input.sourceId, input.monitorId, input.deliveryKey);
|
|
1449
|
+
if (reservation?.thread_id !== turn.thread_id) {
|
|
1450
|
+
throw new WebConsoleError("monitor_origin_mismatch", "The Monitor activity does not belong to this turn.", 409);
|
|
1451
|
+
}
|
|
1452
|
+
if (reservation.projection_json === null)
|
|
1453
|
+
return;
|
|
1454
|
+
let projection;
|
|
1455
|
+
try {
|
|
1456
|
+
projection = parseMonitorProjection(JSON.parse(reservation.projection_json));
|
|
1457
|
+
if (projection.monitorId !== input.monitorId)
|
|
1458
|
+
throw new TypeError("Monitor identity mismatch.");
|
|
1459
|
+
}
|
|
1460
|
+
catch {
|
|
1461
|
+
throw new WebConsoleError("storage_corrupt", "A retained Monitor wake projection is invalid.", 500);
|
|
1462
|
+
}
|
|
1463
|
+
const message = this.requireMessage(turn.assistant_message_id);
|
|
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)
|
|
1473
|
+
return;
|
|
1474
|
+
this.writeMessageParts(message.id, parts, this.now());
|
|
1475
|
+
messageId = message.id;
|
|
1476
|
+
});
|
|
1477
|
+
return messageId === undefined ? undefined : this.requireMessage(messageId);
|
|
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
|
+
}
|
|
1506
|
+
/** Resolve only an exact Monitor live-input receipt belonging to this turn. */
|
|
1507
|
+
monitorWakeProjection(turnId, deliveryKey) {
|
|
1508
|
+
const row = this.database.prepare(`
|
|
1509
|
+
SELECT deliveries.monitor_id, deliveries.projection_json
|
|
1510
|
+
FROM monitor_wake_deliveries AS deliveries
|
|
1511
|
+
JOIN turns ON turns.id = ? AND turns.thread_id = deliveries.thread_id
|
|
1512
|
+
JOIN threads ON threads.id = turns.thread_id AND threads.source_id = deliveries.source_id
|
|
1513
|
+
WHERE deliveries.delivery_key = ?
|
|
1514
|
+
`).get(turnId, deliveryKey);
|
|
1515
|
+
if (row === undefined || row.projection_json === null)
|
|
1516
|
+
return undefined;
|
|
1517
|
+
try {
|
|
1518
|
+
const projection = parseMonitorProjection(JSON.parse(row.projection_json));
|
|
1519
|
+
if (projection.monitorId !== row.monitor_id)
|
|
1520
|
+
throw new TypeError("Monitor identity mismatch.");
|
|
1521
|
+
return projection;
|
|
1522
|
+
}
|
|
1523
|
+
catch {
|
|
1524
|
+
throw new WebConsoleError("storage_corrupt", "A retained Monitor wake projection is invalid.", 500);
|
|
1525
|
+
}
|
|
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
|
+
}
|
|
1541
|
+
/** Release a Monitor reservation only before any operator delivery begins. */
|
|
1542
|
+
abandonMonitorWake(input) {
|
|
1543
|
+
this.database.prepare(`
|
|
1544
|
+
DELETE FROM monitor_wake_deliveries
|
|
1545
|
+
WHERE source_id = ? AND monitor_id = ? AND delivery_key = ? AND state = 'accepted'
|
|
1546
|
+
`).run(input.sourceId, input.monitorId, input.deliveryKey);
|
|
962
1547
|
}
|
|
963
1548
|
/** Exact retained binding used before proxying a single operator job. */
|
|
964
1549
|
processJobCardBelongsToThread(sourceId, threadId, jobId) {
|
|
@@ -967,7 +1552,7 @@ export class WebStore {
|
|
|
967
1552
|
WHERE source_id = ? AND thread_id = ? AND job_id = ?
|
|
968
1553
|
`).get(sourceId, threadId, jobId) !== undefined;
|
|
969
1554
|
}
|
|
970
|
-
createThread(sourceId) {
|
|
1555
|
+
createThread(sourceId, explicit = {}) {
|
|
971
1556
|
const agent = this.getAgent(sourceId);
|
|
972
1557
|
if (agent === undefined) {
|
|
973
1558
|
throw new WebConsoleError("agent_not_found", "The selected agent is no longer available.", 404);
|
|
@@ -975,35 +1560,23 @@ export class WebStore {
|
|
|
975
1560
|
const id = randomUUID();
|
|
976
1561
|
const now = this.now();
|
|
977
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;
|
|
978
1568
|
this.database.prepare(`
|
|
979
1569
|
INSERT INTO threads (
|
|
980
1570
|
id, source_id, conversation_id, title, title_manual, archived_at,
|
|
981
|
-
created_at, updated_at, revision
|
|
982
|
-
) VALUES (?, ?, ?, 'New conversation', 0, NULL, ?, ?, 1)
|
|
983
|
-
`).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);
|
|
984
1574
|
this.database.prepare("INSERT INTO revisions (entity_kind, entity_id, revision, event, created_at) VALUES ('thread', ?, 1, 'created', ?)")
|
|
985
1575
|
.run(id, now);
|
|
986
1576
|
this.setSetting("current_thread_id", id);
|
|
987
1577
|
});
|
|
988
1578
|
return this.requireThread(id);
|
|
989
1579
|
}
|
|
990
|
-
/** Bounded bootstrap: at most one 200-row bucket per (source_id, archived). */
|
|
991
|
-
listThreads() {
|
|
992
|
-
const rows = this.database.prepare(threadSelectSql(`
|
|
993
|
-
WHERE t.id IN (
|
|
994
|
-
SELECT id FROM (
|
|
995
|
-
SELECT id,
|
|
996
|
-
ROW_NUMBER() OVER (
|
|
997
|
-
PARTITION BY source_id, CASE WHEN archived_at IS NULL THEN 0 ELSE 1 END
|
|
998
|
-
ORDER BY updated_at DESC, id DESC
|
|
999
|
-
) AS bucket_row
|
|
1000
|
-
FROM threads
|
|
1001
|
-
) WHERE bucket_row <= ${String(WEB_THREAD_PAGE_MAX)}
|
|
1002
|
-
)
|
|
1003
|
-
ORDER BY t.updated_at DESC, t.id DESC
|
|
1004
|
-
`)).all();
|
|
1005
|
-
return rows.map((row) => this.mapThread(row));
|
|
1006
|
-
}
|
|
1007
1580
|
listThreadsPage(input) {
|
|
1008
1581
|
if (this.getAgent(input.sourceId) === undefined) {
|
|
1009
1582
|
throw new WebConsoleError("agent_not_found", "Agent not found.", 404);
|
|
@@ -1059,7 +1632,7 @@ export class WebStore {
|
|
|
1059
1632
|
FROM message_search
|
|
1060
1633
|
JOIN messages m ON m.rowid = message_search.rowid
|
|
1061
1634
|
JOIN threads t ON t.id = m.thread_id
|
|
1062
|
-
WHERE message_search MATCH ? AND t.source_id = ?
|
|
1635
|
+
WHERE message_search MATCH ? AND t.source_id = ? AND ${visibleMessageSql("m")}
|
|
1063
1636
|
ORDER BY rank
|
|
1064
1637
|
LIMIT ?
|
|
1065
1638
|
`).all(WEB_SEARCH_HIGHLIGHT_OPEN, WEB_SEARCH_HIGHLIGHT_CLOSE, match, input.sourceId, MESSAGE_SEARCH_SCAN_LIMIT + 1);
|
|
@@ -1155,12 +1728,12 @@ export class WebStore {
|
|
|
1155
1728
|
const row = this.database.prepare(threadSelectSql("WHERE t.id = ?")).get(resolved);
|
|
1156
1729
|
return row === undefined ? undefined : this.mapThread(row);
|
|
1157
1730
|
}
|
|
1158
|
-
getThreadDetail(id) {
|
|
1731
|
+
getThreadDetail(id, options = {}) {
|
|
1159
1732
|
const resolved = this.resolveThreadId(id);
|
|
1160
1733
|
const thread = this.getThread(resolved);
|
|
1161
1734
|
if (thread === undefined)
|
|
1162
1735
|
return undefined;
|
|
1163
|
-
const page = this.listMessagesPage(resolved);
|
|
1736
|
+
const page = this.listMessagesPage(resolved, { limit: options.limit ?? WEB_MESSAGE_PAGE_DEFAULT });
|
|
1164
1737
|
return {
|
|
1165
1738
|
thread,
|
|
1166
1739
|
messages: page.messages,
|
|
@@ -1168,7 +1741,7 @@ export class WebStore {
|
|
|
1168
1741
|
};
|
|
1169
1742
|
}
|
|
1170
1743
|
getMessage(id) {
|
|
1171
|
-
const row = this.database.prepare(
|
|
1744
|
+
const row = this.database.prepare(`SELECT * FROM messages WHERE id = ? AND ${visibleMessageSql("messages")}`)
|
|
1172
1745
|
.get(id);
|
|
1173
1746
|
return row === undefined ? undefined : this.mapMessage(row);
|
|
1174
1747
|
}
|
|
@@ -1193,10 +1766,11 @@ export class WebStore {
|
|
|
1193
1766
|
}
|
|
1194
1767
|
values.push(limit + 1);
|
|
1195
1768
|
const rows = this.database.prepare(`
|
|
1196
|
-
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
|
|
1197
1771
|
FROM messages m
|
|
1198
1772
|
LEFT JOIN turns t ON t.id = m.turn_id
|
|
1199
|
-
WHERE m.thread_id = ? ${beforeSql}
|
|
1773
|
+
WHERE m.thread_id = ? AND ${visibleMessageSql("m")} ${beforeSql}
|
|
1200
1774
|
ORDER BY ordered_at DESC, role_rank DESC, m.created_at DESC, storage_rowid DESC
|
|
1201
1775
|
LIMIT ?
|
|
1202
1776
|
`).all(...values);
|
|
@@ -1234,14 +1808,44 @@ export class WebStore {
|
|
|
1234
1808
|
this.setSetting("current_thread_id", resolved);
|
|
1235
1809
|
}
|
|
1236
1810
|
patchThread(id, patch) {
|
|
1811
|
+
return this.transaction(() => this.writeThreadPatch(id, patch));
|
|
1812
|
+
}
|
|
1813
|
+
/**
|
|
1814
|
+
* Compare-and-set: apply `patch` only while the conversation still carries no
|
|
1815
|
+
* run override, and report which way it went.
|
|
1816
|
+
*
|
|
1817
|
+
* The console's one-time adoption of a browser-local override is the caller.
|
|
1818
|
+
* Read and write are ONE `BEGIN IMMEDIATE` here rather than two statements
|
|
1819
|
+
* either side of a service-level check: the process lease
|
|
1820
|
+
* (`state-paths.ts`) is held on a different database file for the service
|
|
1821
|
+
* lifetime, so it makes this process the only *service* writing, not this
|
|
1822
|
+
* statement the only writer of `web.sqlite`. Any other connection to the
|
|
1823
|
+
* state DB -- a maintenance script, a second console pointed at the same
|
|
1824
|
+
* state dir -- could land between a bare read and a bare write, and did in a
|
|
1825
|
+
* probe.
|
|
1826
|
+
*/
|
|
1827
|
+
patchThreadIfRunConfigUnset(id, patch) {
|
|
1828
|
+
return this.transaction(() => {
|
|
1829
|
+
const resolved = this.resolveThreadId(id);
|
|
1830
|
+
const current = this.requireThread(resolved);
|
|
1831
|
+
if (current.runModel !== null || current.runEffort !== null) {
|
|
1832
|
+
return { applied: false, thread: { ...current, sourceId: current.sourceId } };
|
|
1833
|
+
}
|
|
1834
|
+
return { applied: true, thread: this.writeThreadPatch(resolved, patch) };
|
|
1835
|
+
});
|
|
1836
|
+
}
|
|
1837
|
+
/** The body of {@link patchThread}. Assumes an open transaction. */
|
|
1838
|
+
writeThreadPatch(id, patch) {
|
|
1237
1839
|
id = this.resolveThreadId(id);
|
|
1238
1840
|
const current = this.requireThread(id);
|
|
1239
1841
|
const now = this.now();
|
|
1240
1842
|
const title = patch.title === undefined ? undefined : normalizeTitle(patch.title);
|
|
1241
1843
|
const archivedAt = patch.archived === undefined ? undefined : patch.archived ? now : null;
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1844
|
+
const runModel = patch.model === undefined ? undefined : patch.model;
|
|
1845
|
+
const runEffort = patch.effort === undefined ? undefined : patch.effort;
|
|
1846
|
+
{
|
|
1847
|
+
const sets = [];
|
|
1848
|
+
const values = [];
|
|
1245
1849
|
if (title !== undefined) {
|
|
1246
1850
|
sets.push("title = ?", "title_manual = 1");
|
|
1247
1851
|
values.push(title);
|
|
@@ -1250,13 +1854,34 @@ export class WebStore {
|
|
|
1250
1854
|
sets.push("archived_at = ?");
|
|
1251
1855
|
values.push(archivedAt);
|
|
1252
1856
|
}
|
|
1857
|
+
if (runModel !== undefined) {
|
|
1858
|
+
sets.push("run_model = ?");
|
|
1859
|
+
values.push(runModel);
|
|
1860
|
+
}
|
|
1861
|
+
if (runEffort !== undefined) {
|
|
1862
|
+
sets.push("run_effort = ?");
|
|
1863
|
+
values.push(runEffort);
|
|
1864
|
+
}
|
|
1865
|
+
// A model/effort-only patch must not reorder the sidebar, so `updated_at`
|
|
1866
|
+
// only advances when title or archived state actually changes.
|
|
1867
|
+
if (title !== undefined || archivedAt !== undefined) {
|
|
1868
|
+
sets.push("updated_at = ?");
|
|
1869
|
+
values.push(now);
|
|
1870
|
+
}
|
|
1871
|
+
sets.push("revision = revision + 1");
|
|
1253
1872
|
values.push(id);
|
|
1254
1873
|
this.database.prepare(`UPDATE threads SET ${sets.join(", ")} WHERE id = ?`).run(...values);
|
|
1255
|
-
this.recordThreadRevision(id, title !== undefined
|
|
1874
|
+
this.recordThreadRevision(id, title !== undefined
|
|
1875
|
+
? "title_changed"
|
|
1876
|
+
: archivedAt !== undefined
|
|
1877
|
+
? patch.archived
|
|
1878
|
+
? "archived"
|
|
1879
|
+
: "unarchived"
|
|
1880
|
+
: "run_config_changed", now);
|
|
1256
1881
|
if (patch.archived === true && this.currentThreadId() === id) {
|
|
1257
1882
|
this.database.prepare("DELETE FROM settings WHERE key = 'current_thread_id'").run();
|
|
1258
1883
|
}
|
|
1259
|
-
}
|
|
1884
|
+
}
|
|
1260
1885
|
return { ...this.requireThread(id), sourceId: current.sourceId };
|
|
1261
1886
|
}
|
|
1262
1887
|
/** Whether the current interactive thread still accepts agent-proposed titles. */
|
|
@@ -1287,7 +1912,7 @@ export class WebStore {
|
|
|
1287
1912
|
});
|
|
1288
1913
|
return changed ? this.requireThread(id) : undefined;
|
|
1289
1914
|
}
|
|
1290
|
-
async deleteArchivedThread(id) {
|
|
1915
|
+
async deleteArchivedThread(id, options = {}) {
|
|
1291
1916
|
id = this.resolveThreadId(id);
|
|
1292
1917
|
const thread = this.requireThread(id);
|
|
1293
1918
|
if (thread.archivedAt === null) {
|
|
@@ -1301,8 +1926,33 @@ export class WebStore {
|
|
|
1301
1926
|
const attachments = this.database.prepare("SELECT * FROM attachments WHERE thread_id = ?")
|
|
1302
1927
|
.all(id);
|
|
1303
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
|
+
}
|
|
1304
1947
|
const now = this.now();
|
|
1305
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(`
|
|
1306
1956
|
UPDATE push_deliveries SET status = 'dropped', updated_at = ?, finished_at = ?, last_error_code = 'thread_deleted'
|
|
1307
1957
|
WHERE event_id IN (SELECT id FROM push_events WHERE thread_id = ?)
|
|
1308
1958
|
AND status IN ('pending', 'sending')
|
|
@@ -1503,7 +2153,7 @@ export class WebStore {
|
|
|
1503
2153
|
if (input.quote.text.trim().length === 0 || input.quote.messageId.trim().length === 0) {
|
|
1504
2154
|
throw new WebConsoleError("invalid_quote", "Quoted text and its source message are required.", 400);
|
|
1505
2155
|
}
|
|
1506
|
-
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);
|
|
1507
2157
|
if (source === undefined) {
|
|
1508
2158
|
throw new WebConsoleError("invalid_quote", "The quoted message does not belong to this conversation.", 400);
|
|
1509
2159
|
}
|
|
@@ -1515,10 +2165,10 @@ export class WebStore {
|
|
|
1515
2165
|
this.transaction(() => {
|
|
1516
2166
|
this.database.prepare(`
|
|
1517
2167
|
INSERT INTO turns (
|
|
1518
|
-
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,
|
|
1519
2169
|
started_at, finished_at, error_code, error_message
|
|
1520
|
-
) VALUES (?, ?, 'running', ?, ?, ?, ?, ?, NULL, NULL, NULL)
|
|
1521
|
-
`).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);
|
|
1522
2172
|
const userParts = [
|
|
1523
2173
|
...(input.quote === undefined
|
|
1524
2174
|
? []
|
|
@@ -1584,17 +2234,38 @@ export class WebStore {
|
|
|
1584
2234
|
const turnId = randomUUID();
|
|
1585
2235
|
const assistantMessageId = randomUUID();
|
|
1586
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 }];
|
|
1587
2255
|
this.transaction(() => {
|
|
1588
2256
|
this.database.prepare(`
|
|
1589
2257
|
INSERT INTO turns (
|
|
1590
|
-
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,
|
|
1591
2259
|
started_at, finished_at, error_code, error_message
|
|
1592
|
-
) VALUES (?, ?, 'running', ?,
|
|
1593
|
-
`).run(turnId, threadId, 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);
|
|
1594
2262
|
this.database.prepare(`
|
|
1595
2263
|
INSERT INTO messages (id, thread_id, turn_id, role, parts_json, created_at, updated_at, status)
|
|
1596
|
-
VALUES (?, ?, ?, 'assistant',
|
|
1597
|
-
`).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
|
+
}
|
|
1598
2269
|
this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?").run(now, threadId);
|
|
1599
2270
|
this.recordThreadRevision(threadId, "background_follow_up_started", now);
|
|
1600
2271
|
this.setSetting("current_thread_id", threadId);
|
|
@@ -1608,7 +2279,7 @@ export class WebStore {
|
|
|
1608
2279
|
thread: this.requireThread(threadId),
|
|
1609
2280
|
};
|
|
1610
2281
|
}
|
|
1611
|
-
reserveLiveInput(threadId, text) {
|
|
2282
|
+
reserveLiveInput(threadId, text, quote, operatorText = text) {
|
|
1612
2283
|
threadId = this.resolveThreadId(threadId);
|
|
1613
2284
|
const thread = this.requireThread(threadId);
|
|
1614
2285
|
if (thread.archivedAt !== null) {
|
|
@@ -1622,9 +2293,15 @@ export class WebStore {
|
|
|
1622
2293
|
if (text.trim().length === 0) {
|
|
1623
2294
|
throw new WebConsoleError("empty_turn", "Enter a message.", 400);
|
|
1624
2295
|
}
|
|
1625
|
-
if (
|
|
2296
|
+
if (operatorText.length > AGENT_LIVE_INPUT_MAX_CHARACTERS) {
|
|
1626
2297
|
throw new WebConsoleError("turn_text_too_large", `A live follow-up may contain at most ${AGENT_LIVE_INPUT_MAX_CHARACTERS} characters.`, 413);
|
|
1627
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
|
+
}
|
|
1628
2305
|
const usage = this.database.prepare("SELECT COUNT(*) AS count FROM live_inputs WHERE thread_id = ?").get(threadId);
|
|
1629
2306
|
if (usage.count >= WEB_MAX_LIVE_INPUTS_PER_THREAD) {
|
|
1630
2307
|
throw new WebConsoleError("live_input_queue_full", "Too many follow-up messages are waiting.", 429);
|
|
@@ -1634,8 +2311,18 @@ export class WebStore {
|
|
|
1634
2311
|
const messageId = randomUUID();
|
|
1635
2312
|
const now = this.now();
|
|
1636
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;
|
|
1637
2321
|
const parts = [
|
|
1638
2322
|
liveInputTelemetry(status === "offered" ? "pending" : "queued"),
|
|
2323
|
+
...(quote === undefined
|
|
2324
|
+
? []
|
|
2325
|
+
: [{ type: "telemetry", event: QUOTE_TELEMETRY_EVENT, data: quote }]),
|
|
1639
2326
|
{ type: "text", text },
|
|
1640
2327
|
];
|
|
1641
2328
|
this.transaction(() => {
|
|
@@ -1647,7 +2334,7 @@ export class WebStore {
|
|
|
1647
2334
|
INSERT INTO live_inputs (
|
|
1648
2335
|
id, thread_id, message_id, active_turn_id, text, model, effort, status, created_at, updated_at
|
|
1649
2336
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1650
|
-
`).run(id, threadId, messageId, active?.id ?? null,
|
|
2337
|
+
`).run(id, threadId, messageId, active?.id ?? null, operatorText, model, effort, status, now, now);
|
|
1651
2338
|
const title = deriveAutomaticTitle(text, []);
|
|
1652
2339
|
this.database.prepare(`
|
|
1653
2340
|
UPDATE threads
|
|
@@ -1673,8 +2360,7 @@ export class WebStore {
|
|
|
1673
2360
|
const message = this.requireMessage(row.message_id);
|
|
1674
2361
|
const now = this.now();
|
|
1675
2362
|
this.transaction(() => {
|
|
1676
|
-
this.
|
|
1677
|
-
.run(serializeParts(withLiveInputStatus(message.parts, "applied")), now, row.message_id);
|
|
2363
|
+
this.writeMessageParts(row.message_id, withLiveInputStatus(message.parts, "applied"), now);
|
|
1678
2364
|
this.database.prepare("DELETE FROM live_inputs WHERE id = ?").run(id);
|
|
1679
2365
|
this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
|
|
1680
2366
|
.run(now, row.thread_id);
|
|
@@ -1682,7 +2368,36 @@ export class WebStore {
|
|
|
1682
2368
|
});
|
|
1683
2369
|
return this.requireMessage(row.message_id);
|
|
1684
2370
|
}
|
|
1685
|
-
|
|
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) {
|
|
1686
2401
|
const row = this.getLiveInput(id);
|
|
1687
2402
|
if (row === undefined)
|
|
1688
2403
|
return undefined;
|
|
@@ -1690,10 +2405,17 @@ export class WebStore {
|
|
|
1690
2405
|
const now = this.now();
|
|
1691
2406
|
this.transaction(() => {
|
|
1692
2407
|
this.database.prepare(`
|
|
1693
|
-
UPDATE live_inputs
|
|
2408
|
+
UPDATE live_inputs
|
|
2409
|
+
SET status = 'queued', active_turn_id = NULL, dispatch_started_at = NULL, updated_at = ?
|
|
2410
|
+
WHERE id = ?
|
|
1694
2411
|
`).run(now, id);
|
|
1695
|
-
|
|
1696
|
-
.
|
|
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 });
|
|
1697
2419
|
this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
|
|
1698
2420
|
.run(now, row.thread_id);
|
|
1699
2421
|
this.recordThreadRevision(row.thread_id, "live_input_queued", now);
|
|
@@ -1707,8 +2429,7 @@ export class WebStore {
|
|
|
1707
2429
|
const message = this.requireMessage(row.message_id);
|
|
1708
2430
|
const now = this.now();
|
|
1709
2431
|
this.transaction(() => {
|
|
1710
|
-
this.
|
|
1711
|
-
.run(serializeParts(withLiveInputStatus(message.parts, "cancelled")), now, row.message_id);
|
|
2432
|
+
this.writeMessageParts(row.message_id, withLiveInputStatus(message.parts, "cancelled"), now, { turnId: null });
|
|
1712
2433
|
this.database.prepare("DELETE FROM live_inputs WHERE id = ?").run(id);
|
|
1713
2434
|
this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
|
|
1714
2435
|
.run(now, row.thread_id);
|
|
@@ -1723,15 +2444,16 @@ export class WebStore {
|
|
|
1723
2444
|
return [];
|
|
1724
2445
|
const now = this.now();
|
|
1725
2446
|
this.transaction(() => {
|
|
1726
|
-
const update = this.database.prepare("UPDATE messages SET turn_id = NULL, parts_json = ?, updated_at = ? WHERE id = ?");
|
|
1727
2447
|
for (const row of rows) {
|
|
1728
2448
|
const message = this.requireMessage(row.message_id);
|
|
1729
|
-
|
|
2449
|
+
this.writeMessageParts(row.message_id, withLiveInputStatus(message.parts, row.dispatch_started_at === null ? "cancelled" : "uncertain"), now, { turnId: null });
|
|
1730
2450
|
}
|
|
1731
2451
|
this.database.prepare("DELETE FROM live_inputs WHERE thread_id = ?").run(threadId);
|
|
1732
2452
|
this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
|
|
1733
2453
|
.run(now, threadId);
|
|
1734
|
-
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);
|
|
1735
2457
|
});
|
|
1736
2458
|
return rows.map((row) => this.requireMessage(row.message_id));
|
|
1737
2459
|
}
|
|
@@ -1763,12 +2485,11 @@ export class WebStore {
|
|
|
1763
2485
|
this.transaction(() => {
|
|
1764
2486
|
this.database.prepare(`
|
|
1765
2487
|
INSERT INTO turns (
|
|
1766
|
-
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,
|
|
1767
2489
|
started_at, finished_at, error_code, error_message
|
|
1768
|
-
) VALUES (?, ?, 'running', ?, ?, ?, ?, ?, NULL, NULL, NULL)
|
|
1769
|
-
`).run(turnId, threadId, row.text, row.model, row.effort, assistantMessageId, now);
|
|
1770
|
-
this.
|
|
1771
|
-
.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 });
|
|
1772
2493
|
this.database.prepare(`
|
|
1773
2494
|
INSERT INTO messages (id, thread_id, turn_id, role, parts_json, created_at, updated_at, status)
|
|
1774
2495
|
VALUES (?, ?, ?, 'assistant', '[]', ?, ?, 'running')
|
|
@@ -1788,55 +2509,131 @@ export class WebStore {
|
|
|
1788
2509
|
thread: this.requireThread(threadId),
|
|
1789
2510
|
};
|
|
1790
2511
|
}
|
|
1791
|
-
applyStreamFrame(turnId, frame) {
|
|
1792
|
-
return this.applyStreamFrames(turnId, [frame]);
|
|
1793
|
-
}
|
|
1794
2512
|
applyStreamFrames(turnId, frames) {
|
|
1795
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.
|
|
1796
2516
|
if (turn.status !== "running")
|
|
1797
|
-
return this.requireMessage(turn.assistant_message_id);
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
if (frame.
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
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
|
+
}
|
|
1819
2615
|
}
|
|
1820
2616
|
}
|
|
1821
|
-
|
|
1822
|
-
const now = this.now();
|
|
1823
|
-
this.transaction(() => {
|
|
1824
|
-
this.database.prepare("UPDATE messages SET parts_json = ?, updated_at = ? WHERE id = ?")
|
|
1825
|
-
.run(serializeParts(parts), now, message.id);
|
|
1826
|
-
if (actualModel !== undefined || actualEffort !== undefined) {
|
|
2617
|
+
if (actualModel !== undefined || actualEffort !== undefined || actualEffectiveEffort !== undefined || attributionChanged) {
|
|
1827
2618
|
this.database.prepare(`
|
|
1828
2619
|
UPDATE turns SET
|
|
1829
2620
|
model = CASE WHEN ? IS NULL THEN model ELSE ? END,
|
|
1830
|
-
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 = ?
|
|
1831
2624
|
WHERE id = ?
|
|
1832
|
-
`).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);
|
|
1833
2626
|
}
|
|
2627
|
+
return {
|
|
2628
|
+
delta: this.writeMessageDelta(message, parts, this.now()),
|
|
2629
|
+
...(attributionChanged ? { attributionChanged: true } : {}),
|
|
2630
|
+
};
|
|
1834
2631
|
});
|
|
1835
|
-
return this.requireMessage(
|
|
2632
|
+
return { message: this.requireMessage(turn.assistant_message_id), ...write };
|
|
1836
2633
|
}
|
|
1837
|
-
completeTurn(turnId, finalText, metadata, replyParts) {
|
|
2634
|
+
completeTurn(turnId, finalText, metadata, replyParts, options = {}) {
|
|
1838
2635
|
const runtime = runtimeMetadata(metadata);
|
|
1839
|
-
return this.finishTurn(turnId, "complete", finalText, undefined, undefined, runtime, replyParts);
|
|
2636
|
+
return this.finishTurn(turnId, "complete", finalText, undefined, undefined, runtime, replyParts, options.suppressResponsePush === true, options.monitorWakeDeliveryKey);
|
|
1840
2637
|
}
|
|
1841
2638
|
failTurn(turnId, error) {
|
|
1842
2639
|
return this.finishTurn(turnId, error.cancelled === true ? "cancelled" : "failed", undefined, error.code, error.message, undefined);
|
|
@@ -2069,6 +2866,16 @@ export class WebStore {
|
|
|
2069
2866
|
JOIN push_events e ON e.id = d.event_id
|
|
2070
2867
|
JOIN push_subscriptions s ON s.id = d.subscription_id
|
|
2071
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
|
+
)
|
|
2072
2879
|
ORDER BY d.next_attempt_at, d.created_at, d.rowid
|
|
2073
2880
|
LIMIT ?
|
|
2074
2881
|
`).all(now, now, limit);
|
|
@@ -2191,6 +2998,7 @@ export class WebStore {
|
|
|
2191
2998
|
if (versionRow.user_version < 0) {
|
|
2192
2999
|
throw new WebConsoleError("storage_corrupt", "Web state schema version is invalid.", 500);
|
|
2193
3000
|
}
|
|
3001
|
+
validateWebStorageMigrationRegistry();
|
|
2194
3002
|
const migrating = versionRow.user_version < WEB_STORAGE_SCHEMA_VERSION;
|
|
2195
3003
|
if (migrating)
|
|
2196
3004
|
this.database.exec("BEGIN IMMEDIATE");
|
|
@@ -2200,18 +3008,28 @@ export class WebStore {
|
|
|
2200
3008
|
source_id TEXT PRIMARY KEY,
|
|
2201
3009
|
label TEXT NOT NULL,
|
|
2202
3010
|
status TEXT NOT NULL,
|
|
3011
|
+
discovered INTEGER NOT NULL DEFAULT 1 CHECK (discovered IN (0, 1)),
|
|
2203
3012
|
health TEXT,
|
|
2204
3013
|
supports_attachments INTEGER NOT NULL DEFAULT 0,
|
|
3014
|
+
supports_provider_auth INTEGER NOT NULL DEFAULT 0 CHECK (supports_provider_auth IN (0, 1)),
|
|
2205
3015
|
models_json TEXT,
|
|
2206
3016
|
default_model TEXT,
|
|
2207
3017
|
default_effort TEXT,
|
|
2208
3018
|
efforts_json TEXT,
|
|
2209
3019
|
model_options_json TEXT,
|
|
3020
|
+
providers_json TEXT,
|
|
2210
3021
|
cron_read INTEGER NOT NULL DEFAULT 0,
|
|
2211
3022
|
cron_actions INTEGER NOT NULL DEFAULT 0,
|
|
2212
3023
|
ask_by_id INTEGER NOT NULL DEFAULT 0,
|
|
2213
3024
|
updated_at TEXT NOT NULL
|
|
2214
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
|
+
);
|
|
2215
3033
|
CREATE TABLE IF NOT EXISTS threads (
|
|
2216
3034
|
id TEXT PRIMARY KEY,
|
|
2217
3035
|
source_id TEXT NOT NULL REFERENCES agents(source_id),
|
|
@@ -2222,6 +3040,8 @@ export class WebStore {
|
|
|
2222
3040
|
archived_at TEXT,
|
|
2223
3041
|
created_at TEXT NOT NULL,
|
|
2224
3042
|
updated_at TEXT NOT NULL,
|
|
3043
|
+
run_model TEXT,
|
|
3044
|
+
run_effort TEXT,
|
|
2225
3045
|
revision INTEGER NOT NULL DEFAULT 1
|
|
2226
3046
|
);
|
|
2227
3047
|
CREATE TABLE IF NOT EXISTS turns (
|
|
@@ -2231,6 +3051,10 @@ export class WebStore {
|
|
|
2231
3051
|
text TEXT NOT NULL,
|
|
2232
3052
|
model TEXT,
|
|
2233
3053
|
effort TEXT,
|
|
3054
|
+
requested_model TEXT,
|
|
3055
|
+
requested_effort TEXT,
|
|
3056
|
+
effective_effort TEXT,
|
|
3057
|
+
routing_json TEXT NOT NULL DEFAULT '{"transitions":[],"retries":[]}',
|
|
2234
3058
|
assistant_message_id TEXT NOT NULL,
|
|
2235
3059
|
started_at TEXT NOT NULL,
|
|
2236
3060
|
finished_at TEXT,
|
|
@@ -2239,6 +3063,7 @@ export class WebStore {
|
|
|
2239
3063
|
);
|
|
2240
3064
|
CREATE UNIQUE INDEX IF NOT EXISTS turns_one_active_per_thread
|
|
2241
3065
|
ON turns(thread_id) WHERE status = 'running';
|
|
3066
|
+
CREATE INDEX IF NOT EXISTS turns_by_thread_started ON turns(thread_id, started_at);
|
|
2242
3067
|
CREATE TABLE IF NOT EXISTS messages (
|
|
2243
3068
|
id TEXT PRIMARY KEY,
|
|
2244
3069
|
thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
|
@@ -2247,9 +3072,11 @@ export class WebStore {
|
|
|
2247
3072
|
parts_json TEXT NOT NULL,
|
|
2248
3073
|
created_at TEXT NOT NULL,
|
|
2249
3074
|
updated_at TEXT NOT NULL,
|
|
2250
|
-
status TEXT NOT NULL
|
|
3075
|
+
status TEXT NOT NULL,
|
|
3076
|
+
seq INTEGER NOT NULL DEFAULT 0
|
|
2251
3077
|
);
|
|
2252
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);
|
|
2253
3080
|
CREATE TABLE IF NOT EXISTS live_inputs (
|
|
2254
3081
|
id TEXT PRIMARY KEY,
|
|
2255
3082
|
thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
|
@@ -2259,11 +3086,63 @@ export class WebStore {
|
|
|
2259
3086
|
model TEXT,
|
|
2260
3087
|
effort TEXT,
|
|
2261
3088
|
status TEXT NOT NULL CHECK (status IN ('offered', 'queued')),
|
|
3089
|
+
dispatch_started_at TEXT,
|
|
2262
3090
|
created_at TEXT NOT NULL,
|
|
2263
3091
|
updated_at TEXT NOT NULL
|
|
2264
3092
|
);
|
|
2265
3093
|
CREATE INDEX IF NOT EXISTS live_inputs_by_thread
|
|
2266
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';
|
|
2267
3146
|
CREATE TABLE IF NOT EXISTS attachments (
|
|
2268
3147
|
id TEXT PRIMARY KEY,
|
|
2269
3148
|
thread_id TEXT REFERENCES threads(id) ON DELETE CASCADE,
|
|
@@ -2324,11 +3203,27 @@ export class WebStore {
|
|
|
2324
3203
|
);
|
|
2325
3204
|
CREATE INDEX IF NOT EXISTS process_job_wake_deliveries_by_thread
|
|
2326
3205
|
ON process_job_wake_deliveries(thread_id, created_at);
|
|
2327
|
-
CREATE TABLE IF NOT EXISTS
|
|
3206
|
+
CREATE TABLE IF NOT EXISTS monitor_wake_deliveries (
|
|
2328
3207
|
source_id TEXT NOT NULL REFERENCES agents(source_id),
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
3208
|
+
monitor_id TEXT NOT NULL,
|
|
3209
|
+
delivery_key TEXT NOT NULL,
|
|
3210
|
+
thread_id TEXT REFERENCES threads(id) ON DELETE SET NULL,
|
|
3211
|
+
payload_sha256 TEXT NOT NULL,
|
|
3212
|
+
projection_json TEXT,
|
|
3213
|
+
state TEXT NOT NULL CHECK (state IN ('accepted', 'completed')),
|
|
3214
|
+
disposition TEXT CHECK (disposition IN ('steered', 'follow_up')),
|
|
3215
|
+
turn_id TEXT REFERENCES turns(id) ON DELETE SET NULL,
|
|
3216
|
+
created_at TEXT NOT NULL,
|
|
3217
|
+
completed_at TEXT,
|
|
3218
|
+
PRIMARY KEY (source_id, delivery_key)
|
|
3219
|
+
);
|
|
3220
|
+
CREATE INDEX IF NOT EXISTS monitor_wake_deliveries_by_thread
|
|
3221
|
+
ON monitor_wake_deliveries(thread_id, created_at);
|
|
3222
|
+
CREATE TABLE IF NOT EXISTS cron_channels (
|
|
3223
|
+
source_id TEXT NOT NULL REFERENCES agents(source_id),
|
|
3224
|
+
job_id TEXT NOT NULL,
|
|
3225
|
+
thread_id TEXT NOT NULL UNIQUE REFERENCES threads(id) ON DELETE CASCADE,
|
|
3226
|
+
configured INTEGER NOT NULL CHECK (configured IN (0, 1)),
|
|
2332
3227
|
created_at TEXT NOT NULL,
|
|
2333
3228
|
updated_at TEXT NOT NULL,
|
|
2334
3229
|
PRIMARY KEY (source_id, job_id)
|
|
@@ -2446,37 +3341,14 @@ export class WebStore {
|
|
|
2446
3341
|
ON push_deliveries(status, next_attempt_at, created_at);
|
|
2447
3342
|
${MESSAGE_SEARCH_SCHEMA_SQL}
|
|
2448
3343
|
`);
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
if (versionRow.user_version < 6) {
|
|
2458
|
-
const columns = new Set(this.database.prepare("PRAGMA table_info(cron_overviews)").all()
|
|
2459
|
-
.map((column) => column.name));
|
|
2460
|
-
if (!columns.has("jobs_truncated")) {
|
|
2461
|
-
this.database.exec("ALTER TABLE cron_overviews ADD COLUMN jobs_truncated INTEGER NOT NULL DEFAULT 0 CHECK (jobs_truncated IN (0, 1))");
|
|
2462
|
-
}
|
|
2463
|
-
}
|
|
2464
|
-
// The search index derives from parts_json, so an existing database has
|
|
2465
|
-
// to be swept once. Migration writes no message, so the triggers above
|
|
2466
|
-
// cannot have fired yet; the clear-then-insert is a no-op on a fresh
|
|
2467
|
-
// database and idempotent if the sweep is ever re-run.
|
|
2468
|
-
if (versionRow.user_version < 9)
|
|
2469
|
-
this.database.exec(MESSAGE_SEARCH_BACKFILL_SQL);
|
|
2470
|
-
// Durable copies of agent-published images share the upload table so that
|
|
2471
|
-
// thread deletion, archival file cleanup, and every existing purge surface
|
|
2472
|
-
// reach them without a second store to keep in sync.
|
|
2473
|
-
if (versionRow.user_version < 10) {
|
|
2474
|
-
const columns = new Set(this.database.prepare("PRAGMA table_info(attachments)").all()
|
|
2475
|
-
.map((column) => column.name));
|
|
2476
|
-
if (!columns.has("origin")) {
|
|
2477
|
-
this.database.exec("ALTER TABLE attachments ADD COLUMN origin TEXT NOT NULL DEFAULT 'upload' CHECK (origin IN ('upload', 'reply'))");
|
|
2478
|
-
}
|
|
2479
|
-
}
|
|
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
|
+
});
|
|
2480
3352
|
if (migrating)
|
|
2481
3353
|
this.database.exec(`PRAGMA user_version = ${WEB_STORAGE_SCHEMA_VERSION}; COMMIT`);
|
|
2482
3354
|
}
|
|
@@ -2493,6 +3365,83 @@ export class WebStore {
|
|
|
2493
3365
|
throw new WebConsoleError("storage_corrupt", `Unable to initialize web state: ${error instanceof Error ? error.message : String(error)}`, 500);
|
|
2494
3366
|
}
|
|
2495
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
|
+
}
|
|
3408
|
+
/** Preserve Monitor delivery tombstones while making deleted threads threadless. */
|
|
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";
|
|
3417
|
+
this.database.exec(`
|
|
3418
|
+
CREATE TABLE monitor_wake_deliveries_v14 (
|
|
3419
|
+
source_id TEXT NOT NULL REFERENCES agents(source_id),
|
|
3420
|
+
monitor_id TEXT NOT NULL,
|
|
3421
|
+
delivery_key TEXT NOT NULL,
|
|
3422
|
+
thread_id TEXT REFERENCES threads(id) ON DELETE SET NULL,
|
|
3423
|
+
payload_sha256 TEXT NOT NULL,
|
|
3424
|
+
projection_json TEXT,
|
|
3425
|
+
state TEXT NOT NULL CHECK (state IN ('accepted', 'completed')),
|
|
3426
|
+
disposition TEXT CHECK (disposition IN ('steered', 'follow_up')),
|
|
3427
|
+
turn_id TEXT REFERENCES turns(id) ON DELETE SET NULL,
|
|
3428
|
+
created_at TEXT NOT NULL,
|
|
3429
|
+
completed_at TEXT,
|
|
3430
|
+
PRIMARY KEY (source_id, delivery_key)
|
|
3431
|
+
);
|
|
3432
|
+
INSERT INTO monitor_wake_deliveries_v14 (
|
|
3433
|
+
source_id, monitor_id, delivery_key, thread_id, payload_sha256, projection_json,
|
|
3434
|
+
state, disposition, turn_id, created_at, completed_at
|
|
3435
|
+
) SELECT
|
|
3436
|
+
source_id, monitor_id, delivery_key, thread_id, payload_sha256, ${projection},
|
|
3437
|
+
state, disposition, turn_id, created_at, completed_at
|
|
3438
|
+
FROM monitor_wake_deliveries;
|
|
3439
|
+
DROP TABLE monitor_wake_deliveries;
|
|
3440
|
+
ALTER TABLE monitor_wake_deliveries_v14 RENAME TO monitor_wake_deliveries;
|
|
3441
|
+
CREATE INDEX monitor_wake_deliveries_by_thread
|
|
3442
|
+
ON monitor_wake_deliveries(thread_id, created_at);
|
|
3443
|
+
`);
|
|
3444
|
+
}
|
|
2496
3445
|
/**
|
|
2497
3446
|
* Schema-v5 adoption runs after every older fixup inside the same
|
|
2498
3447
|
* BEGIN IMMEDIATE transaction. Each operation is guarded by the resulting
|
|
@@ -2634,6 +3583,7 @@ export class WebStore {
|
|
|
2634
3583
|
}
|
|
2635
3584
|
const requiredTables = new Set([
|
|
2636
3585
|
"agents",
|
|
3586
|
+
"agent_run_overrides",
|
|
2637
3587
|
"threads",
|
|
2638
3588
|
"turns",
|
|
2639
3589
|
"messages",
|
|
@@ -2642,6 +3592,8 @@ export class WebStore {
|
|
|
2642
3592
|
"revisions",
|
|
2643
3593
|
"settings",
|
|
2644
3594
|
"notification_deliveries",
|
|
3595
|
+
"process_job_wake_deliveries",
|
|
3596
|
+
"monitor_wake_deliveries",
|
|
2645
3597
|
"cron_channels",
|
|
2646
3598
|
"cron_channel_deletions",
|
|
2647
3599
|
"cron_overviews",
|
|
@@ -2669,6 +3621,21 @@ export class WebStore {
|
|
|
2669
3621
|
throw new WebConsoleError("storage_corrupt", `Message ${message.id} contains invalid persisted parts.`, 500);
|
|
2670
3622
|
}
|
|
2671
3623
|
}
|
|
3624
|
+
const monitorProjections = this.database.prepare(`
|
|
3625
|
+
SELECT monitor_id, projection_json
|
|
3626
|
+
FROM monitor_wake_deliveries
|
|
3627
|
+
WHERE projection_json IS NOT NULL
|
|
3628
|
+
`).all();
|
|
3629
|
+
for (const row of monitorProjections) {
|
|
3630
|
+
try {
|
|
3631
|
+
const projection = parseMonitorProjection(JSON.parse(row.projection_json));
|
|
3632
|
+
if (projection.monitorId !== row.monitor_id)
|
|
3633
|
+
throw new TypeError("Monitor identity mismatch.");
|
|
3634
|
+
}
|
|
3635
|
+
catch {
|
|
3636
|
+
throw new WebConsoleError("storage_corrupt", "A retained Monitor wake projection is invalid.", 500);
|
|
3637
|
+
}
|
|
3638
|
+
}
|
|
2672
3639
|
}
|
|
2673
3640
|
/**
|
|
2674
3641
|
* Index the messages the streaming gate skipped and nothing settled — the
|
|
@@ -2680,6 +3647,46 @@ export class WebStore {
|
|
|
2680
3647
|
this.database.exec(MESSAGE_SEARCH_UNSETTLED_SQL);
|
|
2681
3648
|
});
|
|
2682
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
|
+
}
|
|
2683
3690
|
recoverInterruptedTurns() {
|
|
2684
3691
|
const active = this.listActiveTurnIds();
|
|
2685
3692
|
for (const turnId of active) {
|
|
@@ -2700,17 +3707,28 @@ export class WebStore {
|
|
|
2700
3707
|
const threadIds = new Set(rows.map((row) => row.thread_id));
|
|
2701
3708
|
this.transaction(() => {
|
|
2702
3709
|
const updateInput = this.database.prepare(`
|
|
2703
|
-
UPDATE live_inputs
|
|
3710
|
+
UPDATE live_inputs
|
|
3711
|
+
SET status = 'queued', active_turn_id = NULL, dispatch_started_at = NULL, updated_at = ?
|
|
3712
|
+
WHERE id = ?
|
|
2704
3713
|
`);
|
|
2705
|
-
const updateMessage = this.database.prepare("UPDATE messages SET turn_id = NULL, parts_json = ?, updated_at = ? WHERE id = ?");
|
|
2706
3714
|
for (const row of rows) {
|
|
2707
3715
|
const persisted = this.database.prepare("SELECT parts_json FROM messages WHERE id = ?")
|
|
2708
3716
|
.get(row.message_id);
|
|
2709
3717
|
if (persisted === undefined) {
|
|
2710
3718
|
throw new WebConsoleError("storage_corrupt", `Live input ${row.id} has no message.`, 500);
|
|
2711
3719
|
}
|
|
2712
|
-
|
|
2713
|
-
|
|
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
|
+
}
|
|
2714
3732
|
}
|
|
2715
3733
|
for (const threadId of threadIds) {
|
|
2716
3734
|
this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
|
|
@@ -2726,20 +3744,21 @@ export class WebStore {
|
|
|
2726
3744
|
WHERE status = 'sending'
|
|
2727
3745
|
`).run(now, now);
|
|
2728
3746
|
}
|
|
2729
|
-
finishTurn(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts) {
|
|
3747
|
+
finishTurn(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts, suppressResponsePush = false, monitorWakeDeliveryKey) {
|
|
2730
3748
|
const turn = this.requireTurn(turnId);
|
|
2731
3749
|
if (turn.status !== "running") {
|
|
2732
3750
|
return this.requireThreadDetail(turn.thread_id);
|
|
2733
3751
|
}
|
|
2734
|
-
this.transaction(() =>
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
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
|
+
};
|
|
2738
3757
|
}
|
|
2739
|
-
finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts) {
|
|
3758
|
+
finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts, suppressResponsePush = false, monitorWakeDeliveryKey) {
|
|
2740
3759
|
const turn = this.requireTurn(turnId);
|
|
2741
3760
|
if (turn.status !== "running")
|
|
2742
|
-
return;
|
|
3761
|
+
return undefined;
|
|
2743
3762
|
const existing = this.requireMessage(turn.assistant_message_id);
|
|
2744
3763
|
let parts = [...existing.parts];
|
|
2745
3764
|
if (finalText !== undefined && finalText.length > 0)
|
|
@@ -2749,17 +3768,30 @@ export class WebStore {
|
|
|
2749
3768
|
if (errorMessage !== undefined) {
|
|
2750
3769
|
parts.push({ type: "error", ...(errorCode === undefined ? {} : { code: errorCode }), message: errorMessage });
|
|
2751
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
|
+
}
|
|
2752
3784
|
const now = this.now();
|
|
2753
3785
|
const thread = this.requireThread(turn.thread_id);
|
|
2754
|
-
const agent = this.
|
|
3786
|
+
const agent = this.getStoredAgent(thread.sourceId);
|
|
2755
3787
|
this.database.prepare(`
|
|
2756
3788
|
UPDATE turns SET status = ?, finished_at = ?, error_code = ?, error_message = ?,
|
|
2757
3789
|
model = CASE WHEN ? IS NULL THEN model ELSE ? END,
|
|
2758
|
-
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
|
|
2759
3792
|
WHERE id = ?
|
|
2760
|
-
`).run(status, now, errorCode ?? null, errorMessage ?? null, runtime?.model ?? null, runtime?.model ?? null, runtime?.effort ?? null, runtime?.effort ?? null, turnId);
|
|
2761
|
-
this.
|
|
2762
|
-
.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 });
|
|
2763
3795
|
this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
|
|
2764
3796
|
.run(now, turn.thread_id);
|
|
2765
3797
|
this.recordThreadRevision(turn.thread_id, `turn_${status}`, now);
|
|
@@ -2768,7 +3800,9 @@ export class WebStore {
|
|
|
2768
3800
|
// Projected cron turns are agent-owned scheduler state. Restart recovery
|
|
2769
3801
|
// still settles the local projection, but only web-owned turns may emit a
|
|
2770
3802
|
// Web Push terminal notification from this service.
|
|
2771
|
-
if (thread.trigger?.kind !== "cron"
|
|
3803
|
+
if (thread.trigger?.kind !== "cron"
|
|
3804
|
+
&& recentEnoughForRecoveredInterruption
|
|
3805
|
+
&& !(status === "complete" && suppressResponsePush)) {
|
|
2772
3806
|
const kind = status === "complete"
|
|
2773
3807
|
? "response.ready"
|
|
2774
3808
|
: status === "cancelled"
|
|
@@ -2778,7 +3812,7 @@ export class WebStore {
|
|
|
2778
3812
|
: "run.failed";
|
|
2779
3813
|
const label = agent?.label ?? "mono-agent";
|
|
2780
3814
|
const body = status === "complete"
|
|
2781
|
-
? parts.filter((part) => part.type === "text")
|
|
3815
|
+
? monitorAssociated ? monitorReplyText(parts) : parts.filter((part) => part.type === "text")
|
|
2782
3816
|
.map((part) => part.text)
|
|
2783
3817
|
.join(" ")
|
|
2784
3818
|
: status === "cancelled"
|
|
@@ -2803,10 +3837,14 @@ export class WebStore {
|
|
|
2803
3837
|
notBefore: new Date(new Date(now).getTime() + 3_000).toISOString(),
|
|
2804
3838
|
});
|
|
2805
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 };
|
|
2806
3843
|
}
|
|
2807
3844
|
mapThread(row) {
|
|
2808
3845
|
const runState = this.latestRunState(row.id);
|
|
2809
3846
|
const preview = this.lastMessagePreview(row.id);
|
|
3847
|
+
const jobActivity = this.jobActivity(row.id);
|
|
2810
3848
|
return {
|
|
2811
3849
|
id: row.id,
|
|
2812
3850
|
sourceId: row.source_id,
|
|
@@ -2829,63 +3867,268 @@ export class WebStore {
|
|
|
2829
3867
|
...(preview === undefined ? {} : { lastMessagePreview: preview }),
|
|
2830
3868
|
messageCount: row.message_count,
|
|
2831
3869
|
runState,
|
|
3870
|
+
...(jobActivity === undefined ? {} : { jobActivity }),
|
|
2832
3871
|
canSend: row.can_send === 1,
|
|
2833
3872
|
canUpload: row.can_upload === 1,
|
|
3873
|
+
runModel: row.run_model,
|
|
3874
|
+
runEffort: row.run_effort,
|
|
3875
|
+
};
|
|
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),
|
|
2834
3966
|
};
|
|
2835
3967
|
}
|
|
2836
3968
|
mapMessage(row) {
|
|
2837
3969
|
const attachments = this.database
|
|
2838
3970
|
.prepare("SELECT * FROM attachments WHERE message_id = ? AND origin = 'upload' ORDER BY created_at, id")
|
|
2839
3971
|
.all(row.id);
|
|
2840
|
-
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;
|
|
2841
3976
|
const quote = quoteFromParts(storedParts);
|
|
2842
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;
|
|
2843
3983
|
return {
|
|
2844
3984
|
id: row.id,
|
|
2845
3985
|
threadId: row.thread_id,
|
|
2846
3986
|
...(row.turn_id === null ? {} : { turnId: row.turn_id }),
|
|
2847
|
-
role
|
|
3987
|
+
role,
|
|
2848
3988
|
...(quote === undefined ? {} : { quote }),
|
|
2849
3989
|
parts: storedParts.filter((part) => part.type !== "telemetry"
|
|
2850
3990
|
|| (part.event !== QUOTE_TELEMETRY_EVENT && part.event !== LIVE_INPUT_TELEMETRY_EVENT)),
|
|
2851
3991
|
attachments: attachments.map((attachment) => toWebAttachment(mapStoredAttachment(attachment))),
|
|
2852
3992
|
createdAt: row.created_at,
|
|
2853
3993
|
updatedAt: row.updated_at,
|
|
3994
|
+
...(finishedAt === undefined ? {} : { finishedAt }),
|
|
2854
3995
|
status: normalizeMessageStatus(row.status),
|
|
2855
3996
|
...(liveInputStatus === undefined ? {} : { liveInputStatus }),
|
|
3997
|
+
...(attribution === undefined ? {} : { attribution }),
|
|
3998
|
+
seq: row.seq,
|
|
2856
3999
|
};
|
|
2857
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
|
+
}
|
|
2858
4015
|
latestRunState(threadId) {
|
|
2859
4016
|
const row = this.database.prepare("SELECT * FROM turns WHERE thread_id = ? ORDER BY started_at DESC, rowid DESC LIMIT 1")
|
|
2860
4017
|
.get(threadId);
|
|
2861
4018
|
if (row === undefined)
|
|
2862
4019
|
return { status: "idle" };
|
|
2863
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
|
+
};
|
|
2864
4064
|
return {
|
|
2865
4065
|
id: row.id,
|
|
2866
4066
|
status,
|
|
2867
4067
|
startedAt: row.started_at,
|
|
4068
|
+
...(lastOutcome === undefined ? {} : { lastOutcome }),
|
|
2868
4069
|
...(row.finished_at === null ? {} : { finishedAt: row.finished_at }),
|
|
2869
4070
|
...(row.error_message === null
|
|
2870
4071
|
? {}
|
|
2871
4072
|
: { error: { ...(row.error_code === null ? {} : { code: row.error_code }), message: row.error_message } }),
|
|
2872
4073
|
...(row.model === null ? {} : { model: row.model }),
|
|
2873
4074
|
...(row.effort === null ? {} : { effort: row.effort }),
|
|
4075
|
+
...(attribution === undefined ? {} : { attribution }),
|
|
2874
4076
|
};
|
|
2875
4077
|
}
|
|
2876
4078
|
lastMessagePreview(threadId) {
|
|
2877
|
-
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`)
|
|
2878
4080
|
.get(threadId);
|
|
2879
4081
|
if (row === undefined)
|
|
2880
4082
|
return undefined;
|
|
2881
|
-
const text =
|
|
2882
|
-
.
|
|
2883
|
-
.
|
|
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] : [])
|
|
2884
4086
|
.join(" ")
|
|
2885
4087
|
.replace(/\s+/gu, " ")
|
|
2886
4088
|
.trim();
|
|
2887
4089
|
return text.length === 0 ? undefined : text.slice(0, 160);
|
|
2888
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
|
+
}
|
|
2889
4132
|
cronChannel(sourceId, jobId) {
|
|
2890
4133
|
return this.database.prepare(`
|
|
2891
4134
|
SELECT * FROM cron_channels WHERE source_id = ? AND job_id = ?
|
|
@@ -3031,8 +4274,85 @@ export class WebStore {
|
|
|
3031
4274
|
this.database.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
|
|
3032
4275
|
.run(key, value);
|
|
3033
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
|
+
}
|
|
3034
4351
|
transaction(operation) {
|
|
4352
|
+
if (this.transactionDepth > 0)
|
|
4353
|
+
return operation();
|
|
3035
4354
|
this.database.exec("BEGIN IMMEDIATE");
|
|
4355
|
+
this.transactionDepth += 1;
|
|
3036
4356
|
try {
|
|
3037
4357
|
const result = operation();
|
|
3038
4358
|
this.database.exec("COMMIT");
|
|
@@ -3042,11 +4362,42 @@ export class WebStore {
|
|
|
3042
4362
|
this.database.exec("ROLLBACK");
|
|
3043
4363
|
throw error;
|
|
3044
4364
|
}
|
|
4365
|
+
finally {
|
|
4366
|
+
this.transactionDepth -= 1;
|
|
4367
|
+
}
|
|
3045
4368
|
}
|
|
3046
4369
|
now() {
|
|
3047
4370
|
return this.clock().toISOString();
|
|
3048
4371
|
}
|
|
3049
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
|
+
}
|
|
3050
4401
|
function mapPushSubscriptionStatus(row) {
|
|
3051
4402
|
const state = row.state === "disabled" || row.state === "expired"
|
|
3052
4403
|
? row.state
|
|
@@ -3124,12 +4475,13 @@ function isValidVapidKeyPair(publicKey, privateKey) {
|
|
|
3124
4475
|
function threadSelectSql(suffix) {
|
|
3125
4476
|
return `
|
|
3126
4477
|
SELECT t.id, t.source_id, t.title, t.title_manual, t.trigger_kind, t.archived_at, t.created_at, t.updated_at, t.revision,
|
|
4478
|
+
t.run_model, t.run_effort,
|
|
3127
4479
|
cc.job_id AS cron_job_id, cc.configured AS cron_configured,
|
|
3128
4480
|
CASE WHEN t.trigger_kind = 'cron' THEN 0
|
|
3129
4481
|
WHEN a.status = 'online' OR a.status = 'degraded' THEN 1 ELSE 0 END AS can_send,
|
|
3130
4482
|
CASE WHEN t.trigger_kind = 'cron' THEN 0
|
|
3131
4483
|
WHEN (a.status = 'online' OR a.status = 'degraded') AND a.supports_attachments = 1 THEN 1 ELSE 0 END AS can_upload,
|
|
3132
|
-
(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
|
|
3133
4485
|
FROM threads t JOIN agents a ON a.source_id = t.source_id
|
|
3134
4486
|
LEFT JOIN cron_channels cc ON cc.thread_id = t.id
|
|
3135
4487
|
${suffix}
|
|
@@ -3138,11 +4490,14 @@ function threadSelectSql(suffix) {
|
|
|
3138
4490
|
function agentSelectSql(suffix) {
|
|
3139
4491
|
return `
|
|
3140
4492
|
SELECT a.*,
|
|
4493
|
+
o.model AS override_model,
|
|
4494
|
+
o.effort AS override_effort,
|
|
3141
4495
|
CASE WHEN EXISTS (
|
|
3142
4496
|
SELECT 1 FROM settings s
|
|
3143
4497
|
WHERE s.key = 'agent_pin:' || a.source_id AND s.value = '1'
|
|
3144
4498
|
) THEN 1 ELSE 0 END AS pinned
|
|
3145
4499
|
FROM agents a
|
|
4500
|
+
LEFT JOIN agent_run_overrides o ON o.source_id = a.source_id
|
|
3146
4501
|
${suffix}
|
|
3147
4502
|
`;
|
|
3148
4503
|
}
|
|
@@ -3224,8 +4579,36 @@ function cronMessageStatus(status) {
|
|
|
3224
4579
|
return "cancelled";
|
|
3225
4580
|
return "complete";
|
|
3226
4581
|
}
|
|
3227
|
-
function
|
|
3228
|
-
|
|
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);
|
|
3229
4612
|
const priorCron = prior.find((part) => part.type === "telemetry" && part.event === "cron_run");
|
|
3230
4613
|
const priorCronData = record(priorCron?.data);
|
|
3231
4614
|
const priorActivityLoaded = priorCronData?.activityLoaded === true;
|
|
@@ -3235,6 +4618,10 @@ function cronRunParts(run, prior, conversationId) {
|
|
|
3235
4618
|
const priorActivityEventCount = Number.isSafeInteger(priorCronData?.activityEventCount)
|
|
3236
4619
|
? Number(priorCronData?.activityEventCount)
|
|
3237
4620
|
: priorLoadedEventCount;
|
|
4621
|
+
const priorDetailFieldsTruncated = Array.isArray(priorCronData?.detailFieldsTruncated)
|
|
4622
|
+
&& priorCronData.detailFieldsTruncated.every((field) => typeof field === "string")
|
|
4623
|
+
? priorCronData.detailFieldsTruncated
|
|
4624
|
+
: undefined;
|
|
3238
4625
|
const activityLoaded = run.projection === "detail"
|
|
3239
4626
|
|| (priorActivityLoaded && priorActivityEventCount === run.eventCount);
|
|
3240
4627
|
const activityStale = run.projection === "summary"
|
|
@@ -3247,10 +4634,12 @@ function cronRunParts(run, prior, conversationId) {
|
|
|
3247
4634
|
// synthetic state/identity before rebuilding it, while retaining a genuine
|
|
3248
4635
|
// notification text that arrived before the operator run projection.
|
|
3249
4636
|
const retained = prior.filter((part) => !(part.type === "telemetry" && part.event === "cron_run")
|
|
3250
|
-
&& !(part.type === "text" && isSyntheticCronStateText(part.text)));
|
|
4637
|
+
&& !(part.type === "text" && !notificationBacked && isSyntheticCronStateText(part.text)));
|
|
3251
4638
|
const parts = run.projection === "summary"
|
|
3252
4639
|
? [...retained]
|
|
3253
|
-
: 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");
|
|
3254
4643
|
for (const event of run.projection === "detail" ? run.events : [])
|
|
3255
4644
|
applyEvent(parts, event);
|
|
3256
4645
|
const preserveLoadedText = run.projection === "summary"
|
|
@@ -3260,7 +4649,7 @@ function cronRunParts(run, prior, conversationId) {
|
|
|
3260
4649
|
&& priorActivityLoaded
|
|
3261
4650
|
&& (run.fieldsTruncated?.includes("error") === true
|
|
3262
4651
|
|| run.fieldsTruncated?.includes("failureKind") === true);
|
|
3263
|
-
if (!silent && !preserveLoadedText && run.text !== undefined && run.text.length > 0) {
|
|
4652
|
+
if (!notificationBacked && !silent && !preserveLoadedText && run.text !== undefined && run.text.length > 0) {
|
|
3264
4653
|
reconcileFinalText(parts, run.text);
|
|
3265
4654
|
}
|
|
3266
4655
|
const hasText = parts.some((part) => part.type === "text" && part.text.trim().length > 0);
|
|
@@ -3304,7 +4693,7 @@ function cronRunParts(run, prior, conversationId) {
|
|
|
3304
4693
|
sequence: run.sequence,
|
|
3305
4694
|
trigger: run.trigger,
|
|
3306
4695
|
status: run.status,
|
|
3307
|
-
...(silent ? { silent: true } : {}),
|
|
4696
|
+
...(silent && !hasMeaningfulCronContent(parts) ? { silent: true } : {}),
|
|
3308
4697
|
...(run.startedAt === undefined ? {} : { startedAt: run.startedAt }),
|
|
3309
4698
|
...(run.completedAt === undefined ? {} : { completedAt: run.completedAt }),
|
|
3310
4699
|
...(run.artifactRunId === undefined ? {} : { artifactRunId: run.artifactRunId }),
|
|
@@ -3319,6 +4708,9 @@ function cronRunParts(run, prior, conversationId) {
|
|
|
3319
4708
|
: {}),
|
|
3320
4709
|
...(activityStale ? { activityStale: true, loadedEventCount: priorLoadedEventCount } : {}),
|
|
3321
4710
|
...(eventsTruncated ? { eventsTruncated: true } : {}),
|
|
4711
|
+
...(run.projection === "detail"
|
|
4712
|
+
? { detailFieldsTruncated: run.fieldsTruncated ?? [] }
|
|
4713
|
+
: priorDetailFieldsTruncated === undefined ? {} : { detailFieldsTruncated: priorDetailFieldsTruncated }),
|
|
3322
4714
|
...(run.fieldsTruncated === undefined ? {} : { fieldsTruncated: run.fieldsTruncated }),
|
|
3323
4715
|
},
|
|
3324
4716
|
});
|
|
@@ -3390,7 +4782,9 @@ function parseStoredCronRun(serialized) {
|
|
|
3390
4782
|
...(legacyEvents === undefined ? {} : { eventsTruncated: true }),
|
|
3391
4783
|
}
|
|
3392
4784
|
: raw);
|
|
3393
|
-
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"
|
|
3394
4788
|
|| typeof run.runId !== "string"
|
|
3395
4789
|
|| typeof run.jobId !== "string"
|
|
3396
4790
|
|| typeof run.scheduledAt !== "string"
|
|
@@ -3460,6 +4854,8 @@ function mapAgent(row) {
|
|
|
3460
4854
|
const models = parseStringArray(row.models_json);
|
|
3461
4855
|
const efforts = parseStringArray(row.efforts_json);
|
|
3462
4856
|
const modelOptions = parseRecord(row.model_options_json);
|
|
4857
|
+
const providers = parseProviderSummary(row.providers_json);
|
|
4858
|
+
const runSettings = agentRunSettings(row);
|
|
3463
4859
|
return {
|
|
3464
4860
|
sourceId: row.source_id,
|
|
3465
4861
|
label: row.label,
|
|
@@ -3467,11 +4863,14 @@ function mapAgent(row) {
|
|
|
3467
4863
|
pinned: row.pinned === 1,
|
|
3468
4864
|
...(row.health === null ? {} : { health: row.health }),
|
|
3469
4865
|
supportsAttachments: row.supports_attachments === 1,
|
|
4866
|
+
...(row.supports_provider_auth === 1 ? { supportsProviderAuth: true } : {}),
|
|
3470
4867
|
...(models === undefined ? {} : { models }),
|
|
3471
4868
|
...(row.default_model === null ? {} : { defaultModel: row.default_model }),
|
|
3472
4869
|
...(row.default_effort === null ? {} : { defaultEffort: row.default_effort }),
|
|
3473
4870
|
...(efforts === undefined ? {} : { efforts }),
|
|
3474
4871
|
...(modelOptions === undefined ? {} : { modelOptions }),
|
|
4872
|
+
runSettings,
|
|
4873
|
+
...(providers === undefined ? {} : { providers }),
|
|
3475
4874
|
...(row.cron_read === 1
|
|
3476
4875
|
? { cron: { read: true, actions: row.cron_actions === 1 } }
|
|
3477
4876
|
: {}),
|
|
@@ -3479,6 +4878,30 @@ function mapAgent(row) {
|
|
|
3479
4878
|
updatedAt: row.updated_at,
|
|
3480
4879
|
};
|
|
3481
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
|
+
}
|
|
3482
4905
|
function mapStoredAttachment(row) {
|
|
3483
4906
|
return {
|
|
3484
4907
|
id: row.id,
|
|
@@ -3519,12 +4942,28 @@ export function toWebAttachment(attachment) {
|
|
|
3519
4942
|
...(attachment.uploaded ? { contentUrl: `/api/v1/uploads/${encodeURIComponent(attachment.id)}/content` } : {}),
|
|
3520
4943
|
};
|
|
3521
4944
|
}
|
|
3522
|
-
function
|
|
4945
|
+
function appliedMonitorWake(event, resolveMonitorWake) {
|
|
4946
|
+
if (resolveMonitorWake === undefined
|
|
4947
|
+
|| event.metadata?.liveInput !== true
|
|
4948
|
+
|| event.metadata?.synthetic !== true
|
|
4949
|
+
|| typeof event.metadata.inputId !== "string") {
|
|
4950
|
+
return undefined;
|
|
4951
|
+
}
|
|
4952
|
+
const projection = resolveMonitorWake(event.metadata.inputId);
|
|
4953
|
+
return projection === undefined
|
|
4954
|
+
? undefined
|
|
4955
|
+
: { deliveryKey: event.metadata.inputId, projection };
|
|
4956
|
+
}
|
|
4957
|
+
function applyEvent(parts, event, resolveMonitorWake, resolveProcessJobWake) {
|
|
3523
4958
|
if (event.type === "assistant_thought") {
|
|
3524
4959
|
appendTextPart(parts, "reasoning", event.text);
|
|
3525
4960
|
return;
|
|
3526
4961
|
}
|
|
3527
4962
|
if (event.type === "tool_call_started") {
|
|
4963
|
+
if (appliedMonitorWake(event, resolveMonitorWake) !== undefined)
|
|
4964
|
+
return;
|
|
4965
|
+
if (appliedProcessJobWake(event, resolveProcessJobWake) !== undefined)
|
|
4966
|
+
return;
|
|
3528
4967
|
const historyUpdate = canonicalEventHistoryUpdate(event.history);
|
|
3529
4968
|
const subagent = subagentOf(event);
|
|
3530
4969
|
if (subagent !== undefined) {
|
|
@@ -3566,6 +5005,19 @@ function applyEvent(parts, event) {
|
|
|
3566
5005
|
return;
|
|
3567
5006
|
}
|
|
3568
5007
|
if (event.type === "tool_call_completed") {
|
|
5008
|
+
const monitorWake = appliedMonitorWake(event, resolveMonitorWake);
|
|
5009
|
+
if (monitorWake !== undefined) {
|
|
5010
|
+
upsertMonitorActivity(parts, monitorWake.projection, monitorWake.deliveryKey);
|
|
5011
|
+
return;
|
|
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
|
+
}
|
|
3569
5021
|
const status = event.isError === true ? "failed" : "complete";
|
|
3570
5022
|
const historyUpdate = canonicalEventHistoryUpdate(event.history);
|
|
3571
5023
|
const executionMs = canonicalExecutionMs(event.executionMs);
|
|
@@ -3578,6 +5030,7 @@ function applyEvent(parts, event) {
|
|
|
3578
5030
|
status,
|
|
3579
5031
|
...(executionMs === undefined ? {} : { executionMs }),
|
|
3580
5032
|
...(subagent.costUsd === undefined ? {} : { costUsd: subagent.costUsd }),
|
|
5033
|
+
...(subagent.attribution === undefined ? {} : { attribution: subagent.attribution }),
|
|
3581
5034
|
}, historyUpdate));
|
|
3582
5035
|
return;
|
|
3583
5036
|
}
|
|
@@ -3610,10 +5063,10 @@ function applyEvent(parts, event) {
|
|
|
3610
5063
|
toolName: event.name ?? existingToolName(parts, event.id) ?? "Tool",
|
|
3611
5064
|
...(event.arguments === undefined ? {} : { args: event.arguments }),
|
|
3612
5065
|
...(event.content === undefined ? {} : { result: event.content }),
|
|
3613
|
-
// `result` is
|
|
3614
|
-
//
|
|
3615
|
-
//
|
|
3616
|
-
//
|
|
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.
|
|
3617
5070
|
...(event.structuredContent === undefined ? {} : { structuredResult: event.structuredContent }),
|
|
3618
5071
|
...(executionMs === undefined ? {} : { executionMs }),
|
|
3619
5072
|
status,
|
|
@@ -3626,6 +5079,42 @@ function applyEvent(parts, event) {
|
|
|
3626
5079
|
}
|
|
3627
5080
|
parts.push({ type: "telemetry", event: event.type, data: event });
|
|
3628
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
|
+
}
|
|
5091
|
+
function upsertMonitorActivity(parts, projection, deliveryKey) {
|
|
5092
|
+
const index = parts.findIndex((part) => part.type === "monitor-activity");
|
|
5093
|
+
const previous = index < 0 ? undefined : parts[index];
|
|
5094
|
+
const monitors = previous?.monitors ?? [];
|
|
5095
|
+
const monitorIndex = monitors.findIndex((entry) => entry.projection.monitorId === projection.monitorId);
|
|
5096
|
+
const prior = monitorIndex < 0 ? undefined : monitors[monitorIndex];
|
|
5097
|
+
// The stream receipt and the durable delivery settlement can race. Once an
|
|
5098
|
+
// exact key is present, settlement is bookkeeping only: replacing its newer
|
|
5099
|
+
// projection with the reservation's older snapshot would make the activity
|
|
5100
|
+
// row move backwards and emit a duplicate invalidation.
|
|
5101
|
+
if (prior?.deliveryKeys.includes(deliveryKey) === true)
|
|
5102
|
+
return false;
|
|
5103
|
+
const deliveryKeys = prior === undefined ? [deliveryKey] : [...prior.deliveryKeys, deliveryKey];
|
|
5104
|
+
const entry = { projection, deliveryKeys };
|
|
5105
|
+
const nextMonitors = monitorIndex < 0
|
|
5106
|
+
? [...monitors, entry]
|
|
5107
|
+
: monitors.map((value, at) => at === monitorIndex ? entry : value);
|
|
5108
|
+
const next = { type: "monitor-activity", monitors: nextMonitors };
|
|
5109
|
+
if (index < 0) {
|
|
5110
|
+
parts.push(next);
|
|
5111
|
+
return true;
|
|
5112
|
+
}
|
|
5113
|
+
if (isDeepStrictEqual(previous, next))
|
|
5114
|
+
return false;
|
|
5115
|
+
parts[index] = next;
|
|
5116
|
+
return true;
|
|
5117
|
+
}
|
|
3629
5118
|
function contextCompactionOperationId(value) {
|
|
3630
5119
|
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
3631
5120
|
return undefined;
|
|
@@ -3697,11 +5186,13 @@ function subagentOf(event) {
|
|
|
3697
5186
|
const costUsd = typeof record.costUsd === "number" && Number.isFinite(record.costUsd) && record.costUsd > 0
|
|
3698
5187
|
? record.costUsd
|
|
3699
5188
|
: undefined;
|
|
5189
|
+
const attribution = canonicalRunAttribution(record.attribution);
|
|
3700
5190
|
return {
|
|
3701
5191
|
id: canonicalId,
|
|
3702
5192
|
name: name.length === 0 ? "subagent" : name,
|
|
3703
5193
|
...(label.length === 0 ? {} : { label }),
|
|
3704
5194
|
...(costUsd === undefined ? {} : { costUsd }),
|
|
5195
|
+
...(attribution === undefined ? {} : { attribution }),
|
|
3705
5196
|
};
|
|
3706
5197
|
}
|
|
3707
5198
|
/** Drop the `<profile>▸` prefix: the group header already names the profile. */
|
|
@@ -3781,19 +5272,26 @@ function upsertToolCall(parts, next, historyUpdate) {
|
|
|
3781
5272
|
parts[index] = updated;
|
|
3782
5273
|
}
|
|
3783
5274
|
/**
|
|
3784
|
-
* Whether a stored part
|
|
3785
|
-
*
|
|
3786
|
-
*
|
|
3787
|
-
* prose.
|
|
5275
|
+
* Whether a stored part marks a semantic boundary between streamed text runs.
|
|
5276
|
+
* Most telemetry remains invisible; compaction and the content-free assistant
|
|
5277
|
+
* message marker intentionally keep adjacent provider responses separate.
|
|
3788
5278
|
*/
|
|
3789
|
-
function
|
|
5279
|
+
function separatesStreamedText(part) {
|
|
5280
|
+
// A Monitor acknowledgement may arrive while the provider is still flushing
|
|
5281
|
+
// the preceding message's final text delta. Its compact activity row must not
|
|
5282
|
+
// split that word; the explicit message boundary below separates responses.
|
|
5283
|
+
if (part.type === "monitor-activity")
|
|
5284
|
+
return false;
|
|
3790
5285
|
if (part.type !== "telemetry")
|
|
3791
5286
|
return true;
|
|
3792
5287
|
const event = part.data;
|
|
3793
5288
|
if (event === null || typeof event !== "object" || Array.isArray(event))
|
|
3794
5289
|
return false;
|
|
3795
5290
|
const record = event;
|
|
3796
|
-
return record.type === "runtime_telemetry"
|
|
5291
|
+
return record.type === "runtime_telemetry"
|
|
5292
|
+
&& (record.kind === "context_compaction"
|
|
5293
|
+
|| record.kind === "context_usage"
|
|
5294
|
+
|| record.kind === "assistant_message_boundary");
|
|
3797
5295
|
}
|
|
3798
5296
|
/**
|
|
3799
5297
|
* Append a streamed delta to the trailing run of `type`.
|
|
@@ -3812,7 +5310,7 @@ function appendTextPart(parts, type, delta) {
|
|
|
3812
5310
|
const part = parts[index];
|
|
3813
5311
|
if (part === undefined)
|
|
3814
5312
|
break;
|
|
3815
|
-
if (!
|
|
5313
|
+
if (!separatesStreamedText(part))
|
|
3816
5314
|
continue;
|
|
3817
5315
|
if (part.type !== type)
|
|
3818
5316
|
break;
|
|
@@ -3922,6 +5420,81 @@ function replaceWholeText(parts, text) {
|
|
|
3922
5420
|
parts.splice(index, 1);
|
|
3923
5421
|
}
|
|
3924
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
|
+
}
|
|
3925
5498
|
function deriveAutomaticTitle(text, attachments) {
|
|
3926
5499
|
const candidate = text.trim().length > 0 ? text : attachments[0]?.name ?? "New conversation";
|
|
3927
5500
|
return normalizeTitle(candidate.replace(/\s+/gu, " ").slice(0, 80));
|
|
@@ -4230,6 +5803,13 @@ function parseParts(value) {
|
|
|
4230
5803
|
if (!Array.isArray(parts) || !parts.every(isWebMessagePart)) {
|
|
4231
5804
|
throw new WebConsoleError("storage_corrupt", "Persisted message parts have an invalid shape.", 500);
|
|
4232
5805
|
}
|
|
5806
|
+
if (parts.filter((part) => part.type === "monitor-activity").length > 1) {
|
|
5807
|
+
throw new WebConsoleError("storage_corrupt", "Persisted Monitor activity is duplicated.", 500);
|
|
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
|
+
}
|
|
4233
5813
|
quoteFromParts(parts);
|
|
4234
5814
|
return parts;
|
|
4235
5815
|
}
|
|
@@ -4256,7 +5836,7 @@ function liveInputStatusFromParts(parts) {
|
|
|
4256
5836
|
throw new WebConsoleError("storage_corrupt", "Persisted live-input metadata is invalid.", 500);
|
|
4257
5837
|
}
|
|
4258
5838
|
const status = data.status;
|
|
4259
|
-
if (status !== "pending" && status !== "applied" && status !== "queued" && status !== "cancelled") {
|
|
5839
|
+
if (status !== "pending" && status !== "applied" && status !== "queued" && status !== "cancelled" && status !== "uncertain") {
|
|
4260
5840
|
throw new WebConsoleError("storage_corrupt", "Persisted live-input status is invalid.", 500);
|
|
4261
5841
|
}
|
|
4262
5842
|
return status;
|
|
@@ -4309,7 +5889,9 @@ function canonicalSessionToolHistoryMetadata(value) {
|
|
|
4309
5889
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
4310
5890
|
return undefined;
|
|
4311
5891
|
const history = value;
|
|
4312
|
-
const valid = (history.persistence === "persisted"
|
|
5892
|
+
const valid = (history.persistence === "persisted"
|
|
5893
|
+
|| history.persistence === "deferred"
|
|
5894
|
+
|| history.persistence === "failed")
|
|
4313
5895
|
&& history.untrusted === true
|
|
4314
5896
|
&& (history.recordId === undefined || boundedHistoryString(history.recordId, 4_096))
|
|
4315
5897
|
&& (history.sequence === undefined || positiveSafeInteger(history.sequence))
|
|
@@ -4376,8 +5958,11 @@ function canonicalizePersistedPartHistory(value) {
|
|
|
4376
5958
|
if (part.type !== "subagent")
|
|
4377
5959
|
return value;
|
|
4378
5960
|
const canonicalPart = canonicalizePersistedObjectHistory(part);
|
|
5961
|
+
const { attribution: _rawAttribution, ...withoutAttribution } = canonicalPart;
|
|
5962
|
+
const attribution = canonicalRunAttribution(part.attribution);
|
|
4379
5963
|
return {
|
|
4380
|
-
...
|
|
5964
|
+
...withoutAttribution,
|
|
5965
|
+
...(attribution === undefined ? {} : { attribution }),
|
|
4381
5966
|
...(Array.isArray(part.calls)
|
|
4382
5967
|
? { calls: part.calls.map((call) => canonicalizePersistedHistoryRecord(call)) }
|
|
4383
5968
|
: {}),
|
|
@@ -4435,6 +6020,7 @@ function isWebMessagePart(value) {
|
|
|
4435
6020
|
&& (part.label === undefined || typeof part.label === "string")
|
|
4436
6021
|
&& (part.executionMs == null || typeof part.executionMs === "number")
|
|
4437
6022
|
&& (part.costUsd === undefined || typeof part.costUsd === "number")
|
|
6023
|
+
&& (part.attribution === undefined || canonicalRunAttribution(part.attribution) !== undefined)
|
|
4438
6024
|
&& (part.history === undefined || isSessionToolHistoryMetadata(part.history))
|
|
4439
6025
|
&& isWebToolCallStatus(part.status)
|
|
4440
6026
|
&& Array.isArray(part.calls)
|
|
@@ -4453,6 +6039,59 @@ function isWebMessagePart(value) {
|
|
|
4453
6039
|
return false;
|
|
4454
6040
|
}
|
|
4455
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
|
+
}
|
|
6051
|
+
if (part.type === "monitor-activity") {
|
|
6052
|
+
if (!hasOnlyKeys(part, new Set(["type", "monitors"]))
|
|
6053
|
+
|| !Array.isArray(part.monitors)
|
|
6054
|
+
|| part.monitors.length === 0
|
|
6055
|
+
|| part.monitors.length > AGENT_LIVE_INPUT_MAX_MESSAGES) {
|
|
6056
|
+
return false;
|
|
6057
|
+
}
|
|
6058
|
+
const monitorIds = new Set();
|
|
6059
|
+
let deliveryCount = 0;
|
|
6060
|
+
for (const raw of part.monitors) {
|
|
6061
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
6062
|
+
return false;
|
|
6063
|
+
const entry = raw;
|
|
6064
|
+
if (!hasOnlyKeys(entry, new Set(["projection", "deliveryKeys"])) || !Array.isArray(entry.deliveryKeys)) {
|
|
6065
|
+
return false;
|
|
6066
|
+
}
|
|
6067
|
+
let projection;
|
|
6068
|
+
try {
|
|
6069
|
+
projection = parseMonitorProjection(entry.projection);
|
|
6070
|
+
}
|
|
6071
|
+
catch {
|
|
6072
|
+
return false;
|
|
6073
|
+
}
|
|
6074
|
+
if (monitorIds.has(projection.monitorId)
|
|
6075
|
+
|| entry.deliveryKeys.length === 0
|
|
6076
|
+
|| entry.deliveryKeys.length > AGENT_LIVE_INPUT_MAX_MESSAGES) {
|
|
6077
|
+
return false;
|
|
6078
|
+
}
|
|
6079
|
+
monitorIds.add(projection.monitorId);
|
|
6080
|
+
const keys = new Set();
|
|
6081
|
+
for (const key of entry.deliveryKeys) {
|
|
6082
|
+
if (typeof key !== "string"
|
|
6083
|
+
|| key.length === 0
|
|
6084
|
+
|| key.length > 1_024
|
|
6085
|
+
|| /[\u0000-\u001f\u007f]/u.test(key)
|
|
6086
|
+
|| keys.has(key)) {
|
|
6087
|
+
return false;
|
|
6088
|
+
}
|
|
6089
|
+
keys.add(key);
|
|
6090
|
+
}
|
|
6091
|
+
deliveryCount += entry.deliveryKeys.length;
|
|
6092
|
+
}
|
|
6093
|
+
return deliveryCount <= AGENT_LIVE_INPUT_MAX_MESSAGES;
|
|
6094
|
+
}
|
|
4456
6095
|
if (part.type === "telemetry")
|
|
4457
6096
|
return typeof part.event === "string";
|
|
4458
6097
|
if (part.type === "error")
|
|
@@ -4575,6 +6214,38 @@ function parseStringArray(value) {
|
|
|
4575
6214
|
return undefined;
|
|
4576
6215
|
}
|
|
4577
6216
|
}
|
|
6217
|
+
/**
|
|
6218
|
+
* Malformed stored JSON must degrade to "this agent advertises no providers",
|
|
6219
|
+
* never throw: `mapAgent` runs on every bootstrap and discovery read, so a
|
|
6220
|
+
* throw here would take the whole console down over one bad row.
|
|
6221
|
+
*/
|
|
6222
|
+
function parseProviderSummary(value) {
|
|
6223
|
+
if (value === null)
|
|
6224
|
+
return undefined;
|
|
6225
|
+
let parsed;
|
|
6226
|
+
try {
|
|
6227
|
+
parsed = JSON.parse(value);
|
|
6228
|
+
}
|
|
6229
|
+
catch {
|
|
6230
|
+
return undefined;
|
|
6231
|
+
}
|
|
6232
|
+
if (!Array.isArray(parsed))
|
|
6233
|
+
return undefined;
|
|
6234
|
+
const result = [];
|
|
6235
|
+
for (const raw of parsed) {
|
|
6236
|
+
if (typeof raw !== "object" || raw === null)
|
|
6237
|
+
continue;
|
|
6238
|
+
const entry = raw;
|
|
6239
|
+
if (typeof entry.id !== "string" || entry.id.length === 0)
|
|
6240
|
+
continue;
|
|
6241
|
+
result.push({
|
|
6242
|
+
id: entry.id,
|
|
6243
|
+
label: typeof entry.label === "string" && entry.label.length > 0 ? entry.label : entry.id,
|
|
6244
|
+
...(entry.configured === true ? { configured: true } : {}),
|
|
6245
|
+
});
|
|
6246
|
+
}
|
|
6247
|
+
return result.length === 0 ? undefined : result;
|
|
6248
|
+
}
|
|
4578
6249
|
function parseRecord(value) {
|
|
4579
6250
|
if (value === null)
|
|
4580
6251
|
return undefined;
|
|
@@ -4604,16 +6275,187 @@ function normalizeRunStatus(value) {
|
|
|
4604
6275
|
? value
|
|
4605
6276
|
: "complete";
|
|
4606
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
|
+
}
|
|
4607
6447
|
function runtimeMetadata(metadata) {
|
|
4608
6448
|
const runtime = metadata?.runtime;
|
|
4609
6449
|
if (typeof runtime !== "object" || runtime === null || Array.isArray(runtime))
|
|
4610
6450
|
return undefined;
|
|
4611
6451
|
const record = runtime;
|
|
4612
|
-
const model =
|
|
4613
|
-
const effort =
|
|
4614
|
-
|
|
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 : {
|
|
4615
6456
|
...(model === undefined ? {} : { model }),
|
|
4616
6457
|
...(effort === undefined ? {} : { effort }),
|
|
6458
|
+
...(effectiveEffort === undefined ? {} : { effectiveEffort }),
|
|
4617
6459
|
};
|
|
4618
6460
|
}
|
|
4619
6461
|
function ignoreMissing(error) {
|