@mono-agent/web 0.13.0 → 0.15.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 +136 -12
- package/dist/contracts.d.ts +14 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +2 -0
- package/dist/contracts.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/notification-client.d.ts +19 -0
- package/dist/notification-client.d.ts.map +1 -0
- package/dist/notification-client.js +136 -0
- package/dist/notification-client.js.map +1 -0
- package/dist/notification-ingress.d.ts +17 -0
- package/dist/notification-ingress.d.ts.map +1 -0
- package/dist/notification-ingress.js +170 -0
- package/dist/notification-ingress.js.map +1 -0
- package/dist/operator-client.d.ts +19 -1
- package/dist/operator-client.d.ts.map +1 -1
- package/dist/operator-client.js +58 -0
- package/dist/operator-client.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +50 -18
- package/dist/server.js.map +1 -1
- package/dist/service.d.ts +24 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +203 -7
- package/dist/service.js.map +1 -1
- package/dist/state-paths.d.ts +1 -0
- package/dist/state-paths.d.ts.map +1 -1
- package/dist/state-paths.js +12 -0
- package/dist/state-paths.js.map +1 -1
- package/dist/store.d.ts +47 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +496 -11
- package/dist/store.js.map +1 -1
- package/package.json +5 -5
- package/webapp/dist/assets/index-D3Tzt8AW.css +1 -0
- package/webapp/dist/assets/index-DlEfCKkw.js +51 -0
- package/webapp/dist/index.html +2 -2
- package/webapp/dist/sw.js +1 -1
- package/webapp/dist/assets/index-1UedhLBk.css +0 -1
- package/webapp/dist/assets/index-D-X2B4kU.js +0 -47
package/dist/store.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { chmod, lstat, readdir, unlink } from "node:fs/promises";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { DatabaseSync } from "node:sqlite";
|
|
5
|
-
import {
|
|
5
|
+
import { AGENT_LIVE_INPUT_MAX_CHARACTERS, } from "@mono-agent/agent-contracts";
|
|
6
|
+
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";
|
|
6
7
|
import { WebConsoleError } from "./errors.js";
|
|
7
8
|
import { prepareWebStatePaths } from "./state-paths.js";
|
|
8
9
|
export class WebStore {
|
|
@@ -38,6 +39,7 @@ export class WebStore {
|
|
|
38
39
|
chmod(`${paths.database}-shm`, 0o600).catch(ignoreMissing),
|
|
39
40
|
]);
|
|
40
41
|
store.recoverInterruptedTurns();
|
|
42
|
+
store.recoverLiveInputs();
|
|
41
43
|
return store;
|
|
42
44
|
}
|
|
43
45
|
catch (error) {
|
|
@@ -98,6 +100,89 @@ export class WebStore {
|
|
|
98
100
|
throw new WebConsoleError("agent_not_found", "The selected agent is no longer available.", 404);
|
|
99
101
|
return agent;
|
|
100
102
|
}
|
|
103
|
+
reserveNotification(input) {
|
|
104
|
+
if (this.getAgent(input.sourceId) === undefined) {
|
|
105
|
+
throw new WebConsoleError("agent_not_found", "The notification agent is no longer available.", 404);
|
|
106
|
+
}
|
|
107
|
+
if (input.deliveryKey.length === 0 || input.deliveryKey.length > 1_024) {
|
|
108
|
+
throw new WebConsoleError("invalid_notification", "Notification deliveryKey must contain 1 to 1024 characters.", 400);
|
|
109
|
+
}
|
|
110
|
+
if (input.text.trim().length === 0) {
|
|
111
|
+
throw new WebConsoleError("invalid_notification", "Notification text cannot be empty.", 400);
|
|
112
|
+
}
|
|
113
|
+
const threadId = notificationThreadId(input.sourceId, input.deliveryKey);
|
|
114
|
+
const payloadSha256 = notificationPayloadSha256(input.triggerKind, input.text);
|
|
115
|
+
const existing = this.database.prepare(`
|
|
116
|
+
SELECT * FROM notification_deliveries WHERE source_id = ? AND delivery_key = ?
|
|
117
|
+
`).get(input.sourceId, input.deliveryKey);
|
|
118
|
+
if (existing !== undefined) {
|
|
119
|
+
if (existing.thread_id !== threadId
|
|
120
|
+
|| existing.trigger_kind !== input.triggerKind
|
|
121
|
+
|| existing.payload_sha256 !== payloadSha256) {
|
|
122
|
+
throw new WebConsoleError("notification_idempotency_conflict", "The notification delivery key was already used with different content.", 409);
|
|
123
|
+
}
|
|
124
|
+
if (existing.completed_at !== null && this.getThread(existing.thread_id) === undefined) {
|
|
125
|
+
throw new WebConsoleError("storage_corrupt", "A completed notification is missing its conversation.", 500);
|
|
126
|
+
}
|
|
127
|
+
return { ...input, threadId, payloadSha256, duplicate: existing.completed_at !== null };
|
|
128
|
+
}
|
|
129
|
+
const now = this.now();
|
|
130
|
+
this.database.prepare(`
|
|
131
|
+
INSERT INTO notification_deliveries (
|
|
132
|
+
source_id, delivery_key, thread_id, trigger_kind, payload_sha256, created_at, completed_at
|
|
133
|
+
) VALUES (?, ?, ?, ?, ?, ?, NULL)
|
|
134
|
+
`).run(input.sourceId, input.deliveryKey, threadId, input.triggerKind, payloadSha256, now);
|
|
135
|
+
return { ...input, threadId, payloadSha256, duplicate: false };
|
|
136
|
+
}
|
|
137
|
+
completeNotification(reservation) {
|
|
138
|
+
const existing = this.database.prepare(`
|
|
139
|
+
SELECT * FROM notification_deliveries WHERE source_id = ? AND delivery_key = ?
|
|
140
|
+
`).get(reservation.sourceId, reservation.deliveryKey);
|
|
141
|
+
if (existing === undefined
|
|
142
|
+
|| existing.thread_id !== reservation.threadId
|
|
143
|
+
|| existing.trigger_kind !== reservation.triggerKind
|
|
144
|
+
|| existing.payload_sha256 !== reservation.payloadSha256) {
|
|
145
|
+
throw new WebConsoleError("notification_reservation_lost", "The notification reservation is no longer valid.", 409);
|
|
146
|
+
}
|
|
147
|
+
if (existing.completed_at !== null) {
|
|
148
|
+
return { thread: this.requireThread(existing.thread_id), duplicate: true };
|
|
149
|
+
}
|
|
150
|
+
const now = this.now();
|
|
151
|
+
const turnId = randomUUID();
|
|
152
|
+
const assistantMessageId = randomUUID();
|
|
153
|
+
const title = reservation.triggerKind === "cron" ? "Cron notification" : "Webhook notification";
|
|
154
|
+
this.transaction(() => {
|
|
155
|
+
if (this.getThread(reservation.threadId) !== undefined) {
|
|
156
|
+
throw new WebConsoleError("notification_idempotency_conflict", "The notification conversation already exists.", 409);
|
|
157
|
+
}
|
|
158
|
+
this.database.prepare(`
|
|
159
|
+
INSERT INTO threads (
|
|
160
|
+
id, source_id, conversation_id, title, title_manual, trigger_kind, archived_at,
|
|
161
|
+
created_at, updated_at, revision
|
|
162
|
+
) VALUES (?, ?, ?, ?, 0, ?, NULL, ?, ?, 1)
|
|
163
|
+
`).run(reservation.threadId, reservation.sourceId, `web:${reservation.threadId}`, title, reservation.triggerKind, now, now);
|
|
164
|
+
this.database.prepare(`
|
|
165
|
+
INSERT INTO turns (
|
|
166
|
+
id, thread_id, status, text, model, effort, assistant_message_id,
|
|
167
|
+
started_at, finished_at, error_code, error_message
|
|
168
|
+
) VALUES (?, ?, 'complete', '', NULL, NULL, ?, ?, ?, NULL, NULL)
|
|
169
|
+
`).run(turnId, reservation.threadId, assistantMessageId, now, now);
|
|
170
|
+
this.database.prepare(`
|
|
171
|
+
INSERT INTO messages (
|
|
172
|
+
id, thread_id, turn_id, role, parts_json, created_at, updated_at, status
|
|
173
|
+
) VALUES (?, ?, ?, 'assistant', ?, ?, ?, 'complete')
|
|
174
|
+
`).run(assistantMessageId, reservation.threadId, turnId, JSON.stringify([{ type: "text", text: reservation.text }]), now, now);
|
|
175
|
+
this.database.prepare(`
|
|
176
|
+
INSERT INTO revisions (entity_kind, entity_id, revision, event, created_at)
|
|
177
|
+
VALUES ('thread', ?, 1, 'notification_created', ?)
|
|
178
|
+
`).run(reservation.threadId, now);
|
|
179
|
+
this.database.prepare(`
|
|
180
|
+
UPDATE notification_deliveries SET completed_at = ?
|
|
181
|
+
WHERE source_id = ? AND delivery_key = ? AND completed_at IS NULL
|
|
182
|
+
`).run(now, reservation.sourceId, reservation.deliveryKey);
|
|
183
|
+
});
|
|
184
|
+
return { thread: this.requireThread(reservation.threadId), duplicate: false };
|
|
185
|
+
}
|
|
101
186
|
createThread(sourceId) {
|
|
102
187
|
const agent = this.getAgent(sourceId);
|
|
103
188
|
if (agent === undefined) {
|
|
@@ -130,7 +215,17 @@ export class WebStore {
|
|
|
130
215
|
const thread = this.getThread(id);
|
|
131
216
|
if (thread === undefined)
|
|
132
217
|
return undefined;
|
|
133
|
-
const rows = this.database.prepare(
|
|
218
|
+
const rows = this.database.prepare(`
|
|
219
|
+
SELECT m.* FROM messages m
|
|
220
|
+
LEFT JOIN turns t ON t.id = m.turn_id
|
|
221
|
+
WHERE m.thread_id = ?
|
|
222
|
+
ORDER BY COALESCE(t.started_at, m.created_at),
|
|
223
|
+
CASE WHEN m.turn_id IS NOT NULL AND m.role = 'user' THEN 0
|
|
224
|
+
WHEN m.turn_id IS NOT NULL AND m.role = 'system' THEN 1
|
|
225
|
+
WHEN m.turn_id IS NOT NULL THEN 2
|
|
226
|
+
ELSE 3 END,
|
|
227
|
+
m.created_at, m.rowid
|
|
228
|
+
`).all(id);
|
|
134
229
|
return { thread, messages: rows.map((row) => this.mapMessage(row)) };
|
|
135
230
|
}
|
|
136
231
|
currentThreadId() {
|
|
@@ -168,6 +263,28 @@ export class WebStore {
|
|
|
168
263
|
});
|
|
169
264
|
return { ...this.requireThread(id), sourceId: current.sourceId };
|
|
170
265
|
}
|
|
266
|
+
async deleteArchivedThread(id) {
|
|
267
|
+
const thread = this.requireThread(id);
|
|
268
|
+
if (thread.archivedAt === null) {
|
|
269
|
+
throw new WebConsoleError("thread_not_archived", "Archive the conversation before deleting it.", 409);
|
|
270
|
+
}
|
|
271
|
+
const attachments = this.database.prepare("SELECT * FROM attachments WHERE thread_id = ?")
|
|
272
|
+
.all(id);
|
|
273
|
+
this.transaction(() => {
|
|
274
|
+
this.database.prepare("DELETE FROM notification_deliveries WHERE thread_id = ?").run(id);
|
|
275
|
+
this.database.prepare("DELETE FROM revisions WHERE entity_kind = 'thread' AND entity_id = ?").run(id);
|
|
276
|
+
this.database.prepare("DELETE FROM threads WHERE id = ?").run(id);
|
|
277
|
+
this.database.prepare("DELETE FROM settings WHERE key = 'current_thread_id' AND value = ?").run(id);
|
|
278
|
+
});
|
|
279
|
+
let orphanedFiles = 0;
|
|
280
|
+
for (const row of attachments) {
|
|
281
|
+
await unlink(this.attachmentPath(mapStoredAttachment(row))).catch((error) => {
|
|
282
|
+
if (error.code !== "ENOENT")
|
|
283
|
+
orphanedFiles += 1;
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return { orphanedFiles };
|
|
287
|
+
}
|
|
171
288
|
createUpload(input) {
|
|
172
289
|
const id = randomUUID();
|
|
173
290
|
const now = this.now();
|
|
@@ -252,6 +369,23 @@ export class WebStore {
|
|
|
252
369
|
}
|
|
253
370
|
return removed;
|
|
254
371
|
}
|
|
372
|
+
async purgeUnreferencedAttachmentFiles() {
|
|
373
|
+
const referenced = new Set(this.database.prepare("SELECT storage_name FROM attachments").all()
|
|
374
|
+
.map((row) => row.storage_name));
|
|
375
|
+
const entries = await readdir(this.paths.uploads, { withFileTypes: true });
|
|
376
|
+
let removed = 0;
|
|
377
|
+
for (const entry of entries) {
|
|
378
|
+
if (!/^[0-9a-f-]{36}\.bin$/iu.test(entry.name) || referenced.has(entry.name))
|
|
379
|
+
continue;
|
|
380
|
+
const path = resolve(this.paths.uploads, entry.name);
|
|
381
|
+
const info = await lstat(path);
|
|
382
|
+
if (!info.isFile() || info.isSymbolicLink())
|
|
383
|
+
continue;
|
|
384
|
+
await unlink(path);
|
|
385
|
+
removed += 1;
|
|
386
|
+
}
|
|
387
|
+
return removed;
|
|
388
|
+
}
|
|
255
389
|
beginTurn(input) {
|
|
256
390
|
const thread = this.requireThread(input.threadId);
|
|
257
391
|
if (thread.archivedAt !== null) {
|
|
@@ -347,6 +481,181 @@ export class WebStore {
|
|
|
347
481
|
thread: this.requireThread(input.threadId),
|
|
348
482
|
};
|
|
349
483
|
}
|
|
484
|
+
reserveLiveInput(threadId, text) {
|
|
485
|
+
const thread = this.requireThread(threadId);
|
|
486
|
+
if (thread.archivedAt !== null) {
|
|
487
|
+
throw new WebConsoleError("thread_archived", "Unarchive this conversation before sending another message.", 409);
|
|
488
|
+
}
|
|
489
|
+
if (!thread.canSend) {
|
|
490
|
+
throw new WebConsoleError("agent_offline", "This agent is offline. The conversation remains available read-only.", 409);
|
|
491
|
+
}
|
|
492
|
+
if (text.trim().length === 0) {
|
|
493
|
+
throw new WebConsoleError("empty_turn", "Enter a message.", 400);
|
|
494
|
+
}
|
|
495
|
+
if (text.length > AGENT_LIVE_INPUT_MAX_CHARACTERS) {
|
|
496
|
+
throw new WebConsoleError("turn_text_too_large", `A live follow-up may contain at most ${AGENT_LIVE_INPUT_MAX_CHARACTERS} characters.`, 413);
|
|
497
|
+
}
|
|
498
|
+
const usage = this.database.prepare("SELECT COUNT(*) AS count FROM live_inputs WHERE thread_id = ?").get(threadId);
|
|
499
|
+
if (usage.count >= WEB_MAX_LIVE_INPUTS_PER_THREAD) {
|
|
500
|
+
throw new WebConsoleError("live_input_queue_full", "Too many follow-up messages are waiting.", 429);
|
|
501
|
+
}
|
|
502
|
+
const active = this.database.prepare("SELECT id, model, effort FROM turns WHERE thread_id = ? AND status = 'running'").get(threadId);
|
|
503
|
+
const id = randomUUID();
|
|
504
|
+
const messageId = randomUUID();
|
|
505
|
+
const now = this.now();
|
|
506
|
+
const status = active === undefined ? "queued" : "offered";
|
|
507
|
+
const parts = [
|
|
508
|
+
liveInputTelemetry(status === "offered" ? "pending" : "queued"),
|
|
509
|
+
{ type: "text", text },
|
|
510
|
+
];
|
|
511
|
+
this.transaction(() => {
|
|
512
|
+
this.database.prepare(`
|
|
513
|
+
INSERT INTO messages (id, thread_id, turn_id, role, parts_json, created_at, updated_at, status)
|
|
514
|
+
VALUES (?, ?, ?, 'user', ?, ?, ?, 'complete')
|
|
515
|
+
`).run(messageId, threadId, active?.id ?? null, JSON.stringify(parts), now, now);
|
|
516
|
+
this.database.prepare(`
|
|
517
|
+
INSERT INTO live_inputs (
|
|
518
|
+
id, thread_id, message_id, active_turn_id, text, model, effort, status, created_at, updated_at
|
|
519
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
520
|
+
`).run(id, threadId, messageId, active?.id ?? null, text, active?.model ?? null, active?.effort ?? null, status, now, now);
|
|
521
|
+
const title = deriveAutomaticTitle(text, []);
|
|
522
|
+
this.database.prepare(`
|
|
523
|
+
UPDATE threads
|
|
524
|
+
SET title = CASE WHEN title_manual = 0 AND title = 'New conversation' THEN ? ELSE title END,
|
|
525
|
+
updated_at = ?, revision = revision + 1
|
|
526
|
+
WHERE id = ?
|
|
527
|
+
`).run(title, now, threadId);
|
|
528
|
+
this.recordThreadRevision(threadId, "live_input_received", now);
|
|
529
|
+
this.setSetting("current_thread_id", threadId);
|
|
530
|
+
});
|
|
531
|
+
const stored = this.requireLiveInput(id);
|
|
532
|
+
return {
|
|
533
|
+
input: mapLiveInput(stored),
|
|
534
|
+
message: this.requireMessage(messageId),
|
|
535
|
+
thread: this.requireThread(threadId),
|
|
536
|
+
offered: status === "offered",
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
markLiveInputApplied(id) {
|
|
540
|
+
const row = this.getLiveInput(id);
|
|
541
|
+
if (row === undefined)
|
|
542
|
+
return undefined;
|
|
543
|
+
const message = this.requireMessage(row.message_id);
|
|
544
|
+
const now = this.now();
|
|
545
|
+
this.transaction(() => {
|
|
546
|
+
this.database.prepare("UPDATE messages SET parts_json = ?, updated_at = ? WHERE id = ?")
|
|
547
|
+
.run(JSON.stringify(withLiveInputStatus(message.parts, "applied")), now, row.message_id);
|
|
548
|
+
this.database.prepare("DELETE FROM live_inputs WHERE id = ?").run(id);
|
|
549
|
+
this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
|
|
550
|
+
.run(now, row.thread_id);
|
|
551
|
+
this.recordThreadRevision(row.thread_id, "live_input_applied", now);
|
|
552
|
+
});
|
|
553
|
+
return this.requireMessage(row.message_id);
|
|
554
|
+
}
|
|
555
|
+
queueLiveInput(id) {
|
|
556
|
+
const row = this.getLiveInput(id);
|
|
557
|
+
if (row === undefined)
|
|
558
|
+
return undefined;
|
|
559
|
+
const message = this.requireMessage(row.message_id);
|
|
560
|
+
const now = this.now();
|
|
561
|
+
this.transaction(() => {
|
|
562
|
+
this.database.prepare(`
|
|
563
|
+
UPDATE live_inputs SET status = 'queued', active_turn_id = NULL, updated_at = ? WHERE id = ?
|
|
564
|
+
`).run(now, id);
|
|
565
|
+
this.database.prepare("UPDATE messages SET turn_id = NULL, parts_json = ?, updated_at = ? WHERE id = ?")
|
|
566
|
+
.run(JSON.stringify(withLiveInputStatus(message.parts, "queued")), now, row.message_id);
|
|
567
|
+
this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
|
|
568
|
+
.run(now, row.thread_id);
|
|
569
|
+
this.recordThreadRevision(row.thread_id, "live_input_queued", now);
|
|
570
|
+
});
|
|
571
|
+
return this.requireMessage(row.message_id);
|
|
572
|
+
}
|
|
573
|
+
cancelLiveInput(id) {
|
|
574
|
+
const row = this.getLiveInput(id);
|
|
575
|
+
if (row === undefined)
|
|
576
|
+
return undefined;
|
|
577
|
+
const message = this.requireMessage(row.message_id);
|
|
578
|
+
const now = this.now();
|
|
579
|
+
this.transaction(() => {
|
|
580
|
+
this.database.prepare("UPDATE messages SET turn_id = NULL, parts_json = ?, updated_at = ? WHERE id = ?")
|
|
581
|
+
.run(JSON.stringify(withLiveInputStatus(message.parts, "cancelled")), now, row.message_id);
|
|
582
|
+
this.database.prepare("DELETE FROM live_inputs WHERE id = ?").run(id);
|
|
583
|
+
this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
|
|
584
|
+
.run(now, row.thread_id);
|
|
585
|
+
this.recordThreadRevision(row.thread_id, "live_input_cancelled", now);
|
|
586
|
+
});
|
|
587
|
+
return this.requireMessage(row.message_id);
|
|
588
|
+
}
|
|
589
|
+
cancelLiveInputs(threadId) {
|
|
590
|
+
const rows = this.database.prepare("SELECT * FROM live_inputs WHERE thread_id = ? ORDER BY created_at, rowid").all(threadId);
|
|
591
|
+
if (rows.length === 0)
|
|
592
|
+
return [];
|
|
593
|
+
const now = this.now();
|
|
594
|
+
this.transaction(() => {
|
|
595
|
+
const update = this.database.prepare("UPDATE messages SET turn_id = NULL, parts_json = ?, updated_at = ? WHERE id = ?");
|
|
596
|
+
for (const row of rows) {
|
|
597
|
+
const message = this.requireMessage(row.message_id);
|
|
598
|
+
update.run(JSON.stringify(withLiveInputStatus(message.parts, "cancelled")), now, row.message_id);
|
|
599
|
+
}
|
|
600
|
+
this.database.prepare("DELETE FROM live_inputs WHERE thread_id = ?").run(threadId);
|
|
601
|
+
this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
|
|
602
|
+
.run(now, threadId);
|
|
603
|
+
this.recordThreadRevision(threadId, "live_inputs_cancelled", now);
|
|
604
|
+
});
|
|
605
|
+
return rows.map((row) => this.requireMessage(row.message_id));
|
|
606
|
+
}
|
|
607
|
+
queuedLiveInputThreadIds() {
|
|
608
|
+
return this.database.prepare(`
|
|
609
|
+
SELECT thread_id FROM live_inputs WHERE status = 'queued'
|
|
610
|
+
GROUP BY thread_id ORDER BY MIN(created_at), thread_id
|
|
611
|
+
`).all().map((row) => row.thread_id);
|
|
612
|
+
}
|
|
613
|
+
promoteNextQueuedLiveInput(threadId) {
|
|
614
|
+
const active = this.database.prepare("SELECT id FROM turns WHERE thread_id = ? AND status = 'running'").get(threadId);
|
|
615
|
+
if (active !== undefined)
|
|
616
|
+
return undefined;
|
|
617
|
+
const row = this.database.prepare(`
|
|
618
|
+
SELECT * FROM live_inputs
|
|
619
|
+
WHERE thread_id = ? AND status = 'queued'
|
|
620
|
+
ORDER BY created_at, rowid LIMIT 1
|
|
621
|
+
`).get(threadId);
|
|
622
|
+
if (row === undefined)
|
|
623
|
+
return undefined;
|
|
624
|
+
const thread = this.requireThread(threadId);
|
|
625
|
+
if (!thread.canSend || thread.archivedAt !== null)
|
|
626
|
+
return undefined;
|
|
627
|
+
const turnId = randomUUID();
|
|
628
|
+
const assistantMessageId = randomUUID();
|
|
629
|
+
const now = this.now();
|
|
630
|
+
const userMessage = this.requireMessage(row.message_id);
|
|
631
|
+
this.transaction(() => {
|
|
632
|
+
this.database.prepare(`
|
|
633
|
+
INSERT INTO turns (
|
|
634
|
+
id, thread_id, status, text, model, effort, assistant_message_id,
|
|
635
|
+
started_at, finished_at, error_code, error_message
|
|
636
|
+
) VALUES (?, ?, 'running', ?, ?, ?, ?, ?, NULL, NULL, NULL)
|
|
637
|
+
`).run(turnId, threadId, row.text, row.model, row.effort, assistantMessageId, now);
|
|
638
|
+
this.database.prepare("UPDATE messages SET turn_id = ?, parts_json = ?, updated_at = ? WHERE id = ?")
|
|
639
|
+
.run(turnId, JSON.stringify(withoutLiveInputTelemetry(userMessage.parts)), now, row.message_id);
|
|
640
|
+
this.database.prepare(`
|
|
641
|
+
INSERT INTO messages (id, thread_id, turn_id, role, parts_json, created_at, updated_at, status)
|
|
642
|
+
VALUES (?, ?, ?, 'assistant', '[]', ?, ?, 'running')
|
|
643
|
+
`).run(assistantMessageId, threadId, turnId, now, now);
|
|
644
|
+
this.database.prepare("DELETE FROM live_inputs WHERE id = ?").run(row.id);
|
|
645
|
+
this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
|
|
646
|
+
.run(now, threadId);
|
|
647
|
+
this.recordThreadRevision(threadId, "turn_started", now);
|
|
648
|
+
});
|
|
649
|
+
return {
|
|
650
|
+
turnId,
|
|
651
|
+
conversationId: `web:${threadId}`,
|
|
652
|
+
text: row.text,
|
|
653
|
+
userMessageId: row.message_id,
|
|
654
|
+
assistantMessageId,
|
|
655
|
+
attachments: [],
|
|
656
|
+
thread: this.requireThread(threadId),
|
|
657
|
+
};
|
|
658
|
+
}
|
|
350
659
|
applyStreamFrame(turnId, frame) {
|
|
351
660
|
return this.applyStreamFrames(turnId, [frame]);
|
|
352
661
|
}
|
|
@@ -419,13 +728,17 @@ export class WebStore {
|
|
|
419
728
|
try {
|
|
420
729
|
this.database.exec("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;");
|
|
421
730
|
const versionRow = this.database.prepare("PRAGMA user_version").get();
|
|
422
|
-
if (versionRow.user_version >
|
|
423
|
-
throw new WebConsoleError("unsupported_storage_schema", `Web state schema ${versionRow.user_version} is newer than supported schema
|
|
731
|
+
if (versionRow.user_version > 3) {
|
|
732
|
+
throw new WebConsoleError("unsupported_storage_schema", `Web state schema ${versionRow.user_version} is newer than supported schema 3.`, 500);
|
|
424
733
|
}
|
|
425
734
|
if (versionRow.user_version < 0) {
|
|
426
735
|
throw new WebConsoleError("storage_corrupt", "Web state schema version is invalid.", 500);
|
|
427
736
|
}
|
|
428
|
-
|
|
737
|
+
const migrating = versionRow.user_version < 3;
|
|
738
|
+
if (migrating)
|
|
739
|
+
this.database.exec("BEGIN IMMEDIATE");
|
|
740
|
+
try {
|
|
741
|
+
this.database.exec(`
|
|
429
742
|
CREATE TABLE IF NOT EXISTS agents (
|
|
430
743
|
source_id TEXT PRIMARY KEY,
|
|
431
744
|
label TEXT NOT NULL,
|
|
@@ -445,6 +758,7 @@ export class WebStore {
|
|
|
445
758
|
conversation_id TEXT NOT NULL UNIQUE,
|
|
446
759
|
title TEXT NOT NULL,
|
|
447
760
|
title_manual INTEGER NOT NULL DEFAULT 0,
|
|
761
|
+
trigger_kind TEXT CHECK (trigger_kind IN ('cron', 'webhook')),
|
|
448
762
|
archived_at TEXT,
|
|
449
763
|
created_at TEXT NOT NULL,
|
|
450
764
|
updated_at TEXT NOT NULL,
|
|
@@ -476,6 +790,20 @@ export class WebStore {
|
|
|
476
790
|
status TEXT NOT NULL
|
|
477
791
|
);
|
|
478
792
|
CREATE INDEX IF NOT EXISTS messages_by_thread ON messages(thread_id, created_at);
|
|
793
|
+
CREATE TABLE IF NOT EXISTS live_inputs (
|
|
794
|
+
id TEXT PRIMARY KEY,
|
|
795
|
+
thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
|
796
|
+
message_id TEXT NOT NULL UNIQUE REFERENCES messages(id) ON DELETE CASCADE,
|
|
797
|
+
active_turn_id TEXT REFERENCES turns(id) ON DELETE SET NULL,
|
|
798
|
+
text TEXT NOT NULL,
|
|
799
|
+
model TEXT,
|
|
800
|
+
effort TEXT,
|
|
801
|
+
status TEXT NOT NULL CHECK (status IN ('offered', 'queued')),
|
|
802
|
+
created_at TEXT NOT NULL,
|
|
803
|
+
updated_at TEXT NOT NULL
|
|
804
|
+
);
|
|
805
|
+
CREATE INDEX IF NOT EXISTS live_inputs_by_thread
|
|
806
|
+
ON live_inputs(thread_id, status, created_at);
|
|
479
807
|
CREATE TABLE IF NOT EXISTS attachments (
|
|
480
808
|
id TEXT PRIMARY KEY,
|
|
481
809
|
thread_id TEXT REFERENCES threads(id) ON DELETE CASCADE,
|
|
@@ -504,9 +832,31 @@ export class WebStore {
|
|
|
504
832
|
key TEXT PRIMARY KEY,
|
|
505
833
|
value TEXT NOT NULL
|
|
506
834
|
);
|
|
835
|
+
CREATE TABLE IF NOT EXISTS notification_deliveries (
|
|
836
|
+
source_id TEXT NOT NULL REFERENCES agents(source_id),
|
|
837
|
+
delivery_key TEXT NOT NULL,
|
|
838
|
+
thread_id TEXT NOT NULL UNIQUE,
|
|
839
|
+
trigger_kind TEXT NOT NULL CHECK (trigger_kind IN ('cron', 'webhook')),
|
|
840
|
+
payload_sha256 TEXT NOT NULL,
|
|
841
|
+
created_at TEXT NOT NULL,
|
|
842
|
+
completed_at TEXT,
|
|
843
|
+
PRIMARY KEY (source_id, delivery_key)
|
|
844
|
+
);
|
|
507
845
|
`);
|
|
508
|
-
|
|
509
|
-
|
|
846
|
+
if (versionRow.user_version === 1) {
|
|
847
|
+
const columns = this.database.prepare("PRAGMA table_info(threads)").all();
|
|
848
|
+
if (!columns.some((column) => column.name === "trigger_kind")) {
|
|
849
|
+
this.database.exec("ALTER TABLE threads ADD COLUMN trigger_kind TEXT CHECK (trigger_kind IN ('cron', 'webhook'))");
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
if (migrating)
|
|
853
|
+
this.database.exec("PRAGMA user_version = 3; COMMIT");
|
|
854
|
+
}
|
|
855
|
+
catch (error) {
|
|
856
|
+
if (this.database.isTransaction)
|
|
857
|
+
this.database.exec("ROLLBACK");
|
|
858
|
+
throw error;
|
|
859
|
+
}
|
|
510
860
|
this.validateStorage();
|
|
511
861
|
}
|
|
512
862
|
catch (error) {
|
|
@@ -520,7 +870,17 @@ export class WebStore {
|
|
|
520
870
|
if (check === undefined || !Object.values(check).includes("ok")) {
|
|
521
871
|
throw new WebConsoleError("storage_corrupt", "Web state failed SQLite integrity validation.", 500);
|
|
522
872
|
}
|
|
523
|
-
const requiredTables = new Set([
|
|
873
|
+
const requiredTables = new Set([
|
|
874
|
+
"agents",
|
|
875
|
+
"threads",
|
|
876
|
+
"turns",
|
|
877
|
+
"messages",
|
|
878
|
+
"live_inputs",
|
|
879
|
+
"attachments",
|
|
880
|
+
"revisions",
|
|
881
|
+
"settings",
|
|
882
|
+
"notification_deliveries",
|
|
883
|
+
]);
|
|
524
884
|
const tables = this.database.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all();
|
|
525
885
|
for (const table of tables)
|
|
526
886
|
requiredTables.delete(table.name);
|
|
@@ -543,6 +903,33 @@ export class WebStore {
|
|
|
543
903
|
this.interruptTurn(turnId, "The web service restarted before this turn completed.");
|
|
544
904
|
}
|
|
545
905
|
}
|
|
906
|
+
recoverLiveInputs() {
|
|
907
|
+
const rows = this.database.prepare("SELECT * FROM live_inputs WHERE status = 'offered' ORDER BY created_at, rowid").all();
|
|
908
|
+
if (rows.length === 0)
|
|
909
|
+
return;
|
|
910
|
+
const now = this.now();
|
|
911
|
+
const threadIds = new Set(rows.map((row) => row.thread_id));
|
|
912
|
+
this.transaction(() => {
|
|
913
|
+
const updateInput = this.database.prepare(`
|
|
914
|
+
UPDATE live_inputs SET status = 'queued', active_turn_id = NULL, updated_at = ? WHERE id = ?
|
|
915
|
+
`);
|
|
916
|
+
const updateMessage = this.database.prepare("UPDATE messages SET turn_id = NULL, parts_json = ?, updated_at = ? WHERE id = ?");
|
|
917
|
+
for (const row of rows) {
|
|
918
|
+
const persisted = this.database.prepare("SELECT parts_json FROM messages WHERE id = ?")
|
|
919
|
+
.get(row.message_id);
|
|
920
|
+
if (persisted === undefined) {
|
|
921
|
+
throw new WebConsoleError("storage_corrupt", `Live input ${row.id} has no message.`, 500);
|
|
922
|
+
}
|
|
923
|
+
updateInput.run(now, row.id);
|
|
924
|
+
updateMessage.run(JSON.stringify(withLiveInputStatus(parseParts(persisted.parts_json), "queued")), now, row.message_id);
|
|
925
|
+
}
|
|
926
|
+
for (const threadId of threadIds) {
|
|
927
|
+
this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
|
|
928
|
+
.run(now, threadId);
|
|
929
|
+
this.recordThreadRevision(threadId, "live_inputs_recovered", now);
|
|
930
|
+
}
|
|
931
|
+
});
|
|
932
|
+
}
|
|
546
933
|
finishTurn(turnId, status, finalText, errorCode, errorMessage, runtime) {
|
|
547
934
|
const turn = this.requireTurn(turnId);
|
|
548
935
|
const existing = this.requireMessage(turn.assistant_message_id);
|
|
@@ -582,6 +969,9 @@ export class WebStore {
|
|
|
582
969
|
createdAt: row.created_at,
|
|
583
970
|
updatedAt: row.updated_at,
|
|
584
971
|
revision: row.revision,
|
|
972
|
+
...(row.trigger_kind === "cron" || row.trigger_kind === "webhook"
|
|
973
|
+
? { trigger: { kind: row.trigger_kind } }
|
|
974
|
+
: {}),
|
|
585
975
|
...(preview === undefined ? {} : { lastMessagePreview: preview }),
|
|
586
976
|
messageCount: row.message_count,
|
|
587
977
|
runState,
|
|
@@ -594,17 +984,20 @@ export class WebStore {
|
|
|
594
984
|
.all(row.id);
|
|
595
985
|
const storedParts = parseParts(row.parts_json);
|
|
596
986
|
const quote = quoteFromParts(storedParts);
|
|
987
|
+
const liveInputStatus = liveInputStatusFromParts(storedParts);
|
|
597
988
|
return {
|
|
598
989
|
id: row.id,
|
|
599
990
|
threadId: row.thread_id,
|
|
600
991
|
...(row.turn_id === null ? {} : { turnId: row.turn_id }),
|
|
601
992
|
role: normalizeRole(row.role),
|
|
602
993
|
...(quote === undefined ? {} : { quote }),
|
|
603
|
-
parts: storedParts.filter((part) => part.type !== "telemetry"
|
|
994
|
+
parts: storedParts.filter((part) => part.type !== "telemetry"
|
|
995
|
+
|| (part.event !== QUOTE_TELEMETRY_EVENT && part.event !== LIVE_INPUT_TELEMETRY_EVENT)),
|
|
604
996
|
attachments: attachments.map((attachment) => toWebAttachment(mapStoredAttachment(attachment))),
|
|
605
997
|
createdAt: row.created_at,
|
|
606
998
|
updatedAt: row.updated_at,
|
|
607
999
|
status: normalizeMessageStatus(row.status),
|
|
1000
|
+
...(liveInputStatus === undefined ? {} : { liveInputStatus }),
|
|
608
1001
|
};
|
|
609
1002
|
}
|
|
610
1003
|
latestRunState(threadId) {
|
|
@@ -662,6 +1055,16 @@ export class WebStore {
|
|
|
662
1055
|
throw new WebConsoleError("message_not_found", "Message not found.", 404);
|
|
663
1056
|
return this.mapMessage(row);
|
|
664
1057
|
}
|
|
1058
|
+
getLiveInput(id) {
|
|
1059
|
+
return this.database.prepare("SELECT * FROM live_inputs WHERE id = ?")
|
|
1060
|
+
.get(id);
|
|
1061
|
+
}
|
|
1062
|
+
requireLiveInput(id) {
|
|
1063
|
+
const row = this.getLiveInput(id);
|
|
1064
|
+
if (row === undefined)
|
|
1065
|
+
throw new WebConsoleError("live_input_not_found", "Live input not found.", 404);
|
|
1066
|
+
return row;
|
|
1067
|
+
}
|
|
665
1068
|
requireStoredAttachment(id) {
|
|
666
1069
|
const attachment = this.getStoredAttachment(id);
|
|
667
1070
|
if (attachment === undefined)
|
|
@@ -695,7 +1098,7 @@ export class WebStore {
|
|
|
695
1098
|
}
|
|
696
1099
|
function threadSelectSql(suffix) {
|
|
697
1100
|
return `
|
|
698
|
-
SELECT t.id, t.source_id, t.title, t.archived_at, t.created_at, t.updated_at, t.revision,
|
|
1101
|
+
SELECT t.id, t.source_id, t.title, t.trigger_kind, t.archived_at, t.created_at, t.updated_at, t.revision,
|
|
699
1102
|
CASE WHEN a.status = 'online' OR a.status = 'degraded' THEN 1 ELSE 0 END AS can_send,
|
|
700
1103
|
CASE WHEN (a.status = 'online' OR a.status = 'degraded') AND a.supports_attachments = 1 THEN 1 ELSE 0 END AS can_upload,
|
|
701
1104
|
(SELECT COUNT(*) FROM messages m WHERE m.thread_id = t.id) AS message_count
|
|
@@ -717,6 +1120,22 @@ function agentSelectSql(suffix) {
|
|
|
717
1120
|
function agentPinSettingKey(sourceId) {
|
|
718
1121
|
return `agent_pin:${sourceId}`;
|
|
719
1122
|
}
|
|
1123
|
+
function notificationThreadId(sourceId, deliveryKey) {
|
|
1124
|
+
const digest = createHash("sha256")
|
|
1125
|
+
.update(sourceId)
|
|
1126
|
+
.update("\0")
|
|
1127
|
+
.update(deliveryKey)
|
|
1128
|
+
.digest("hex")
|
|
1129
|
+
.slice(0, 32);
|
|
1130
|
+
return `notification-${digest}`;
|
|
1131
|
+
}
|
|
1132
|
+
function notificationPayloadSha256(kind, text) {
|
|
1133
|
+
return createHash("sha256")
|
|
1134
|
+
.update(kind)
|
|
1135
|
+
.update("\0")
|
|
1136
|
+
.update(text)
|
|
1137
|
+
.digest("hex");
|
|
1138
|
+
}
|
|
720
1139
|
function mapAgent(row) {
|
|
721
1140
|
const models = parseStringArray(row.models_json);
|
|
722
1141
|
const efforts = parseStringArray(row.efforts_json);
|
|
@@ -752,6 +1171,16 @@ function mapStoredAttachment(row) {
|
|
|
752
1171
|
updatedAt: row.updated_at,
|
|
753
1172
|
};
|
|
754
1173
|
}
|
|
1174
|
+
function mapLiveInput(row) {
|
|
1175
|
+
return {
|
|
1176
|
+
id: row.id,
|
|
1177
|
+
threadId: row.thread_id,
|
|
1178
|
+
messageId: row.message_id,
|
|
1179
|
+
text: row.text,
|
|
1180
|
+
status: row.status,
|
|
1181
|
+
createdAt: row.created_at,
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
755
1184
|
export function toWebAttachment(attachment) {
|
|
756
1185
|
return {
|
|
757
1186
|
id: attachment.id,
|
|
@@ -801,8 +1230,37 @@ function applyEvent(parts, event) {
|
|
|
801
1230
|
});
|
|
802
1231
|
return;
|
|
803
1232
|
}
|
|
1233
|
+
if (event.type === "runtime_telemetry" && event.kind === "context_compaction") {
|
|
1234
|
+
upsertContextCompaction(parts, event);
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
804
1237
|
parts.push({ type: "telemetry", event: event.type, data: event });
|
|
805
1238
|
}
|
|
1239
|
+
function contextCompactionOperationId(value) {
|
|
1240
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
1241
|
+
return undefined;
|
|
1242
|
+
const event = value;
|
|
1243
|
+
if (event.type !== "runtime_telemetry" || event.kind !== "context_compaction")
|
|
1244
|
+
return undefined;
|
|
1245
|
+
const data = event.data;
|
|
1246
|
+
if (data === null || typeof data !== "object" || Array.isArray(data))
|
|
1247
|
+
return undefined;
|
|
1248
|
+
const operationId = data.operationId;
|
|
1249
|
+
return typeof operationId === "string" && operationId.length > 0 ? operationId : undefined;
|
|
1250
|
+
}
|
|
1251
|
+
function upsertContextCompaction(parts, event) {
|
|
1252
|
+
const operationId = contextCompactionOperationId(event);
|
|
1253
|
+
const next = { type: "telemetry", event: event.type, data: event };
|
|
1254
|
+
if (operationId === undefined) {
|
|
1255
|
+
parts.push(next);
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
const index = parts.findIndex((part) => part.type === "telemetry" && contextCompactionOperationId(part.data) === operationId);
|
|
1259
|
+
if (index < 0)
|
|
1260
|
+
parts.push(next);
|
|
1261
|
+
else
|
|
1262
|
+
parts[index] = next;
|
|
1263
|
+
}
|
|
806
1264
|
function existingToolName(parts, id) {
|
|
807
1265
|
const existing = parts.find((part) => part.type === "tool-call" && part.toolCallId === id);
|
|
808
1266
|
return existing?.type === "tool-call" ? existing.toolName : undefined;
|
|
@@ -897,6 +1355,33 @@ function parseParts(value) {
|
|
|
897
1355
|
return parsed;
|
|
898
1356
|
}
|
|
899
1357
|
const QUOTE_TELEMETRY_EVENT = "quote";
|
|
1358
|
+
const LIVE_INPUT_TELEMETRY_EVENT = "live_input";
|
|
1359
|
+
function liveInputTelemetry(status) {
|
|
1360
|
+
return { type: "telemetry", event: LIVE_INPUT_TELEMETRY_EVENT, data: { status } };
|
|
1361
|
+
}
|
|
1362
|
+
function withoutLiveInputTelemetry(parts) {
|
|
1363
|
+
return parts.filter((part) => part.type !== "telemetry" || part.event !== LIVE_INPUT_TELEMETRY_EVENT);
|
|
1364
|
+
}
|
|
1365
|
+
function withLiveInputStatus(parts, status) {
|
|
1366
|
+
return [liveInputTelemetry(status), ...withoutLiveInputTelemetry(parts)];
|
|
1367
|
+
}
|
|
1368
|
+
function liveInputStatusFromParts(parts) {
|
|
1369
|
+
const markers = parts.filter((part) => part.type === "telemetry" && part.event === LIVE_INPUT_TELEMETRY_EVENT);
|
|
1370
|
+
if (markers.length === 0)
|
|
1371
|
+
return undefined;
|
|
1372
|
+
if (markers.length !== 1) {
|
|
1373
|
+
throw new WebConsoleError("storage_corrupt", "Persisted live-input metadata is duplicated.", 500);
|
|
1374
|
+
}
|
|
1375
|
+
const data = markers[0]?.data;
|
|
1376
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
1377
|
+
throw new WebConsoleError("storage_corrupt", "Persisted live-input metadata is invalid.", 500);
|
|
1378
|
+
}
|
|
1379
|
+
const status = data.status;
|
|
1380
|
+
if (status !== "pending" && status !== "applied" && status !== "queued" && status !== "cancelled") {
|
|
1381
|
+
throw new WebConsoleError("storage_corrupt", "Persisted live-input status is invalid.", 500);
|
|
1382
|
+
}
|
|
1383
|
+
return status;
|
|
1384
|
+
}
|
|
900
1385
|
function quoteFromParts(parts) {
|
|
901
1386
|
const markers = parts.filter((part) => part.type === "telemetry" && part.event === QUOTE_TELEMETRY_EVENT);
|
|
902
1387
|
if (markers.length === 0)
|