@mono-agent/web 0.18.3 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +79 -18
  2. package/dist/contracts.d.ts +46 -1
  3. package/dist/contracts.d.ts.map +1 -1
  4. package/dist/contracts.js +3 -0
  5. package/dist/contracts.js.map +1 -1
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/operator-client.d.ts +10 -1
  11. package/dist/operator-client.d.ts.map +1 -1
  12. package/dist/operator-client.js +85 -0
  13. package/dist/operator-client.js.map +1 -1
  14. package/dist/push-preview.d.ts +8 -0
  15. package/dist/push-preview.d.ts.map +1 -0
  16. package/dist/push-preview.js +146 -0
  17. package/dist/push-preview.js.map +1 -0
  18. package/dist/push.d.ts +68 -0
  19. package/dist/push.d.ts.map +1 -0
  20. package/dist/push.js +594 -0
  21. package/dist/push.js.map +1 -0
  22. package/dist/server.d.ts +2 -0
  23. package/dist/server.d.ts.map +1 -1
  24. package/dist/server.js +195 -4
  25. package/dist/server.js.map +1 -1
  26. package/dist/service.d.ts +36 -2
  27. package/dist/service.d.ts.map +1 -1
  28. package/dist/service.js +251 -8
  29. package/dist/service.js.map +1 -1
  30. package/dist/store.d.ts +87 -1
  31. package/dist/store.d.ts.map +1 -1
  32. package/dist/store.js +590 -5
  33. package/dist/store.js.map +1 -1
  34. package/package.json +8 -6
  35. package/webapp/dist/assets/{assistant-ui-DTnHvSvW.js → assistant-ui-CxqQJmH9.js} +13 -13
  36. package/webapp/dist/assets/index-DBk-VjNE.css +1 -0
  37. package/webapp/dist/assets/index-DntvgDjy.js +51 -0
  38. package/webapp/dist/assets/{markdown-CTWk6ujC.js → markdown-8pwMczg6.js} +1 -1
  39. package/webapp/dist/index.html +8 -7
  40. package/webapp/dist/manifest.webmanifest +17 -1
  41. package/webapp/dist/notification-sw.js +162 -4
  42. package/webapp/dist/sw.js +1 -1
  43. package/webapp/dist/assets/index-BDLWl9d1.css +0 -1
  44. package/webapp/dist/assets/index-byaz_i41.js +0 -51
package/dist/store.js CHANGED
@@ -1,11 +1,16 @@
1
- import { createHash, randomUUID } from "node:crypto";
1
+ import { createECDH, createHash, randomUUID, timingSafeEqual } 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
5
  import { AGENT_LIVE_INPUT_MAX_CHARACTERS, } from "@mono-agent/agent-contracts";
6
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";
7
7
  import { WebConsoleError } from "./errors.js";
8
+ import { webPushPreview } from "./push-preview.js";
8
9
  import { prepareWebStatePaths } from "./state-paths.js";
10
+ const WEB_STORAGE_SCHEMA_VERSION = 4;
11
+ const MAX_ACTIVE_PUSH_SUBSCRIPTIONS = 32;
12
+ const MAX_PENDING_PUSH_DELIVERIES_PER_SUBSCRIPTION = 200;
13
+ const PUSH_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
9
14
  export class WebStore {
10
15
  paths;
11
16
  database;
@@ -40,6 +45,7 @@ export class WebStore {
40
45
  ]);
41
46
  store.recoverInterruptedTurns();
42
47
  store.recoverLiveInputs();
48
+ store.recoverWebPushDeliveries();
43
49
  return store;
44
50
  }
45
51
  catch (error) {
@@ -180,6 +186,17 @@ export class WebStore {
180
186
  UPDATE notification_deliveries SET completed_at = ?
181
187
  WHERE source_id = ? AND delivery_key = ? AND completed_at IS NULL
182
188
  `).run(now, reservation.sourceId, reservation.deliveryKey);
189
+ const agent = this.getAgent(reservation.sourceId);
190
+ this.enqueueWebPushEventInTransaction({
191
+ logicalKey: `web-new:${reservation.threadId}`,
192
+ kind: "response.ready",
193
+ threadId: reservation.threadId,
194
+ sourceId: reservation.sourceId,
195
+ title: `${agent?.label ?? "mono-agent"} · ${reservation.triggerKind.toUpperCase()}`,
196
+ body: reservation.text,
197
+ expiresAt: new Date(new Date(now).getTime() + 24 * 60 * 60 * 1_000).toISOString(),
198
+ notBefore: new Date(new Date(now).getTime() + 3_000).toISOString(),
199
+ });
183
200
  });
184
201
  return { thread: this.requireThread(reservation.threadId), duplicate: false };
185
202
  }
@@ -271,6 +288,12 @@ export class WebStore {
271
288
  const attachments = this.database.prepare("SELECT * FROM attachments WHERE thread_id = ?")
272
289
  .all(id);
273
290
  this.transaction(() => {
291
+ const now = this.now();
292
+ this.database.prepare(`
293
+ UPDATE push_deliveries SET status = 'dropped', updated_at = ?, finished_at = ?, last_error_code = 'thread_deleted'
294
+ WHERE event_id IN (SELECT id FROM push_events WHERE thread_id = ?)
295
+ AND status IN ('pending', 'sending')
296
+ `).run(now, now, id);
274
297
  this.database.prepare("DELETE FROM notification_deliveries WHERE thread_id = ?").run(id);
275
298
  this.database.prepare("DELETE FROM revisions WHERE entity_kind = 'thread' AND entity_id = ?").run(id);
276
299
  this.database.prepare("DELETE FROM threads WHERE id = ?").run(id);
@@ -724,17 +747,320 @@ export class WebStore {
724
747
  const row = this.database.prepare("SELECT thread_id FROM turns WHERE id = ?").get(turnId);
725
748
  return row?.thread_id;
726
749
  }
750
+ ensureWebPushIdentity(generate) {
751
+ const rows = this.database.prepare(`
752
+ SELECT key, value FROM settings
753
+ WHERE key IN ('web_push_public_key', 'web_push_private_key', 'web_push_key_fingerprint')
754
+ `).all();
755
+ const values = new Map(rows.map((row) => [row.key, row.value]));
756
+ const publicKey = values.get("web_push_public_key");
757
+ const privateKey = values.get("web_push_private_key");
758
+ const fingerprint = values.get("web_push_key_fingerprint");
759
+ const present = [publicKey, privateKey, fingerprint].filter((value) => value !== undefined).length;
760
+ if (present > 0 && present < 3) {
761
+ throw new WebConsoleError("web_push_identity_corrupt", "The stored Web Push identity is incomplete. Reset web state before enabling notifications again.", 500);
762
+ }
763
+ if (publicKey !== undefined && privateKey !== undefined && fingerprint !== undefined) {
764
+ if (!isValidVapidKeyPair(publicKey, privateKey) || fingerprint !== pushKeyFingerprint(publicKey)) {
765
+ throw new WebConsoleError("web_push_identity_corrupt", "The stored Web Push identity is invalid. Reset web state before enabling notifications again.", 500);
766
+ }
767
+ return { publicKey, privateKey, fingerprint };
768
+ }
769
+ const generated = generate();
770
+ if (!isValidVapidKeyPair(generated.publicKey, generated.privateKey)) {
771
+ throw new WebConsoleError("web_push_identity_generation_failed", "Unable to generate a valid Web Push identity.", 500);
772
+ }
773
+ const generatedFingerprint = pushKeyFingerprint(generated.publicKey);
774
+ this.transaction(() => {
775
+ this.setSetting("web_push_public_key", generated.publicKey);
776
+ this.setSetting("web_push_private_key", generated.privateKey);
777
+ this.setSetting("web_push_key_fingerprint", generatedFingerprint);
778
+ });
779
+ return { ...generated, fingerprint: generatedFingerprint };
780
+ }
781
+ registerWebPushSubscription(input) {
782
+ const endpointSha256 = createHash("sha256").update(input.endpoint).digest("hex");
783
+ const previousEndpointSha256 = input.previousEndpoint === undefined
784
+ ? undefined
785
+ : createHash("sha256").update(input.previousEndpoint).digest("hex");
786
+ return this.transaction(() => {
787
+ const existing = this.database.prepare("SELECT * FROM push_subscriptions WHERE endpoint_sha256 = ?")
788
+ .get(endpointSha256);
789
+ const replacements = new Set();
790
+ if (input.previousSubscriptionId !== undefined) {
791
+ const row = this.database.prepare("SELECT id FROM push_subscriptions WHERE id = ? AND site_origin = ?")
792
+ .get(input.previousSubscriptionId, input.siteOrigin);
793
+ if (row !== undefined)
794
+ replacements.add(row.id);
795
+ }
796
+ if (previousEndpointSha256 !== undefined) {
797
+ const row = this.database.prepare("SELECT id FROM push_subscriptions WHERE endpoint_sha256 = ? AND site_origin = ?")
798
+ .get(previousEndpointSha256, input.siteOrigin);
799
+ if (row !== undefined)
800
+ replacements.add(row.id);
801
+ }
802
+ if (existing !== undefined)
803
+ replacements.delete(existing.id);
804
+ const now = this.now();
805
+ for (const replacementId of replacements) {
806
+ this.database.prepare(`
807
+ UPDATE push_subscriptions SET state = 'expired', disabled_at = ?, updated_at = ?,
808
+ last_error_at = ?, last_error_code = 'subscription_rotated'
809
+ WHERE id = ? AND state = 'active'
810
+ `).run(now, now, now, replacementId);
811
+ this.database.prepare(`
812
+ UPDATE push_deliveries SET status = 'stale', updated_at = ?, finished_at = ?,
813
+ last_error_code = 'subscription_rotated'
814
+ WHERE subscription_id = ? AND status IN ('pending', 'sending')
815
+ `).run(now, now, replacementId);
816
+ }
817
+ if (existing?.state !== "active") {
818
+ const active = this.database.prepare("SELECT COUNT(*) AS count FROM push_subscriptions WHERE state = 'active'")
819
+ .get();
820
+ if (active.count >= MAX_ACTIVE_PUSH_SUBSCRIPTIONS) {
821
+ throw new WebConsoleError("push_subscription_limit", `This console already has ${MAX_ACTIVE_PUSH_SUBSCRIPTIONS} active notification subscriptions.`, 409);
822
+ }
823
+ }
824
+ const id = existing?.id ?? randomUUID();
825
+ this.database.prepare(`
826
+ INSERT INTO push_subscriptions (
827
+ id, endpoint, endpoint_sha256, p256dh, auth, expiration_time, site_origin,
828
+ key_fingerprint, state, created_at, updated_at, disabled_at,
829
+ last_success_at, last_error_at, last_error_code
830
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, NULL, NULL, NULL, NULL)
831
+ ON CONFLICT(endpoint_sha256) DO UPDATE SET
832
+ endpoint = excluded.endpoint,
833
+ p256dh = excluded.p256dh,
834
+ auth = excluded.auth,
835
+ expiration_time = excluded.expiration_time,
836
+ site_origin = excluded.site_origin,
837
+ key_fingerprint = excluded.key_fingerprint,
838
+ state = 'active',
839
+ updated_at = excluded.updated_at,
840
+ disabled_at = NULL,
841
+ last_error_at = NULL,
842
+ last_error_code = NULL
843
+ `).run(id, input.endpoint, endpointSha256, input.p256dh, input.auth, input.expirationTime ?? null, input.siteOrigin, input.keyFingerprint, now, now);
844
+ return this.requirePushSubscriptionStatus(id);
845
+ });
846
+ }
847
+ getWebPushSubscription(id) {
848
+ const row = this.database.prepare("SELECT * FROM push_subscriptions WHERE id = ?").get(id);
849
+ return row === undefined ? undefined : mapPushSubscriptionStatus(row);
850
+ }
851
+ disableWebPushSubscription(id) {
852
+ const now = this.now();
853
+ const result = this.database.prepare(`
854
+ UPDATE push_subscriptions SET state = 'disabled', disabled_at = ?, updated_at = ?
855
+ WHERE id = ? AND state != 'disabled'
856
+ `).run(now, now, id);
857
+ if (result.changes === 0 && this.getWebPushSubscription(id) === undefined) {
858
+ throw new WebConsoleError("push_subscription_not_found", "Notification subscription not found.", 404);
859
+ }
860
+ this.database.prepare(`
861
+ UPDATE push_deliveries SET status = 'dropped', updated_at = ?, finished_at = ?, last_error_code = 'subscription_disabled'
862
+ WHERE subscription_id = ? AND status IN ('pending', 'sending')
863
+ `).run(now, now, id);
864
+ }
865
+ expireWebPushSubscription(id, reason) {
866
+ const now = this.now();
867
+ this.transaction(() => {
868
+ this.database.prepare(`
869
+ UPDATE push_subscriptions SET state = 'expired', disabled_at = ?, updated_at = ?,
870
+ last_error_at = ?, last_error_code = ? WHERE id = ?
871
+ `).run(now, now, now, reason, id);
872
+ this.database.prepare(`
873
+ UPDATE push_deliveries SET status = 'stale', updated_at = ?, finished_at = ?, last_error_code = ?
874
+ WHERE subscription_id = ? AND status IN ('pending', 'sending')
875
+ `).run(now, now, reason, id);
876
+ });
877
+ }
878
+ enqueueWebPushEvent(input) {
879
+ return this.transaction(() => this.enqueueWebPushEventInTransaction(input));
880
+ }
881
+ enqueueWebPushTest(subscriptionId) {
882
+ const subscription = this.requirePushSubscription(subscriptionId);
883
+ if (subscription.state !== "active") {
884
+ throw new WebConsoleError("push_subscription_inactive", "Notification subscription is not active.", 409);
885
+ }
886
+ const latest = this.database.prepare(`
887
+ SELECT e.created_at FROM push_events e
888
+ JOIN push_deliveries d ON d.event_id = e.id
889
+ WHERE d.subscription_id = ? AND e.kind = 'test'
890
+ ORDER BY e.created_at DESC LIMIT 1
891
+ `).get(subscriptionId);
892
+ if (latest !== undefined && this.clock().getTime() - new Date(latest.created_at).getTime() < 10_000) {
893
+ throw new WebConsoleError("push_test_rate_limited", "Wait 10 seconds before sending another test notification.", 429);
894
+ }
895
+ const now = this.clock();
896
+ const event = this.enqueueWebPushEvent({
897
+ logicalKey: `test:${randomUUID()}`,
898
+ kind: "test",
899
+ title: "mono-agent notifications",
900
+ body: "Push notifications are connected.",
901
+ expiresAt: new Date(now.getTime() + 60_000).toISOString(),
902
+ notBefore: now.toISOString(),
903
+ subscriptionId,
904
+ });
905
+ if (event === undefined)
906
+ throw new WebConsoleError("push_subscription_inactive", "Notification subscription is not active.", 409);
907
+ return event;
908
+ }
909
+ webPushEventByLogicalKey(logicalKey) {
910
+ const row = this.database.prepare("SELECT * FROM push_events WHERE logical_key = ?").get(logicalKey);
911
+ return row === undefined ? undefined : mapPushEvent(row);
912
+ }
913
+ acknowledgeWebPushEvent(eventId, subscriptionId) {
914
+ const now = this.now();
915
+ const result = this.database.prepare(`
916
+ UPDATE push_deliveries SET status = 'suppressed', updated_at = ?, finished_at = ?, last_error_code = NULL
917
+ WHERE event_id = ? AND subscription_id = ? AND status = 'pending' AND attempts = 0
918
+ `).run(now, now, eventId, subscriptionId);
919
+ return result.changes > 0;
920
+ }
921
+ claimDueWebPushDeliveries(limit) {
922
+ const now = this.now();
923
+ return this.transaction(() => {
924
+ this.database.prepare(`
925
+ UPDATE push_deliveries SET status = 'dropped', updated_at = ?, finished_at = ?, last_error_code = 'expired'
926
+ WHERE status IN ('pending', 'sending')
927
+ AND event_id IN (SELECT id FROM push_events WHERE expires_at <= ?)
928
+ `).run(now, now, now);
929
+ const rows = this.database.prepare(`
930
+ SELECT d.event_id, d.subscription_id, d.status, d.attempts, d.next_attempt_at,
931
+ d.last_status_code, d.last_error_code, d.created_at, d.updated_at, d.finished_at,
932
+ e.id AS e_id, e.logical_key, e.kind, e.thread_id, e.source_id, e.title, e.body,
933
+ e.tag, e.topic, e.expires_at, e.created_at AS e_created_at,
934
+ s.id AS s_id, s.endpoint, s.endpoint_sha256, s.p256dh, s.auth, s.expiration_time,
935
+ s.site_origin, s.key_fingerprint, s.state, s.created_at AS s_created_at,
936
+ s.updated_at AS s_updated_at, s.disabled_at, s.last_success_at, s.last_error_at, s.last_error_code AS s_last_error_code
937
+ FROM push_deliveries d
938
+ JOIN push_events e ON e.id = d.event_id
939
+ JOIN push_subscriptions s ON s.id = d.subscription_id
940
+ WHERE d.status = 'pending' AND d.next_attempt_at <= ? AND e.expires_at > ? AND s.state = 'active'
941
+ ORDER BY d.next_attempt_at, d.created_at, d.rowid
942
+ LIMIT ?
943
+ `).all(now, now, limit);
944
+ const claim = this.database.prepare(`
945
+ UPDATE push_deliveries SET status = 'sending', updated_at = ?
946
+ WHERE event_id = ? AND subscription_id = ? AND status = 'pending'
947
+ `);
948
+ const claimed = [];
949
+ for (const row of rows) {
950
+ if (claim.run(now, row.event_id, row.subscription_id).changes === 0)
951
+ continue;
952
+ claimed.push({
953
+ event: mapPushEvent({
954
+ id: row.e_id,
955
+ logical_key: row.logical_key,
956
+ kind: row.kind,
957
+ thread_id: row.thread_id,
958
+ source_id: row.source_id,
959
+ title: row.title,
960
+ body: row.body,
961
+ tag: row.tag,
962
+ topic: row.topic,
963
+ expires_at: row.expires_at,
964
+ created_at: row.e_created_at,
965
+ }),
966
+ subscription: mapPushSubscription({
967
+ id: row.s_id,
968
+ endpoint: row.endpoint,
969
+ endpoint_sha256: row.endpoint_sha256,
970
+ p256dh: row.p256dh,
971
+ auth: row.auth,
972
+ expiration_time: row.expiration_time,
973
+ site_origin: row.site_origin,
974
+ key_fingerprint: row.key_fingerprint,
975
+ state: row.state,
976
+ created_at: row.s_created_at,
977
+ updated_at: row.s_updated_at,
978
+ disabled_at: row.disabled_at,
979
+ last_success_at: row.last_success_at,
980
+ last_error_at: row.last_error_at,
981
+ last_error_code: row.s_last_error_code,
982
+ }),
983
+ attempts: row.attempts,
984
+ });
985
+ }
986
+ return claimed;
987
+ });
988
+ }
989
+ settleWebPushDelivery(input) {
990
+ const now = this.now();
991
+ this.transaction(() => {
992
+ this.database.prepare(`
993
+ UPDATE push_deliveries SET status = ?, attempts = attempts + 1, updated_at = ?, finished_at = ?,
994
+ last_status_code = ?, last_error_code = ?
995
+ WHERE event_id = ? AND subscription_id = ? AND status = 'sending'
996
+ `).run(input.status, now, now, input.statusCode ?? null, input.errorCode ?? null, input.eventId, input.subscriptionId);
997
+ if (input.status === "accepted") {
998
+ this.database.prepare(`
999
+ UPDATE push_subscriptions SET last_success_at = ?, updated_at = ?, last_error_code = NULL
1000
+ WHERE id = ?
1001
+ `).run(now, now, input.subscriptionId);
1002
+ }
1003
+ else {
1004
+ this.database.prepare(`
1005
+ UPDATE push_subscriptions SET last_error_at = ?, updated_at = ?, last_error_code = ?
1006
+ WHERE id = ?
1007
+ `).run(now, now, input.errorCode ?? input.status, input.subscriptionId);
1008
+ }
1009
+ });
1010
+ }
1011
+ retryWebPushDelivery(input) {
1012
+ const now = this.now();
1013
+ this.database.prepare(`
1014
+ UPDATE push_deliveries SET status = 'pending', attempts = attempts + 1, next_attempt_at = ?,
1015
+ updated_at = ?, last_status_code = ?, last_error_code = ?
1016
+ WHERE event_id = ? AND subscription_id = ? AND status = 'sending'
1017
+ `).run(input.nextAttemptAt, now, input.statusCode ?? null, input.errorCode, input.eventId, input.subscriptionId);
1018
+ }
1019
+ deferClaimedWebPushDelivery(eventId, subscriptionId, nextAttemptAt) {
1020
+ this.database.prepare(`
1021
+ UPDATE push_deliveries SET status = 'pending', next_attempt_at = ?, updated_at = ?
1022
+ WHERE event_id = ? AND subscription_id = ? AND status = 'sending'
1023
+ `).run(nextAttemptAt, this.now(), eventId, subscriptionId);
1024
+ }
1025
+ staleWebPushEvent(logicalKey, reason = "resolved") {
1026
+ const now = this.now();
1027
+ this.database.prepare(`
1028
+ UPDATE push_deliveries SET status = 'stale', updated_at = ?, finished_at = ?, last_error_code = ?
1029
+ WHERE event_id = (SELECT id FROM push_events WHERE logical_key = ?)
1030
+ AND status IN ('pending', 'sending')
1031
+ `).run(now, now, reason, logicalKey);
1032
+ }
1033
+ webPushDueQueueDepth() {
1034
+ const row = this.database.prepare(`
1035
+ SELECT COUNT(*) AS count FROM push_deliveries
1036
+ WHERE status = 'sending' OR (status = 'pending' AND next_attempt_at <= ?)
1037
+ `).get(this.now());
1038
+ return row.count;
1039
+ }
1040
+ purgeWebPushState() {
1041
+ const before = new Date(this.clock().getTime() - PUSH_RETENTION_MS).toISOString();
1042
+ this.transaction(() => {
1043
+ this.database.prepare(`
1044
+ DELETE FROM push_subscriptions WHERE state IN ('disabled', 'expired') AND updated_at < ?
1045
+ `).run(before);
1046
+ this.database.prepare(`
1047
+ DELETE FROM push_events WHERE created_at < ? AND NOT EXISTS (
1048
+ SELECT 1 FROM push_deliveries d WHERE d.event_id = push_events.id AND d.status IN ('pending', 'sending')
1049
+ )
1050
+ `).run(before);
1051
+ });
1052
+ }
727
1053
  initialize() {
728
1054
  try {
729
1055
  this.database.exec("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;");
730
1056
  const versionRow = this.database.prepare("PRAGMA user_version").get();
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);
1057
+ if (versionRow.user_version > WEB_STORAGE_SCHEMA_VERSION) {
1058
+ throw new WebConsoleError("unsupported_storage_schema", `Web state schema ${versionRow.user_version} is newer than supported schema ${WEB_STORAGE_SCHEMA_VERSION}.`, 500);
733
1059
  }
734
1060
  if (versionRow.user_version < 0) {
735
1061
  throw new WebConsoleError("storage_corrupt", "Web state schema version is invalid.", 500);
736
1062
  }
737
- const migrating = versionRow.user_version < 3;
1063
+ const migrating = versionRow.user_version < WEB_STORAGE_SCHEMA_VERSION;
738
1064
  if (migrating)
739
1065
  this.database.exec("BEGIN IMMEDIATE");
740
1066
  try {
@@ -842,6 +1168,58 @@ export class WebStore {
842
1168
  completed_at TEXT,
843
1169
  PRIMARY KEY (source_id, delivery_key)
844
1170
  );
1171
+ CREATE TABLE IF NOT EXISTS push_subscriptions (
1172
+ id TEXT PRIMARY KEY,
1173
+ endpoint TEXT NOT NULL,
1174
+ endpoint_sha256 TEXT NOT NULL UNIQUE,
1175
+ p256dh TEXT NOT NULL,
1176
+ auth TEXT NOT NULL,
1177
+ expiration_time INTEGER,
1178
+ site_origin TEXT NOT NULL,
1179
+ key_fingerprint TEXT NOT NULL,
1180
+ state TEXT NOT NULL CHECK (state IN ('active', 'disabled', 'expired')),
1181
+ created_at TEXT NOT NULL,
1182
+ updated_at TEXT NOT NULL,
1183
+ disabled_at TEXT,
1184
+ last_success_at TEXT,
1185
+ last_error_at TEXT,
1186
+ last_error_code TEXT
1187
+ );
1188
+ CREATE INDEX IF NOT EXISTS push_subscriptions_by_state
1189
+ ON push_subscriptions(state, updated_at);
1190
+ CREATE TABLE IF NOT EXISTS push_events (
1191
+ id TEXT PRIMARY KEY,
1192
+ logical_key TEXT NOT NULL UNIQUE,
1193
+ kind TEXT NOT NULL CHECK (kind IN (
1194
+ 'response.ready', 'input.required', 'run.failed', 'run.cancelled', 'run.interrupted', 'test'
1195
+ )),
1196
+ thread_id TEXT REFERENCES threads(id) ON DELETE SET NULL,
1197
+ source_id TEXT,
1198
+ title TEXT NOT NULL,
1199
+ body TEXT NOT NULL,
1200
+ tag TEXT NOT NULL,
1201
+ topic TEXT NOT NULL,
1202
+ expires_at TEXT NOT NULL,
1203
+ created_at TEXT NOT NULL
1204
+ );
1205
+ CREATE INDEX IF NOT EXISTS push_events_by_expiry ON push_events(expires_at);
1206
+ CREATE TABLE IF NOT EXISTS push_deliveries (
1207
+ event_id TEXT NOT NULL REFERENCES push_events(id) ON DELETE CASCADE,
1208
+ subscription_id TEXT NOT NULL REFERENCES push_subscriptions(id) ON DELETE CASCADE,
1209
+ status TEXT NOT NULL CHECK (status IN (
1210
+ 'pending', 'sending', 'accepted', 'suppressed', 'stale', 'failed', 'config_error', 'dropped'
1211
+ )),
1212
+ attempts INTEGER NOT NULL DEFAULT 0,
1213
+ next_attempt_at TEXT NOT NULL,
1214
+ last_status_code INTEGER,
1215
+ last_error_code TEXT,
1216
+ created_at TEXT NOT NULL,
1217
+ updated_at TEXT NOT NULL,
1218
+ finished_at TEXT,
1219
+ PRIMARY KEY (event_id, subscription_id)
1220
+ );
1221
+ CREATE INDEX IF NOT EXISTS push_deliveries_due
1222
+ ON push_deliveries(status, next_attempt_at, created_at);
845
1223
  `);
846
1224
  if (versionRow.user_version === 1) {
847
1225
  const columns = this.database.prepare("PRAGMA table_info(threads)").all();
@@ -850,7 +1228,7 @@ export class WebStore {
850
1228
  }
851
1229
  }
852
1230
  if (migrating)
853
- this.database.exec("PRAGMA user_version = 3; COMMIT");
1231
+ this.database.exec(`PRAGMA user_version = ${WEB_STORAGE_SCHEMA_VERSION}; COMMIT`);
854
1232
  }
855
1233
  catch (error) {
856
1234
  if (this.database.isTransaction)
@@ -880,6 +1258,9 @@ export class WebStore {
880
1258
  "revisions",
881
1259
  "settings",
882
1260
  "notification_deliveries",
1261
+ "push_subscriptions",
1262
+ "push_events",
1263
+ "push_deliveries",
883
1264
  ]);
884
1265
  const tables = this.database.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all();
885
1266
  for (const table of tables)
@@ -930,6 +1311,13 @@ export class WebStore {
930
1311
  }
931
1312
  });
932
1313
  }
1314
+ recoverWebPushDeliveries() {
1315
+ const now = this.now();
1316
+ this.database.prepare(`
1317
+ UPDATE push_deliveries SET status = 'pending', next_attempt_at = ?, updated_at = ?
1318
+ WHERE status = 'sending'
1319
+ `).run(now, now);
1320
+ }
933
1321
  finishTurn(turnId, status, finalText, errorCode, errorMessage, runtime) {
934
1322
  const turn = this.requireTurn(turnId);
935
1323
  const existing = this.requireMessage(turn.assistant_message_id);
@@ -943,6 +1331,8 @@ export class WebStore {
943
1331
  parts.push({ type: "error", ...(errorCode === undefined ? {} : { code: errorCode }), message: errorMessage });
944
1332
  }
945
1333
  const now = this.now();
1334
+ const thread = this.requireThread(turn.thread_id);
1335
+ const agent = this.getAgent(thread.sourceId);
946
1336
  this.transaction(() => {
947
1337
  this.database.prepare(`
948
1338
  UPDATE turns SET status = ?, finished_at = ?, error_code = ?, error_message = ?,
@@ -955,6 +1345,43 @@ export class WebStore {
955
1345
  this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
956
1346
  .run(now, turn.thread_id);
957
1347
  this.recordThreadRevision(turn.thread_id, `turn_${status}`, now);
1348
+ const recentEnoughForRecoveredInterruption = status !== "interrupted"
1349
+ || new Date(now).getTime() - new Date(existing.updatedAt).getTime() <= 60 * 60 * 1_000;
1350
+ if (recentEnoughForRecoveredInterruption) {
1351
+ const kind = status === "complete"
1352
+ ? "response.ready"
1353
+ : status === "cancelled"
1354
+ ? "run.cancelled"
1355
+ : status === "interrupted"
1356
+ ? "run.interrupted"
1357
+ : "run.failed";
1358
+ const label = agent?.label ?? "mono-agent";
1359
+ const body = status === "complete"
1360
+ ? parts.filter((part) => part.type === "text")
1361
+ .map((part) => part.text)
1362
+ .join(" ")
1363
+ : status === "cancelled"
1364
+ ? "The run was cancelled."
1365
+ : status === "interrupted"
1366
+ ? "The run was interrupted when the web service stopped."
1367
+ : errorMessage ?? "The run failed.";
1368
+ this.enqueueWebPushEventInTransaction({
1369
+ logicalKey: `turn:${turnId}:terminal`,
1370
+ kind,
1371
+ threadId: turn.thread_id,
1372
+ sourceId: thread.sourceId,
1373
+ title: status === "complete"
1374
+ ? `${label} replied`
1375
+ : status === "cancelled"
1376
+ ? `${label} run cancelled`
1377
+ : status === "interrupted"
1378
+ ? `${label} run interrupted`
1379
+ : `${label} run failed`,
1380
+ body,
1381
+ expiresAt: new Date(new Date(now).getTime() + (status === "complete" ? 24 : 1) * 60 * 60 * 1_000).toISOString(),
1382
+ notBefore: new Date(new Date(now).getTime() + 3_000).toISOString(),
1383
+ });
1384
+ }
958
1385
  });
959
1386
  return this.requireThreadDetail(turn.thread_id);
960
1387
  }
@@ -1071,6 +1498,90 @@ export class WebStore {
1071
1498
  throw new WebConsoleError("attachment_not_found", "Attachment not found.", 404);
1072
1499
  return attachment;
1073
1500
  }
1501
+ requirePushSubscription(id) {
1502
+ const row = this.database.prepare("SELECT * FROM push_subscriptions WHERE id = ?")
1503
+ .get(id);
1504
+ if (row === undefined) {
1505
+ throw new WebConsoleError("push_subscription_not_found", "Notification subscription not found.", 404);
1506
+ }
1507
+ return mapPushSubscription(row);
1508
+ }
1509
+ requirePushSubscriptionStatus(id) {
1510
+ const status = this.getWebPushSubscription(id);
1511
+ if (status === undefined) {
1512
+ throw new WebConsoleError("push_subscription_not_found", "Notification subscription not found.", 404);
1513
+ }
1514
+ return status;
1515
+ }
1516
+ enqueueWebPushEventInTransaction(input) {
1517
+ const existing = this.database.prepare("SELECT * FROM push_events WHERE logical_key = ?")
1518
+ .get(input.logicalKey);
1519
+ if (existing !== undefined)
1520
+ return mapPushEvent(existing);
1521
+ const targets = input.subscriptionId === undefined
1522
+ ? this.database.prepare("SELECT id FROM push_subscriptions WHERE state = 'active' ORDER BY created_at, id")
1523
+ .all()
1524
+ : this.database.prepare("SELECT id FROM push_subscriptions WHERE id = ? AND state = 'active'")
1525
+ .all(input.subscriptionId);
1526
+ if (targets.length === 0)
1527
+ return undefined;
1528
+ const now = this.now();
1529
+ const expiry = new Date(input.expiresAt);
1530
+ if (!Number.isFinite(expiry.getTime()) || expiry.getTime() <= new Date(now).getTime()) {
1531
+ throw new WebConsoleError("invalid_push_event", "Notification expiry must be in the future.", 500);
1532
+ }
1533
+ const nextAttemptAt = input.notBefore ?? new Date(new Date(now).getTime() + 3_000).toISOString();
1534
+ const id = randomUUID();
1535
+ const title = webPushPreview(input.title, "mono-agent");
1536
+ const body = webPushPreview(input.body);
1537
+ const topic = createHash("sha256")
1538
+ .update(input.logicalKey)
1539
+ .digest("base64url")
1540
+ .slice(0, 32);
1541
+ const tag = `mono-agent-${id}`;
1542
+ this.database.prepare(`
1543
+ INSERT INTO push_events (
1544
+ id, logical_key, kind, thread_id, source_id, title, body, tag, topic, expires_at, created_at
1545
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1546
+ `).run(id, input.logicalKey, input.kind, input.threadId ?? null, input.sourceId ?? null, title, body, tag, topic, expiry.toISOString(), now);
1547
+ const insert = this.database.prepare(`
1548
+ INSERT INTO push_deliveries (
1549
+ event_id, subscription_id, status, attempts, next_attempt_at,
1550
+ last_status_code, last_error_code, created_at, updated_at, finished_at
1551
+ ) VALUES (?, ?, 'pending', 0, ?, NULL, NULL, ?, ?, NULL)
1552
+ `);
1553
+ for (const target of targets) {
1554
+ const pending = this.database.prepare(`
1555
+ SELECT COUNT(*) AS count FROM push_deliveries
1556
+ WHERE subscription_id = ? AND status IN ('pending', 'sending')
1557
+ `).get(target.id);
1558
+ const dropCount = Math.max(0, pending.count - MAX_PENDING_PUSH_DELIVERIES_PER_SUBSCRIPTION + 1);
1559
+ if (dropCount > 0) {
1560
+ this.database.prepare(`
1561
+ UPDATE push_deliveries SET status = 'dropped', updated_at = ?, finished_at = ?, last_error_code = 'queue_limit'
1562
+ WHERE rowid IN (
1563
+ SELECT rowid FROM push_deliveries
1564
+ WHERE subscription_id = ? AND status = 'pending'
1565
+ ORDER BY created_at, rowid LIMIT ?
1566
+ )
1567
+ `).run(now, now, target.id, dropCount);
1568
+ }
1569
+ insert.run(id, target.id, nextAttemptAt, now, now);
1570
+ }
1571
+ return {
1572
+ id,
1573
+ logicalKey: input.logicalKey,
1574
+ kind: input.kind,
1575
+ ...(input.threadId === undefined ? {} : { threadId: input.threadId }),
1576
+ ...(input.sourceId === undefined ? {} : { sourceId: input.sourceId }),
1577
+ title,
1578
+ body,
1579
+ tag,
1580
+ topic,
1581
+ expiresAt: expiry.toISOString(),
1582
+ createdAt: now,
1583
+ };
1584
+ }
1074
1585
  recordThreadRevision(threadId, event, now) {
1075
1586
  const row = this.database.prepare("SELECT revision FROM threads WHERE id = ?").get(threadId);
1076
1587
  this.database.prepare("INSERT INTO revisions (entity_kind, entity_id, revision, event, created_at) VALUES ('thread', ?, ?, ?, ?)")
@@ -1096,6 +1607,80 @@ export class WebStore {
1096
1607
  return this.clock().toISOString();
1097
1608
  }
1098
1609
  }
1610
+ function mapPushSubscriptionStatus(row) {
1611
+ const state = row.state === "disabled" || row.state === "expired"
1612
+ ? row.state
1613
+ : "active";
1614
+ return {
1615
+ id: row.id,
1616
+ state,
1617
+ keyFingerprint: row.key_fingerprint,
1618
+ createdAt: row.created_at,
1619
+ updatedAt: row.updated_at,
1620
+ ...(row.last_success_at === null ? {} : { lastSuccessAt: row.last_success_at }),
1621
+ ...(row.last_error_at === null ? {} : { lastErrorAt: row.last_error_at }),
1622
+ ...(row.last_error_code === null ? {} : { lastErrorCode: row.last_error_code }),
1623
+ };
1624
+ }
1625
+ function mapPushSubscription(row) {
1626
+ return {
1627
+ ...mapPushSubscriptionStatus(row),
1628
+ endpoint: row.endpoint,
1629
+ p256dh: row.p256dh,
1630
+ auth: row.auth,
1631
+ ...(row.expiration_time === null ? {} : { expirationTime: row.expiration_time }),
1632
+ siteOrigin: row.site_origin,
1633
+ };
1634
+ }
1635
+ function mapPushEvent(row) {
1636
+ const validKinds = [
1637
+ "response.ready",
1638
+ "input.required",
1639
+ "run.failed",
1640
+ "run.cancelled",
1641
+ "run.interrupted",
1642
+ "test",
1643
+ ];
1644
+ if (!validKinds.includes(row.kind)) {
1645
+ throw new WebConsoleError("storage_corrupt", `Push event ${row.id} has an invalid kind.`, 500);
1646
+ }
1647
+ return {
1648
+ id: row.id,
1649
+ logicalKey: row.logical_key,
1650
+ kind: row.kind,
1651
+ ...(row.thread_id === null ? {} : { threadId: row.thread_id }),
1652
+ ...(row.source_id === null ? {} : { sourceId: row.source_id }),
1653
+ title: row.title,
1654
+ body: row.body,
1655
+ tag: row.tag,
1656
+ topic: row.topic,
1657
+ expiresAt: row.expires_at,
1658
+ createdAt: row.created_at,
1659
+ };
1660
+ }
1661
+ function pushKeyFingerprint(publicKey) {
1662
+ return createHash("sha256").update(publicKey).digest("base64url");
1663
+ }
1664
+ function isBase64Url(value, minimum, maximum) {
1665
+ return value.length >= minimum && value.length <= maximum && /^[A-Za-z0-9_-]+$/u.test(value);
1666
+ }
1667
+ function isValidVapidKeyPair(publicKey, privateKey) {
1668
+ if (!isBase64Url(publicKey, 32, 128) || !isBase64Url(privateKey, 24, 96))
1669
+ return false;
1670
+ try {
1671
+ const decodedPublic = Buffer.from(publicKey, "base64url");
1672
+ const decodedPrivate = Buffer.from(privateKey, "base64url");
1673
+ if (decodedPublic.byteLength !== 65 || decodedPublic[0] !== 4 || decodedPrivate.byteLength !== 32)
1674
+ return false;
1675
+ const ecdh = createECDH("prime256v1");
1676
+ ecdh.setPrivateKey(decodedPrivate);
1677
+ const derivedPublic = ecdh.getPublicKey();
1678
+ return derivedPublic.byteLength === decodedPublic.byteLength && timingSafeEqual(derivedPublic, decodedPublic);
1679
+ }
1680
+ catch {
1681
+ return false;
1682
+ }
1683
+ }
1099
1684
  function threadSelectSql(suffix) {
1100
1685
  return `
1101
1686
  SELECT t.id, t.source_id, t.title, t.trigger_kind, t.archived_at, t.created_at, t.updated_at, t.revision,