@mono-agent/web 0.20.10 → 0.20.14
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 +19 -8
- package/dist/contracts.d.ts +82 -2
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js.map +1 -1
- package/dist/effort-ladder.d.ts +97 -0
- package/dist/effort-ladder.d.ts.map +1 -0
- package/dist/effort-ladder.js +114 -0
- package/dist/effort-ladder.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/notification-client.d.ts +11 -2
- package/dist/notification-client.d.ts.map +1 -1
- package/dist/notification-client.js +1 -1
- 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 +10 -1
- package/dist/operator-client.d.ts.map +1 -1
- package/dist/operator-client.js +110 -2
- package/dist/operator-client.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +52 -3
- package/dist/server.js.map +1 -1
- package/dist/service.d.ts +117 -12
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +661 -69
- package/dist/service.js.map +1 -1
- package/dist/store.d.ts +124 -6
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +543 -34
- package/dist/store.js.map +1 -1
- package/package.json +5 -7
- package/webapp/dist/assets/{assistant-ui-CxqQJmH9.js → assistant-ui-BzN2E6n6.js} +1 -1
- package/webapp/dist/assets/index-C4a2Dv1W.js +155 -0
- package/webapp/dist/assets/index-mhMBLGB0.css +1 -0
- package/webapp/dist/assets/{markdown-8pwMczg6.js → markdown-Vq23xgh7.js} +1 -1
- package/webapp/dist/index.html +4 -4
- package/webapp/dist/sw.js +1 -1
- package/webapp/dist/assets/index-DG1ELH22.css +0 -1
- package/webapp/dist/assets/index-j6VTEsJ5.js +0 -155
package/dist/store.js
CHANGED
|
@@ -3,7 +3,7 @@ import { chmod, lstat, readdir, unlink } from "node:fs/promises";
|
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { DatabaseSync } from "node:sqlite";
|
|
5
5
|
import { isDeepStrictEqual } from "node:util";
|
|
6
|
-
import { AGENT_LIVE_INPUT_MAX_CHARACTERS, MAX_AGENT_REPLY_PARTS, classifyNotifySuppression, parseProcessJobProjection, } from "@mono-agent/agent-contracts";
|
|
6
|
+
import { AGENT_LIVE_INPUT_MAX_CHARACTERS, AGENT_LIVE_INPUT_MAX_MESSAGES, MAX_AGENT_REPLY_PARTS, classifyNotifySuppression, parseMonitorProjection, parseProcessJobProjection, } from "@mono-agent/agent-contracts";
|
|
7
7
|
import { WEB_MAX_FILES_PER_TURN, WEB_MAX_LIVE_INPUTS_PER_THREAD, WEB_MAX_TURN_ATTACHMENT_BYTES, WEB_MAX_TURN_TEXT_CHARACTERS, } from "./contracts.js";
|
|
8
8
|
import { WebConsoleError } from "./errors.js";
|
|
9
9
|
import { webPushPreview } from "./push-preview.js";
|
|
@@ -118,7 +118,7 @@ export function messageSearchMatchExpression(raw) {
|
|
|
118
118
|
export function escapeLikeTerm(raw) {
|
|
119
119
|
return raw.replaceAll(/[\\%_]/gu, (character) => `\\${character}`);
|
|
120
120
|
}
|
|
121
|
-
const WEB_STORAGE_SCHEMA_VERSION =
|
|
121
|
+
const WEB_STORAGE_SCHEMA_VERSION = 15;
|
|
122
122
|
const MAX_REVISIONS_PER_THREAD = 1_000;
|
|
123
123
|
export const WEB_THREAD_PAGE_MAX = 200;
|
|
124
124
|
export const WEB_MESSAGE_PAGE_MAX = 100;
|
|
@@ -133,6 +133,14 @@ export class WebStore {
|
|
|
133
133
|
database;
|
|
134
134
|
clock;
|
|
135
135
|
closed = false;
|
|
136
|
+
/**
|
|
137
|
+
* The agent PROCESS generation each live summary was built from, by source
|
|
138
|
+
* id. Deliberately not a column: it describes the process behind a source id
|
|
139
|
+
* right now, so a value read back from disk after a console restart would be
|
|
140
|
+
* a claim about a process nobody probed. `replaceAgents` is the only writer,
|
|
141
|
+
* and it drops ids discovery no longer reports.
|
|
142
|
+
*/
|
|
143
|
+
agentGenerations = new Map();
|
|
136
144
|
constructor(database, paths, clock) {
|
|
137
145
|
this.database = database;
|
|
138
146
|
this.paths = paths;
|
|
@@ -179,24 +187,77 @@ export class WebStore {
|
|
|
179
187
|
this.closed = true;
|
|
180
188
|
this.database.close();
|
|
181
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* Persist the discovered agent list. Returns whether the change is worth
|
|
192
|
+
* telling clients about.
|
|
193
|
+
*
|
|
194
|
+
* `updatedAt` is a heartbeat timestamp that moves on every discovery poll, so
|
|
195
|
+
* comparing it made this return `true` roughly once every five seconds. At the
|
|
196
|
+
* time, `agents.changed` carried the whole agent list, every model, every model
|
|
197
|
+
* option and every provider. Measured against a 67-agent fleet that was 60 KB
|
|
198
|
+
* per event and 99.5% of all SSE traffic: ~42 MiB/hour to an idle console with
|
|
199
|
+
* nobody using it. The event is now a compact invalidation, but a heartbeat is
|
|
200
|
+
* still not a state change worth making every browser re-bootstrap for.
|
|
201
|
+
*
|
|
202
|
+
* So the two questions are separated. Any difference at all is still written,
|
|
203
|
+
* because the store should hold the freshest heartbeat; only a difference that
|
|
204
|
+
* survives normalizing `updatedAt` is broadcast. Nothing in the console reads
|
|
205
|
+
* an agent's `updatedAt` --- it stays on the wire for clients that want it, it
|
|
206
|
+
* just no longer triggers a fleet-sized frame on its own. `agentGeneration()`
|
|
207
|
+
* already excludes it for the same reason.
|
|
208
|
+
*/
|
|
182
209
|
replaceAgents(agents) {
|
|
183
210
|
const current = this.listAgents();
|
|
184
211
|
const currentById = new Map(current.map((agent) => [agent.sourceId, agent]));
|
|
185
212
|
const incomingIds = new Set(agents.map((agent) => agent.sourceId));
|
|
186
|
-
const
|
|
213
|
+
const departed = current.some((agent) => !incomingIds.has(agent.sourceId) && agent.status !== "offline");
|
|
214
|
+
const differs = (agent, ignoreHeartbeat) => {
|
|
187
215
|
const prior = currentById.get(agent.sourceId);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
216
|
+
if (prior === undefined)
|
|
217
|
+
return true;
|
|
218
|
+
// `pinned` is store-owned and never arrives from discovery; normalizing it
|
|
219
|
+
// keeps a locally pinned agent from looking like an incoming change.
|
|
220
|
+
const next = { ...agent, pinned: prior.pinned };
|
|
221
|
+
if (!ignoreHeartbeat)
|
|
222
|
+
return !isDeepStrictEqual(prior, next);
|
|
223
|
+
return !isDeepStrictEqual({ ...prior, updatedAt: "" }, { ...next, updatedAt: "" });
|
|
224
|
+
};
|
|
225
|
+
const changed = agents.some((agent) => differs(agent, false)) || departed;
|
|
226
|
+
const notable = agents.some((agent) => differs(agent, true)) || departed;
|
|
227
|
+
// After `current` is read (it carries the PREVIOUS generations) and after
|
|
228
|
+
// both comparisons, so a restart behind an otherwise identical summary
|
|
229
|
+
// still reads as a change and still reaches the browser.
|
|
230
|
+
//
|
|
231
|
+
// And only once the row it describes is actually on disk. Advancing before
|
|
232
|
+
// the transaction made the map a claim the database had not agreed to: a
|
|
233
|
+
// transaction that threw left the NEW generation stitched onto the OLD row,
|
|
234
|
+
// so the retry compared the new summary against a prior that already
|
|
235
|
+
// carried its generation, found only `updatedAt` different, and returned
|
|
236
|
+
// `notable === false`. The restart broadcast was then lost permanently ---
|
|
237
|
+
// not deferred --- and an open console stayed stale until it reconnected.
|
|
238
|
+
const adoptGenerations = () => {
|
|
239
|
+
this.agentGenerations.clear();
|
|
240
|
+
for (const agent of agents) {
|
|
241
|
+
if (agent.generation !== undefined)
|
|
242
|
+
this.agentGenerations.set(agent.sourceId, agent.generation);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
if (!changed) {
|
|
246
|
+
// Nothing to persist, so there is nothing for the adoption to outrun:
|
|
247
|
+
// every incoming generation already equals the one it is replacing, or
|
|
248
|
+
// `changed` would be true. The map is still swept so a departed agent
|
|
249
|
+
// does not leave one behind.
|
|
250
|
+
adoptGenerations();
|
|
191
251
|
return false;
|
|
252
|
+
}
|
|
192
253
|
this.transaction(() => {
|
|
193
254
|
this.database.prepare("UPDATE agents SET status = 'offline'").run();
|
|
194
255
|
const statement = this.database.prepare(`
|
|
195
256
|
INSERT INTO agents (
|
|
196
257
|
source_id, label, status, health, supports_attachments, models_json,
|
|
197
258
|
default_model, default_effort, efforts_json, model_options_json,
|
|
198
|
-
cron_read, cron_actions, ask_by_id, updated_at
|
|
199
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
259
|
+
providers_json, cron_read, cron_actions, ask_by_id, updated_at
|
|
260
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
200
261
|
ON CONFLICT(source_id) DO UPDATE SET
|
|
201
262
|
label = excluded.label,
|
|
202
263
|
status = excluded.status,
|
|
@@ -207,24 +268,31 @@ export class WebStore {
|
|
|
207
268
|
default_effort = excluded.default_effort,
|
|
208
269
|
efforts_json = excluded.efforts_json,
|
|
209
270
|
model_options_json = excluded.model_options_json,
|
|
271
|
+
providers_json = excluded.providers_json,
|
|
210
272
|
cron_read = excluded.cron_read,
|
|
211
273
|
cron_actions = excluded.cron_actions,
|
|
212
274
|
ask_by_id = excluded.ask_by_id,
|
|
213
275
|
updated_at = excluded.updated_at
|
|
214
276
|
`);
|
|
215
277
|
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);
|
|
278
|
+
statement.run(agent.sourceId, agent.label, agent.status, agent.health ?? null, agent.supportsAttachments ? 1 : 0, stringifyOptional(agent.models), agent.defaultModel ?? null, agent.defaultEffort ?? null, stringifyOptional(agent.efforts), stringifyOptional(agent.modelOptions), stringifyOptional(agent.providers), agent.cron?.read === true ? 1 : 0, agent.cron?.actions === true ? 1 : 0, agent.supportsAskById === true ? 1 : 0, agent.updatedAt);
|
|
217
279
|
}
|
|
218
280
|
});
|
|
219
|
-
|
|
281
|
+
adoptGenerations();
|
|
282
|
+
return notable;
|
|
220
283
|
}
|
|
221
284
|
listAgents() {
|
|
222
285
|
const rows = this.database.prepare(agentSelectSql("ORDER BY pinned DESC, a.label COLLATE NOCASE, a.source_id")).all();
|
|
223
|
-
return rows.map(mapAgent);
|
|
286
|
+
return rows.map((row) => this.withGeneration(mapAgent(row)));
|
|
224
287
|
}
|
|
225
288
|
getAgent(sourceId) {
|
|
226
289
|
const row = this.database.prepare(agentSelectSql("WHERE a.source_id = ?")).get(sourceId);
|
|
227
|
-
return row === undefined ? undefined : mapAgent(row);
|
|
290
|
+
return row === undefined ? undefined : this.withGeneration(mapAgent(row));
|
|
291
|
+
}
|
|
292
|
+
/** Stitch the live generation onto a row read back from disk. */
|
|
293
|
+
withGeneration(agent) {
|
|
294
|
+
const generation = this.agentGenerations.get(agent.sourceId);
|
|
295
|
+
return generation === undefined ? agent : { ...agent, generation };
|
|
228
296
|
}
|
|
229
297
|
setAgentPinned(sourceId, pinned) {
|
|
230
298
|
if (this.getAgent(sourceId) === undefined) {
|
|
@@ -960,6 +1028,115 @@ export class WebStore {
|
|
|
960
1028
|
WHERE source_id = ? AND job_id = ? AND delivery_key = ? AND state = 'accepted'
|
|
961
1029
|
`).run(input.sourceId, input.jobId, input.deliveryKey);
|
|
962
1030
|
}
|
|
1031
|
+
/** Durably claim one Monitor wake before touching the operator. */
|
|
1032
|
+
reserveMonitorWake(input) {
|
|
1033
|
+
const monitor = parseMonitorProjection(input.monitor);
|
|
1034
|
+
if (monitor.monitorId !== input.monitorId) {
|
|
1035
|
+
throw new WebConsoleError("invalid_notification", "The Monitor projection does not match its delivery identity.", 409);
|
|
1036
|
+
}
|
|
1037
|
+
const thread = this.database.prepare("SELECT source_id, archived_at, trigger_kind FROM threads WHERE id = ?")
|
|
1038
|
+
.get(input.threadId);
|
|
1039
|
+
if (thread === undefined || thread.source_id !== input.sourceId) {
|
|
1040
|
+
throw new WebConsoleError("invalid_notification", "The Monitor wake does not match its web thread.", 409);
|
|
1041
|
+
}
|
|
1042
|
+
if (thread.archived_at !== null || thread.trigger_kind !== null) {
|
|
1043
|
+
throw new WebConsoleError("thread_archived", "The Monitor wake destination is not an active web conversation.", 409);
|
|
1044
|
+
}
|
|
1045
|
+
const existing = this.database.prepare(`
|
|
1046
|
+
SELECT monitor_id, thread_id, payload_sha256, state, disposition
|
|
1047
|
+
FROM monitor_wake_deliveries WHERE source_id = ? AND delivery_key = ?
|
|
1048
|
+
`).get(input.sourceId, input.deliveryKey);
|
|
1049
|
+
if (existing !== undefined) {
|
|
1050
|
+
if (existing.monitor_id !== input.monitorId
|
|
1051
|
+
|| existing.thread_id !== input.threadId
|
|
1052
|
+
|| existing.payload_sha256 !== input.payloadSha256) {
|
|
1053
|
+
throw new WebConsoleError("notification_idempotency_conflict", "The Monitor delivery key was already used for a different wake.", 409);
|
|
1054
|
+
}
|
|
1055
|
+
if (existing.state === "completed"
|
|
1056
|
+
&& (existing.disposition === "steered" || existing.disposition === "follow_up")) {
|
|
1057
|
+
return { kind: "completed", disposition: existing.disposition };
|
|
1058
|
+
}
|
|
1059
|
+
return { kind: "uncertain" };
|
|
1060
|
+
}
|
|
1061
|
+
this.database.prepare(`
|
|
1062
|
+
INSERT INTO monitor_wake_deliveries (
|
|
1063
|
+
source_id, monitor_id, delivery_key, thread_id, payload_sha256, projection_json,
|
|
1064
|
+
state, disposition, turn_id, created_at, completed_at
|
|
1065
|
+
) VALUES (?, ?, ?, ?, ?, ?, 'accepted', NULL, NULL, ?, NULL)
|
|
1066
|
+
`).run(input.sourceId, input.monitorId, input.deliveryKey, input.threadId, input.payloadSha256, JSON.stringify(monitor), this.now());
|
|
1067
|
+
return { kind: "new" };
|
|
1068
|
+
}
|
|
1069
|
+
completeMonitorWake(input) {
|
|
1070
|
+
let messageId;
|
|
1071
|
+
this.transaction(() => {
|
|
1072
|
+
const result = this.database.prepare(`
|
|
1073
|
+
UPDATE monitor_wake_deliveries
|
|
1074
|
+
SET state = 'completed', disposition = ?, turn_id = ?, completed_at = ?
|
|
1075
|
+
WHERE source_id = ? AND monitor_id = ? AND delivery_key = ? AND state = 'accepted'
|
|
1076
|
+
`).run(input.disposition, input.turnId ?? null, this.now(), input.sourceId, input.monitorId, input.deliveryKey);
|
|
1077
|
+
if (result.changes !== 1) {
|
|
1078
|
+
throw new WebConsoleError("notification_reservation_lost", "The Monitor wake reservation was lost.", 409);
|
|
1079
|
+
}
|
|
1080
|
+
if (input.turnId === undefined)
|
|
1081
|
+
return;
|
|
1082
|
+
const turn = this.requireTurn(input.turnId);
|
|
1083
|
+
const reservation = this.database.prepare(`
|
|
1084
|
+
SELECT thread_id, projection_json FROM monitor_wake_deliveries
|
|
1085
|
+
WHERE source_id = ? AND monitor_id = ? AND delivery_key = ?
|
|
1086
|
+
`).get(input.sourceId, input.monitorId, input.deliveryKey);
|
|
1087
|
+
if (reservation?.thread_id !== turn.thread_id) {
|
|
1088
|
+
throw new WebConsoleError("monitor_origin_mismatch", "The Monitor activity does not belong to this turn.", 409);
|
|
1089
|
+
}
|
|
1090
|
+
if (reservation.projection_json === null)
|
|
1091
|
+
return;
|
|
1092
|
+
let projection;
|
|
1093
|
+
try {
|
|
1094
|
+
projection = parseMonitorProjection(JSON.parse(reservation.projection_json));
|
|
1095
|
+
if (projection.monitorId !== input.monitorId)
|
|
1096
|
+
throw new TypeError("Monitor identity mismatch.");
|
|
1097
|
+
}
|
|
1098
|
+
catch {
|
|
1099
|
+
throw new WebConsoleError("storage_corrupt", "A retained Monitor wake projection is invalid.", 500);
|
|
1100
|
+
}
|
|
1101
|
+
const message = this.requireMessage(turn.assistant_message_id);
|
|
1102
|
+
const parts = [...message.parts];
|
|
1103
|
+
if (!upsertMonitorActivity(parts, projection, input.deliveryKey))
|
|
1104
|
+
return;
|
|
1105
|
+
const now = this.now();
|
|
1106
|
+
this.database.prepare("UPDATE messages SET parts_json = ?, updated_at = ? WHERE id = ?")
|
|
1107
|
+
.run(serializeParts(parts), now, message.id);
|
|
1108
|
+
messageId = message.id;
|
|
1109
|
+
});
|
|
1110
|
+
return messageId === undefined ? undefined : this.requireMessage(messageId);
|
|
1111
|
+
}
|
|
1112
|
+
/** Resolve only an exact Monitor live-input receipt belonging to this turn. */
|
|
1113
|
+
monitorWakeProjection(turnId, deliveryKey) {
|
|
1114
|
+
const row = this.database.prepare(`
|
|
1115
|
+
SELECT deliveries.monitor_id, deliveries.projection_json
|
|
1116
|
+
FROM monitor_wake_deliveries AS deliveries
|
|
1117
|
+
JOIN turns ON turns.id = ? AND turns.thread_id = deliveries.thread_id
|
|
1118
|
+
JOIN threads ON threads.id = turns.thread_id AND threads.source_id = deliveries.source_id
|
|
1119
|
+
WHERE deliveries.delivery_key = ?
|
|
1120
|
+
`).get(turnId, deliveryKey);
|
|
1121
|
+
if (row === undefined || row.projection_json === null)
|
|
1122
|
+
return undefined;
|
|
1123
|
+
try {
|
|
1124
|
+
const projection = parseMonitorProjection(JSON.parse(row.projection_json));
|
|
1125
|
+
if (projection.monitorId !== row.monitor_id)
|
|
1126
|
+
throw new TypeError("Monitor identity mismatch.");
|
|
1127
|
+
return projection;
|
|
1128
|
+
}
|
|
1129
|
+
catch {
|
|
1130
|
+
throw new WebConsoleError("storage_corrupt", "A retained Monitor wake projection is invalid.", 500);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
/** Release a Monitor reservation only before any operator delivery begins. */
|
|
1134
|
+
abandonMonitorWake(input) {
|
|
1135
|
+
this.database.prepare(`
|
|
1136
|
+
DELETE FROM monitor_wake_deliveries
|
|
1137
|
+
WHERE source_id = ? AND monitor_id = ? AND delivery_key = ? AND state = 'accepted'
|
|
1138
|
+
`).run(input.sourceId, input.monitorId, input.deliveryKey);
|
|
1139
|
+
}
|
|
963
1140
|
/** Exact retained binding used before proxying a single operator job. */
|
|
964
1141
|
processJobCardBelongsToThread(sourceId, threadId, jobId) {
|
|
965
1142
|
return this.database.prepare(`
|
|
@@ -1234,14 +1411,44 @@ export class WebStore {
|
|
|
1234
1411
|
this.setSetting("current_thread_id", resolved);
|
|
1235
1412
|
}
|
|
1236
1413
|
patchThread(id, patch) {
|
|
1414
|
+
return this.transaction(() => this.writeThreadPatch(id, patch));
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Compare-and-set: apply `patch` only while the conversation still carries no
|
|
1418
|
+
* run override, and report which way it went.
|
|
1419
|
+
*
|
|
1420
|
+
* The console's one-time adoption of a browser-local override is the caller.
|
|
1421
|
+
* Read and write are ONE `BEGIN IMMEDIATE` here rather than two statements
|
|
1422
|
+
* either side of a service-level check: the process lease
|
|
1423
|
+
* (`state-paths.ts`) is held on a different database file for the service
|
|
1424
|
+
* lifetime, so it makes this process the only *service* writing, not this
|
|
1425
|
+
* statement the only writer of `web.sqlite`. Any other connection to the
|
|
1426
|
+
* state DB -- a maintenance script, a second console pointed at the same
|
|
1427
|
+
* state dir -- could land between a bare read and a bare write, and did in a
|
|
1428
|
+
* probe.
|
|
1429
|
+
*/
|
|
1430
|
+
patchThreadIfRunConfigUnset(id, patch) {
|
|
1431
|
+
return this.transaction(() => {
|
|
1432
|
+
const resolved = this.resolveThreadId(id);
|
|
1433
|
+
const current = this.requireThread(resolved);
|
|
1434
|
+
if (current.runModel !== null || current.runEffort !== null) {
|
|
1435
|
+
return { applied: false, thread: { ...current, sourceId: current.sourceId } };
|
|
1436
|
+
}
|
|
1437
|
+
return { applied: true, thread: this.writeThreadPatch(resolved, patch) };
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
/** The body of {@link patchThread}. Assumes an open transaction. */
|
|
1441
|
+
writeThreadPatch(id, patch) {
|
|
1237
1442
|
id = this.resolveThreadId(id);
|
|
1238
1443
|
const current = this.requireThread(id);
|
|
1239
1444
|
const now = this.now();
|
|
1240
1445
|
const title = patch.title === undefined ? undefined : normalizeTitle(patch.title);
|
|
1241
1446
|
const archivedAt = patch.archived === undefined ? undefined : patch.archived ? now : null;
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1447
|
+
const runModel = patch.model === undefined ? undefined : patch.model;
|
|
1448
|
+
const runEffort = patch.effort === undefined ? undefined : patch.effort;
|
|
1449
|
+
{
|
|
1450
|
+
const sets = [];
|
|
1451
|
+
const values = [];
|
|
1245
1452
|
if (title !== undefined) {
|
|
1246
1453
|
sets.push("title = ?", "title_manual = 1");
|
|
1247
1454
|
values.push(title);
|
|
@@ -1250,13 +1457,34 @@ export class WebStore {
|
|
|
1250
1457
|
sets.push("archived_at = ?");
|
|
1251
1458
|
values.push(archivedAt);
|
|
1252
1459
|
}
|
|
1460
|
+
if (runModel !== undefined) {
|
|
1461
|
+
sets.push("run_model = ?");
|
|
1462
|
+
values.push(runModel);
|
|
1463
|
+
}
|
|
1464
|
+
if (runEffort !== undefined) {
|
|
1465
|
+
sets.push("run_effort = ?");
|
|
1466
|
+
values.push(runEffort);
|
|
1467
|
+
}
|
|
1468
|
+
// A model/effort-only patch must not reorder the sidebar, so `updated_at`
|
|
1469
|
+
// only advances when title or archived state actually changes.
|
|
1470
|
+
if (title !== undefined || archivedAt !== undefined) {
|
|
1471
|
+
sets.push("updated_at = ?");
|
|
1472
|
+
values.push(now);
|
|
1473
|
+
}
|
|
1474
|
+
sets.push("revision = revision + 1");
|
|
1253
1475
|
values.push(id);
|
|
1254
1476
|
this.database.prepare(`UPDATE threads SET ${sets.join(", ")} WHERE id = ?`).run(...values);
|
|
1255
|
-
this.recordThreadRevision(id, title !== undefined
|
|
1477
|
+
this.recordThreadRevision(id, title !== undefined
|
|
1478
|
+
? "title_changed"
|
|
1479
|
+
: archivedAt !== undefined
|
|
1480
|
+
? patch.archived
|
|
1481
|
+
? "archived"
|
|
1482
|
+
: "unarchived"
|
|
1483
|
+
: "run_config_changed", now);
|
|
1256
1484
|
if (patch.archived === true && this.currentThreadId() === id) {
|
|
1257
1485
|
this.database.prepare("DELETE FROM settings WHERE key = 'current_thread_id'").run();
|
|
1258
1486
|
}
|
|
1259
|
-
}
|
|
1487
|
+
}
|
|
1260
1488
|
return { ...this.requireThread(id), sourceId: current.sourceId };
|
|
1261
1489
|
}
|
|
1262
1490
|
/** Whether the current interactive thread still accepts agent-proposed titles. */
|
|
@@ -1361,6 +1589,34 @@ export class WebStore {
|
|
|
1361
1589
|
const row = this.database.prepare("SELECT * FROM attachments WHERE id = ?").get(id);
|
|
1362
1590
|
return row === undefined ? undefined : mapStoredAttachment(row);
|
|
1363
1591
|
}
|
|
1592
|
+
/**
|
|
1593
|
+
* Durable copies are keyed by the part that produced them, so re-observing a
|
|
1594
|
+
* message — a reconnect, a replay, a second read of the same thread — cannot
|
|
1595
|
+
* store the same image twice.
|
|
1596
|
+
*/
|
|
1597
|
+
static replyAttachmentId(messageId, partId) {
|
|
1598
|
+
return `reply:${encodeURIComponent(messageId)}:${encodeURIComponent(partId)}`;
|
|
1599
|
+
}
|
|
1600
|
+
storedReplyAttachment(messageId, partId) {
|
|
1601
|
+
const attachment = this.getStoredAttachment(WebStore.replyAttachmentId(messageId, partId));
|
|
1602
|
+
return attachment?.origin === "reply" ? attachment : undefined;
|
|
1603
|
+
}
|
|
1604
|
+
/**
|
|
1605
|
+
* Records an already-written durable copy. Committed on arrival and bound to
|
|
1606
|
+
* its thread, so the staged-upload purge and the staged-only delete route both
|
|
1607
|
+
* pass it over, while the thread's own delete cascade still reclaims it.
|
|
1608
|
+
*/
|
|
1609
|
+
recordReplyAttachment(input) {
|
|
1610
|
+
const id = WebStore.replyAttachmentId(input.messageId, input.partId);
|
|
1611
|
+
const now = this.now();
|
|
1612
|
+
this.database.prepare(`
|
|
1613
|
+
INSERT INTO attachments (
|
|
1614
|
+
id, thread_id, message_id, name, content_type, size_bytes, kind,
|
|
1615
|
+
status, uploaded, origin, storage_name, created_at, updated_at
|
|
1616
|
+
) VALUES (?, ?, ?, ?, ?, ?, 'image', 'committed', 1, 'reply', ?, ?, ?)
|
|
1617
|
+
`).run(id, input.threadId, input.messageId, input.name, input.contentType, input.sizeBytes, input.storageName, now, now);
|
|
1618
|
+
return this.requireStoredAttachment(id);
|
|
1619
|
+
}
|
|
1364
1620
|
stagedUploadUsage() {
|
|
1365
1621
|
const row = this.database.prepare(`
|
|
1366
1622
|
SELECT COUNT(*) AS count, COALESCE(SUM(size_bytes), 0) AS bytes
|
|
@@ -1562,7 +1818,7 @@ export class WebStore {
|
|
|
1562
1818
|
id, thread_id, status, text, model, effort, assistant_message_id,
|
|
1563
1819
|
started_at, finished_at, error_code, error_message
|
|
1564
1820
|
) VALUES (?, ?, 'running', ?, NULL, NULL, ?, ?, NULL, NULL, NULL)
|
|
1565
|
-
`).run(turnId, threadId, input.prompt, assistantMessageId, now);
|
|
1821
|
+
`).run(turnId, threadId, input.storedPrompt ?? input.prompt, assistantMessageId, now);
|
|
1566
1822
|
this.database.prepare(`
|
|
1567
1823
|
INSERT INTO messages (id, thread_id, turn_id, role, parts_json, created_at, updated_at, status)
|
|
1568
1824
|
VALUES (?, ?, ?, 'assistant', '[]', ?, ?, 'running')
|
|
@@ -1782,7 +2038,7 @@ export class WebStore {
|
|
|
1782
2038
|
replaceWholeText(parts, frame.text);
|
|
1783
2039
|
}
|
|
1784
2040
|
else if (frame.kind === "event") {
|
|
1785
|
-
applyEvent(parts, frame.event);
|
|
2041
|
+
applyEvent(parts, frame.event, (deliveryKey) => this.monitorWakeProjection(turnId, deliveryKey));
|
|
1786
2042
|
if (frame.event.type === "runtime_telemetry" && frame.event.kind === "run_config") {
|
|
1787
2043
|
if (typeof frame.event.data?.model === "string")
|
|
1788
2044
|
actualModel = frame.event.data.model;
|
|
@@ -1806,9 +2062,9 @@ export class WebStore {
|
|
|
1806
2062
|
});
|
|
1807
2063
|
return this.requireMessage(message.id);
|
|
1808
2064
|
}
|
|
1809
|
-
completeTurn(turnId, finalText, metadata, replyParts) {
|
|
2065
|
+
completeTurn(turnId, finalText, metadata, replyParts, options = {}) {
|
|
1810
2066
|
const runtime = runtimeMetadata(metadata);
|
|
1811
|
-
return this.finishTurn(turnId, "complete", finalText, undefined, undefined, runtime, replyParts);
|
|
2067
|
+
return this.finishTurn(turnId, "complete", finalText, undefined, undefined, runtime, replyParts, options.suppressResponsePush === true);
|
|
1812
2068
|
}
|
|
1813
2069
|
failTurn(turnId, error) {
|
|
1814
2070
|
return this.finishTurn(turnId, error.cancelled === true ? "cancelled" : "failed", undefined, error.code, error.message, undefined);
|
|
@@ -2179,6 +2435,7 @@ export class WebStore {
|
|
|
2179
2435
|
default_effort TEXT,
|
|
2180
2436
|
efforts_json TEXT,
|
|
2181
2437
|
model_options_json TEXT,
|
|
2438
|
+
providers_json TEXT,
|
|
2182
2439
|
cron_read INTEGER NOT NULL DEFAULT 0,
|
|
2183
2440
|
cron_actions INTEGER NOT NULL DEFAULT 0,
|
|
2184
2441
|
ask_by_id INTEGER NOT NULL DEFAULT 0,
|
|
@@ -2194,6 +2451,8 @@ export class WebStore {
|
|
|
2194
2451
|
archived_at TEXT,
|
|
2195
2452
|
created_at TEXT NOT NULL,
|
|
2196
2453
|
updated_at TEXT NOT NULL,
|
|
2454
|
+
run_model TEXT,
|
|
2455
|
+
run_effort TEXT,
|
|
2197
2456
|
revision INTEGER NOT NULL DEFAULT 1
|
|
2198
2457
|
);
|
|
2199
2458
|
CREATE TABLE IF NOT EXISTS turns (
|
|
@@ -2246,6 +2505,7 @@ export class WebStore {
|
|
|
2246
2505
|
kind TEXT NOT NULL,
|
|
2247
2506
|
status TEXT NOT NULL,
|
|
2248
2507
|
uploaded INTEGER NOT NULL DEFAULT 0,
|
|
2508
|
+
origin TEXT NOT NULL DEFAULT 'upload' CHECK (origin IN ('upload', 'reply')),
|
|
2249
2509
|
storage_name TEXT NOT NULL UNIQUE,
|
|
2250
2510
|
created_at TEXT NOT NULL,
|
|
2251
2511
|
updated_at TEXT NOT NULL
|
|
@@ -2295,6 +2555,22 @@ export class WebStore {
|
|
|
2295
2555
|
);
|
|
2296
2556
|
CREATE INDEX IF NOT EXISTS process_job_wake_deliveries_by_thread
|
|
2297
2557
|
ON process_job_wake_deliveries(thread_id, created_at);
|
|
2558
|
+
CREATE TABLE IF NOT EXISTS monitor_wake_deliveries (
|
|
2559
|
+
source_id TEXT NOT NULL REFERENCES agents(source_id),
|
|
2560
|
+
monitor_id TEXT NOT NULL,
|
|
2561
|
+
delivery_key TEXT NOT NULL,
|
|
2562
|
+
thread_id TEXT REFERENCES threads(id) ON DELETE SET NULL,
|
|
2563
|
+
payload_sha256 TEXT NOT NULL,
|
|
2564
|
+
projection_json TEXT,
|
|
2565
|
+
state TEXT NOT NULL CHECK (state IN ('accepted', 'completed')),
|
|
2566
|
+
disposition TEXT CHECK (disposition IN ('steered', 'follow_up')),
|
|
2567
|
+
turn_id TEXT REFERENCES turns(id) ON DELETE SET NULL,
|
|
2568
|
+
created_at TEXT NOT NULL,
|
|
2569
|
+
completed_at TEXT,
|
|
2570
|
+
PRIMARY KEY (source_id, delivery_key)
|
|
2571
|
+
);
|
|
2572
|
+
CREATE INDEX IF NOT EXISTS monitor_wake_deliveries_by_thread
|
|
2573
|
+
ON monitor_wake_deliveries(thread_id, created_at);
|
|
2298
2574
|
CREATE TABLE IF NOT EXISTS cron_channels (
|
|
2299
2575
|
source_id TEXT NOT NULL REFERENCES agents(source_id),
|
|
2300
2576
|
job_id TEXT NOT NULL,
|
|
@@ -2438,6 +2714,51 @@ export class WebStore {
|
|
|
2438
2714
|
// database and idempotent if the sweep is ever re-run.
|
|
2439
2715
|
if (versionRow.user_version < 9)
|
|
2440
2716
|
this.database.exec(MESSAGE_SEARCH_BACKFILL_SQL);
|
|
2717
|
+
// Durable copies of agent-published images share the upload table so that
|
|
2718
|
+
// thread deletion, archival file cleanup, and every existing purge surface
|
|
2719
|
+
// reach them without a second store to keep in sync.
|
|
2720
|
+
if (versionRow.user_version < 10) {
|
|
2721
|
+
const columns = new Set(this.database.prepare("PRAGMA table_info(attachments)").all()
|
|
2722
|
+
.map((column) => column.name));
|
|
2723
|
+
if (!columns.has("origin")) {
|
|
2724
|
+
this.database.exec("ALTER TABLE attachments ADD COLUMN origin TEXT NOT NULL DEFAULT 'upload' CHECK (origin IN ('upload', 'reply'))");
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
// Per-conversation model/effort overrides are server-persisted columns.
|
|
2728
|
+
// Guard on PRAGMA table_info so the ALTER is skipped when the columns
|
|
2729
|
+
// already exist, keeping the migration re-runnable.
|
|
2730
|
+
if (versionRow.user_version < 11) {
|
|
2731
|
+
const columns = new Set(this.database.prepare("PRAGMA table_info(threads)").all()
|
|
2732
|
+
.map((column) => column.name));
|
|
2733
|
+
if (!columns.has("run_model")) {
|
|
2734
|
+
this.database.exec("ALTER TABLE threads ADD COLUMN run_model TEXT");
|
|
2735
|
+
}
|
|
2736
|
+
if (!columns.has("run_effort")) {
|
|
2737
|
+
this.database.exec("ALTER TABLE threads ADD COLUMN run_effort TEXT");
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
// The provider summary an agent advertises. Guarded on PRAGMA
|
|
2741
|
+
// table_info so the ALTER is skipped when the column already exists,
|
|
2742
|
+
// keeping the migration re-runnable after an interrupted upgrade.
|
|
2743
|
+
if (versionRow.user_version < 12) {
|
|
2744
|
+
const columns = new Set(this.database.prepare("PRAGMA table_info(agents)").all()
|
|
2745
|
+
.map((column) => column.name));
|
|
2746
|
+
if (!columns.has("providers_json")) {
|
|
2747
|
+
this.database.exec("ALTER TABLE agents ADD COLUMN providers_json TEXT");
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
// Schema v13 tied the Monitor idempotency ledger to the conversation
|
|
2751
|
+
// with ON DELETE CASCADE. That erased the tombstone and allowed a
|
|
2752
|
+
// delivery key to name different content after thread deletion.
|
|
2753
|
+
if (versionRow.user_version === 13)
|
|
2754
|
+
this.migrateMonitorWakeDeliveries();
|
|
2755
|
+
if (versionRow.user_version < 15) {
|
|
2756
|
+
const columns = new Set(this.database.prepare("PRAGMA table_info(monitor_wake_deliveries)").all()
|
|
2757
|
+
.map((column) => column.name));
|
|
2758
|
+
if (!columns.has("projection_json")) {
|
|
2759
|
+
this.database.exec("ALTER TABLE monitor_wake_deliveries ADD COLUMN projection_json TEXT");
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2441
2762
|
if (migrating)
|
|
2442
2763
|
this.database.exec(`PRAGMA user_version = ${WEB_STORAGE_SCHEMA_VERSION}; COMMIT`);
|
|
2443
2764
|
}
|
|
@@ -2454,6 +2775,36 @@ export class WebStore {
|
|
|
2454
2775
|
throw new WebConsoleError("storage_corrupt", `Unable to initialize web state: ${error instanceof Error ? error.message : String(error)}`, 500);
|
|
2455
2776
|
}
|
|
2456
2777
|
}
|
|
2778
|
+
/** Preserve Monitor delivery tombstones while making deleted threads threadless. */
|
|
2779
|
+
migrateMonitorWakeDeliveries() {
|
|
2780
|
+
this.database.exec(`
|
|
2781
|
+
CREATE TABLE monitor_wake_deliveries_v14 (
|
|
2782
|
+
source_id TEXT NOT NULL REFERENCES agents(source_id),
|
|
2783
|
+
monitor_id TEXT NOT NULL,
|
|
2784
|
+
delivery_key TEXT NOT NULL,
|
|
2785
|
+
thread_id TEXT REFERENCES threads(id) ON DELETE SET NULL,
|
|
2786
|
+
payload_sha256 TEXT NOT NULL,
|
|
2787
|
+
projection_json TEXT,
|
|
2788
|
+
state TEXT NOT NULL CHECK (state IN ('accepted', 'completed')),
|
|
2789
|
+
disposition TEXT CHECK (disposition IN ('steered', 'follow_up')),
|
|
2790
|
+
turn_id TEXT REFERENCES turns(id) ON DELETE SET NULL,
|
|
2791
|
+
created_at TEXT NOT NULL,
|
|
2792
|
+
completed_at TEXT,
|
|
2793
|
+
PRIMARY KEY (source_id, delivery_key)
|
|
2794
|
+
);
|
|
2795
|
+
INSERT INTO monitor_wake_deliveries_v14 (
|
|
2796
|
+
source_id, monitor_id, delivery_key, thread_id, payload_sha256, projection_json,
|
|
2797
|
+
state, disposition, turn_id, created_at, completed_at
|
|
2798
|
+
) SELECT
|
|
2799
|
+
source_id, monitor_id, delivery_key, thread_id, payload_sha256, NULL,
|
|
2800
|
+
state, disposition, turn_id, created_at, completed_at
|
|
2801
|
+
FROM monitor_wake_deliveries;
|
|
2802
|
+
DROP TABLE monitor_wake_deliveries;
|
|
2803
|
+
ALTER TABLE monitor_wake_deliveries_v14 RENAME TO monitor_wake_deliveries;
|
|
2804
|
+
CREATE INDEX monitor_wake_deliveries_by_thread
|
|
2805
|
+
ON monitor_wake_deliveries(thread_id, created_at);
|
|
2806
|
+
`);
|
|
2807
|
+
}
|
|
2457
2808
|
/**
|
|
2458
2809
|
* Schema-v5 adoption runs after every older fixup inside the same
|
|
2459
2810
|
* BEGIN IMMEDIATE transaction. Each operation is guarded by the resulting
|
|
@@ -2603,6 +2954,8 @@ export class WebStore {
|
|
|
2603
2954
|
"revisions",
|
|
2604
2955
|
"settings",
|
|
2605
2956
|
"notification_deliveries",
|
|
2957
|
+
"process_job_wake_deliveries",
|
|
2958
|
+
"monitor_wake_deliveries",
|
|
2606
2959
|
"cron_channels",
|
|
2607
2960
|
"cron_channel_deletions",
|
|
2608
2961
|
"cron_overviews",
|
|
@@ -2630,6 +2983,21 @@ export class WebStore {
|
|
|
2630
2983
|
throw new WebConsoleError("storage_corrupt", `Message ${message.id} contains invalid persisted parts.`, 500);
|
|
2631
2984
|
}
|
|
2632
2985
|
}
|
|
2986
|
+
const monitorProjections = this.database.prepare(`
|
|
2987
|
+
SELECT monitor_id, projection_json
|
|
2988
|
+
FROM monitor_wake_deliveries
|
|
2989
|
+
WHERE projection_json IS NOT NULL
|
|
2990
|
+
`).all();
|
|
2991
|
+
for (const row of monitorProjections) {
|
|
2992
|
+
try {
|
|
2993
|
+
const projection = parseMonitorProjection(JSON.parse(row.projection_json));
|
|
2994
|
+
if (projection.monitorId !== row.monitor_id)
|
|
2995
|
+
throw new TypeError("Monitor identity mismatch.");
|
|
2996
|
+
}
|
|
2997
|
+
catch {
|
|
2998
|
+
throw new WebConsoleError("storage_corrupt", "A retained Monitor wake projection is invalid.", 500);
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
2633
3001
|
}
|
|
2634
3002
|
/**
|
|
2635
3003
|
* Index the messages the streaming gate skipped and nothing settled — the
|
|
@@ -2687,17 +3055,17 @@ export class WebStore {
|
|
|
2687
3055
|
WHERE status = 'sending'
|
|
2688
3056
|
`).run(now, now);
|
|
2689
3057
|
}
|
|
2690
|
-
finishTurn(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts) {
|
|
3058
|
+
finishTurn(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts, suppressResponsePush = false) {
|
|
2691
3059
|
const turn = this.requireTurn(turnId);
|
|
2692
3060
|
if (turn.status !== "running") {
|
|
2693
3061
|
return this.requireThreadDetail(turn.thread_id);
|
|
2694
3062
|
}
|
|
2695
3063
|
this.transaction(() => {
|
|
2696
|
-
this.finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts);
|
|
3064
|
+
this.finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts, suppressResponsePush);
|
|
2697
3065
|
});
|
|
2698
3066
|
return this.requireThreadDetail(turn.thread_id);
|
|
2699
3067
|
}
|
|
2700
|
-
finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts) {
|
|
3068
|
+
finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts, suppressResponsePush = false) {
|
|
2701
3069
|
const turn = this.requireTurn(turnId);
|
|
2702
3070
|
if (turn.status !== "running")
|
|
2703
3071
|
return;
|
|
@@ -2729,7 +3097,9 @@ export class WebStore {
|
|
|
2729
3097
|
// Projected cron turns are agent-owned scheduler state. Restart recovery
|
|
2730
3098
|
// still settles the local projection, but only web-owned turns may emit a
|
|
2731
3099
|
// Web Push terminal notification from this service.
|
|
2732
|
-
if (thread.trigger?.kind !== "cron"
|
|
3100
|
+
if (thread.trigger?.kind !== "cron"
|
|
3101
|
+
&& recentEnoughForRecoveredInterruption
|
|
3102
|
+
&& !(status === "complete" && suppressResponsePush)) {
|
|
2733
3103
|
const kind = status === "complete"
|
|
2734
3104
|
? "response.ready"
|
|
2735
3105
|
: status === "cancelled"
|
|
@@ -2792,10 +3162,13 @@ export class WebStore {
|
|
|
2792
3162
|
runState,
|
|
2793
3163
|
canSend: row.can_send === 1,
|
|
2794
3164
|
canUpload: row.can_upload === 1,
|
|
3165
|
+
runModel: row.run_model,
|
|
3166
|
+
runEffort: row.run_effort,
|
|
2795
3167
|
};
|
|
2796
3168
|
}
|
|
2797
3169
|
mapMessage(row) {
|
|
2798
|
-
const attachments = this.database
|
|
3170
|
+
const attachments = this.database
|
|
3171
|
+
.prepare("SELECT * FROM attachments WHERE message_id = ? AND origin = 'upload' ORDER BY created_at, id")
|
|
2799
3172
|
.all(row.id);
|
|
2800
3173
|
const storedParts = parseParts(row.parts_json);
|
|
2801
3174
|
const quote = quoteFromParts(storedParts);
|
|
@@ -3084,6 +3457,7 @@ function isValidVapidKeyPair(publicKey, privateKey) {
|
|
|
3084
3457
|
function threadSelectSql(suffix) {
|
|
3085
3458
|
return `
|
|
3086
3459
|
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,
|
|
3460
|
+
t.run_model, t.run_effort,
|
|
3087
3461
|
cc.job_id AS cron_job_id, cc.configured AS cron_configured,
|
|
3088
3462
|
CASE WHEN t.trigger_kind = 'cron' THEN 0
|
|
3089
3463
|
WHEN a.status = 'online' OR a.status = 'degraded' THEN 1 ELSE 0 END AS can_send,
|
|
@@ -3420,6 +3794,7 @@ function mapAgent(row) {
|
|
|
3420
3794
|
const models = parseStringArray(row.models_json);
|
|
3421
3795
|
const efforts = parseStringArray(row.efforts_json);
|
|
3422
3796
|
const modelOptions = parseRecord(row.model_options_json);
|
|
3797
|
+
const providers = parseProviderSummary(row.providers_json);
|
|
3423
3798
|
return {
|
|
3424
3799
|
sourceId: row.source_id,
|
|
3425
3800
|
label: row.label,
|
|
@@ -3432,6 +3807,7 @@ function mapAgent(row) {
|
|
|
3432
3807
|
...(row.default_effort === null ? {} : { defaultEffort: row.default_effort }),
|
|
3433
3808
|
...(efforts === undefined ? {} : { efforts }),
|
|
3434
3809
|
...(modelOptions === undefined ? {} : { modelOptions }),
|
|
3810
|
+
...(providers === undefined ? {} : { providers }),
|
|
3435
3811
|
...(row.cron_read === 1
|
|
3436
3812
|
? { cron: { read: true, actions: row.cron_actions === 1 } }
|
|
3437
3813
|
: {}),
|
|
@@ -3450,6 +3826,7 @@ function mapStoredAttachment(row) {
|
|
|
3450
3826
|
kind: row.kind === "image" ? "image" : "document",
|
|
3451
3827
|
status: row.status === "committed" ? "committed" : "staged",
|
|
3452
3828
|
uploaded: row.uploaded === 1,
|
|
3829
|
+
origin: row.origin === "reply" ? "reply" : "upload",
|
|
3453
3830
|
storageName: row.storage_name,
|
|
3454
3831
|
createdAt: row.created_at,
|
|
3455
3832
|
updatedAt: row.updated_at,
|
|
@@ -3478,12 +3855,26 @@ export function toWebAttachment(attachment) {
|
|
|
3478
3855
|
...(attachment.uploaded ? { contentUrl: `/api/v1/uploads/${encodeURIComponent(attachment.id)}/content` } : {}),
|
|
3479
3856
|
};
|
|
3480
3857
|
}
|
|
3481
|
-
function
|
|
3858
|
+
function appliedMonitorWake(event, resolveMonitorWake) {
|
|
3859
|
+
if (resolveMonitorWake === undefined
|
|
3860
|
+
|| event.metadata?.liveInput !== true
|
|
3861
|
+
|| event.metadata?.synthetic !== true
|
|
3862
|
+
|| typeof event.metadata.inputId !== "string") {
|
|
3863
|
+
return undefined;
|
|
3864
|
+
}
|
|
3865
|
+
const projection = resolveMonitorWake(event.metadata.inputId);
|
|
3866
|
+
return projection === undefined
|
|
3867
|
+
? undefined
|
|
3868
|
+
: { deliveryKey: event.metadata.inputId, projection };
|
|
3869
|
+
}
|
|
3870
|
+
function applyEvent(parts, event, resolveMonitorWake) {
|
|
3482
3871
|
if (event.type === "assistant_thought") {
|
|
3483
3872
|
appendTextPart(parts, "reasoning", event.text);
|
|
3484
3873
|
return;
|
|
3485
3874
|
}
|
|
3486
3875
|
if (event.type === "tool_call_started") {
|
|
3876
|
+
if (appliedMonitorWake(event, resolveMonitorWake) !== undefined)
|
|
3877
|
+
return;
|
|
3487
3878
|
const historyUpdate = canonicalEventHistoryUpdate(event.history);
|
|
3488
3879
|
const subagent = subagentOf(event);
|
|
3489
3880
|
if (subagent !== undefined) {
|
|
@@ -3525,6 +3916,11 @@ function applyEvent(parts, event) {
|
|
|
3525
3916
|
return;
|
|
3526
3917
|
}
|
|
3527
3918
|
if (event.type === "tool_call_completed") {
|
|
3919
|
+
const monitorWake = appliedMonitorWake(event, resolveMonitorWake);
|
|
3920
|
+
if (monitorWake !== undefined) {
|
|
3921
|
+
upsertMonitorActivity(parts, monitorWake.projection, monitorWake.deliveryKey);
|
|
3922
|
+
return;
|
|
3923
|
+
}
|
|
3528
3924
|
const status = event.isError === true ? "failed" : "complete";
|
|
3529
3925
|
const historyUpdate = canonicalEventHistoryUpdate(event.history);
|
|
3530
3926
|
const executionMs = canonicalExecutionMs(event.executionMs);
|
|
@@ -3585,6 +3981,33 @@ function applyEvent(parts, event) {
|
|
|
3585
3981
|
}
|
|
3586
3982
|
parts.push({ type: "telemetry", event: event.type, data: event });
|
|
3587
3983
|
}
|
|
3984
|
+
function upsertMonitorActivity(parts, projection, deliveryKey) {
|
|
3985
|
+
const index = parts.findIndex((part) => part.type === "monitor-activity");
|
|
3986
|
+
const previous = index < 0 ? undefined : parts[index];
|
|
3987
|
+
const monitors = previous?.monitors ?? [];
|
|
3988
|
+
const monitorIndex = monitors.findIndex((entry) => entry.projection.monitorId === projection.monitorId);
|
|
3989
|
+
const prior = monitorIndex < 0 ? undefined : monitors[monitorIndex];
|
|
3990
|
+
// The stream receipt and the durable delivery settlement can race. Once an
|
|
3991
|
+
// exact key is present, settlement is bookkeeping only: replacing its newer
|
|
3992
|
+
// projection with the reservation's older snapshot would make the activity
|
|
3993
|
+
// row move backwards and emit a duplicate invalidation.
|
|
3994
|
+
if (prior?.deliveryKeys.includes(deliveryKey) === true)
|
|
3995
|
+
return false;
|
|
3996
|
+
const deliveryKeys = prior === undefined ? [deliveryKey] : [...prior.deliveryKeys, deliveryKey];
|
|
3997
|
+
const entry = { projection, deliveryKeys };
|
|
3998
|
+
const nextMonitors = monitorIndex < 0
|
|
3999
|
+
? [...monitors, entry]
|
|
4000
|
+
: monitors.map((value, at) => at === monitorIndex ? entry : value);
|
|
4001
|
+
const next = { type: "monitor-activity", monitors: nextMonitors };
|
|
4002
|
+
if (index < 0) {
|
|
4003
|
+
parts.push(next);
|
|
4004
|
+
return true;
|
|
4005
|
+
}
|
|
4006
|
+
if (isDeepStrictEqual(previous, next))
|
|
4007
|
+
return false;
|
|
4008
|
+
parts[index] = next;
|
|
4009
|
+
return true;
|
|
4010
|
+
}
|
|
3588
4011
|
function contextCompactionOperationId(value) {
|
|
3589
4012
|
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
3590
4013
|
return undefined;
|
|
@@ -3740,19 +4163,26 @@ function upsertToolCall(parts, next, historyUpdate) {
|
|
|
3740
4163
|
parts[index] = updated;
|
|
3741
4164
|
}
|
|
3742
4165
|
/**
|
|
3743
|
-
* Whether a stored part
|
|
3744
|
-
*
|
|
3745
|
-
*
|
|
3746
|
-
* prose.
|
|
4166
|
+
* Whether a stored part marks a semantic boundary between streamed text runs.
|
|
4167
|
+
* Most telemetry remains invisible; compaction and the content-free assistant
|
|
4168
|
+
* message marker intentionally keep adjacent provider responses separate.
|
|
3747
4169
|
*/
|
|
3748
|
-
function
|
|
4170
|
+
function separatesStreamedText(part) {
|
|
4171
|
+
// A Monitor acknowledgement may arrive while the provider is still flushing
|
|
4172
|
+
// the preceding message's final text delta. Its compact activity row must not
|
|
4173
|
+
// split that word; the explicit message boundary below separates responses.
|
|
4174
|
+
if (part.type === "monitor-activity")
|
|
4175
|
+
return false;
|
|
3749
4176
|
if (part.type !== "telemetry")
|
|
3750
4177
|
return true;
|
|
3751
4178
|
const event = part.data;
|
|
3752
4179
|
if (event === null || typeof event !== "object" || Array.isArray(event))
|
|
3753
4180
|
return false;
|
|
3754
4181
|
const record = event;
|
|
3755
|
-
return record.type === "runtime_telemetry"
|
|
4182
|
+
return record.type === "runtime_telemetry"
|
|
4183
|
+
&& (record.kind === "context_compaction"
|
|
4184
|
+
|| record.kind === "context_usage"
|
|
4185
|
+
|| record.kind === "assistant_message_boundary");
|
|
3756
4186
|
}
|
|
3757
4187
|
/**
|
|
3758
4188
|
* Append a streamed delta to the trailing run of `type`.
|
|
@@ -3771,7 +4201,7 @@ function appendTextPart(parts, type, delta) {
|
|
|
3771
4201
|
const part = parts[index];
|
|
3772
4202
|
if (part === undefined)
|
|
3773
4203
|
break;
|
|
3774
|
-
if (!
|
|
4204
|
+
if (!separatesStreamedText(part))
|
|
3775
4205
|
continue;
|
|
3776
4206
|
if (part.type !== type)
|
|
3777
4207
|
break;
|
|
@@ -4189,6 +4619,9 @@ function parseParts(value) {
|
|
|
4189
4619
|
if (!Array.isArray(parts) || !parts.every(isWebMessagePart)) {
|
|
4190
4620
|
throw new WebConsoleError("storage_corrupt", "Persisted message parts have an invalid shape.", 500);
|
|
4191
4621
|
}
|
|
4622
|
+
if (parts.filter((part) => part.type === "monitor-activity").length > 1) {
|
|
4623
|
+
throw new WebConsoleError("storage_corrupt", "Persisted Monitor activity is duplicated.", 500);
|
|
4624
|
+
}
|
|
4192
4625
|
quoteFromParts(parts);
|
|
4193
4626
|
return parts;
|
|
4194
4627
|
}
|
|
@@ -4412,6 +4845,50 @@ function isWebMessagePart(value) {
|
|
|
4412
4845
|
return false;
|
|
4413
4846
|
}
|
|
4414
4847
|
}
|
|
4848
|
+
if (part.type === "monitor-activity") {
|
|
4849
|
+
if (!hasOnlyKeys(part, new Set(["type", "monitors"]))
|
|
4850
|
+
|| !Array.isArray(part.monitors)
|
|
4851
|
+
|| part.monitors.length === 0
|
|
4852
|
+
|| part.monitors.length > AGENT_LIVE_INPUT_MAX_MESSAGES) {
|
|
4853
|
+
return false;
|
|
4854
|
+
}
|
|
4855
|
+
const monitorIds = new Set();
|
|
4856
|
+
let deliveryCount = 0;
|
|
4857
|
+
for (const raw of part.monitors) {
|
|
4858
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
4859
|
+
return false;
|
|
4860
|
+
const entry = raw;
|
|
4861
|
+
if (!hasOnlyKeys(entry, new Set(["projection", "deliveryKeys"])) || !Array.isArray(entry.deliveryKeys)) {
|
|
4862
|
+
return false;
|
|
4863
|
+
}
|
|
4864
|
+
let projection;
|
|
4865
|
+
try {
|
|
4866
|
+
projection = parseMonitorProjection(entry.projection);
|
|
4867
|
+
}
|
|
4868
|
+
catch {
|
|
4869
|
+
return false;
|
|
4870
|
+
}
|
|
4871
|
+
if (monitorIds.has(projection.monitorId)
|
|
4872
|
+
|| entry.deliveryKeys.length === 0
|
|
4873
|
+
|| entry.deliveryKeys.length > AGENT_LIVE_INPUT_MAX_MESSAGES) {
|
|
4874
|
+
return false;
|
|
4875
|
+
}
|
|
4876
|
+
monitorIds.add(projection.monitorId);
|
|
4877
|
+
const keys = new Set();
|
|
4878
|
+
for (const key of entry.deliveryKeys) {
|
|
4879
|
+
if (typeof key !== "string"
|
|
4880
|
+
|| key.length === 0
|
|
4881
|
+
|| key.length > 1_024
|
|
4882
|
+
|| /[\u0000-\u001f\u007f]/u.test(key)
|
|
4883
|
+
|| keys.has(key)) {
|
|
4884
|
+
return false;
|
|
4885
|
+
}
|
|
4886
|
+
keys.add(key);
|
|
4887
|
+
}
|
|
4888
|
+
deliveryCount += entry.deliveryKeys.length;
|
|
4889
|
+
}
|
|
4890
|
+
return deliveryCount <= AGENT_LIVE_INPUT_MAX_MESSAGES;
|
|
4891
|
+
}
|
|
4415
4892
|
if (part.type === "telemetry")
|
|
4416
4893
|
return typeof part.event === "string";
|
|
4417
4894
|
if (part.type === "error")
|
|
@@ -4534,6 +5011,38 @@ function parseStringArray(value) {
|
|
|
4534
5011
|
return undefined;
|
|
4535
5012
|
}
|
|
4536
5013
|
}
|
|
5014
|
+
/**
|
|
5015
|
+
* Malformed stored JSON must degrade to "this agent advertises no providers",
|
|
5016
|
+
* never throw: `mapAgent` runs on every bootstrap and discovery read, so a
|
|
5017
|
+
* throw here would take the whole console down over one bad row.
|
|
5018
|
+
*/
|
|
5019
|
+
function parseProviderSummary(value) {
|
|
5020
|
+
if (value === null)
|
|
5021
|
+
return undefined;
|
|
5022
|
+
let parsed;
|
|
5023
|
+
try {
|
|
5024
|
+
parsed = JSON.parse(value);
|
|
5025
|
+
}
|
|
5026
|
+
catch {
|
|
5027
|
+
return undefined;
|
|
5028
|
+
}
|
|
5029
|
+
if (!Array.isArray(parsed))
|
|
5030
|
+
return undefined;
|
|
5031
|
+
const result = [];
|
|
5032
|
+
for (const raw of parsed) {
|
|
5033
|
+
if (typeof raw !== "object" || raw === null)
|
|
5034
|
+
continue;
|
|
5035
|
+
const entry = raw;
|
|
5036
|
+
if (typeof entry.id !== "string" || entry.id.length === 0)
|
|
5037
|
+
continue;
|
|
5038
|
+
result.push({
|
|
5039
|
+
id: entry.id,
|
|
5040
|
+
label: typeof entry.label === "string" && entry.label.length > 0 ? entry.label : entry.id,
|
|
5041
|
+
...(entry.configured === true ? { configured: true } : {}),
|
|
5042
|
+
});
|
|
5043
|
+
}
|
|
5044
|
+
return result.length === 0 ? undefined : result;
|
|
5045
|
+
}
|
|
4537
5046
|
function parseRecord(value) {
|
|
4538
5047
|
if (value === null)
|
|
4539
5048
|
return undefined;
|