@schlessera/brain-ui-server 0.18.0 → 0.19.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.
Files changed (101) hide show
  1. package/dist/activity/digest.d.ts +29 -0
  2. package/dist/activity/digest.d.ts.map +1 -0
  3. package/dist/activity/digest.js +61 -0
  4. package/dist/activity/digest.js.map +1 -0
  5. package/dist/activity/notify.d.ts +62 -0
  6. package/dist/activity/notify.d.ts.map +1 -0
  7. package/dist/activity/notify.js +171 -0
  8. package/dist/activity/notify.js.map +1 -0
  9. package/dist/activity/push-sender.d.ts +63 -0
  10. package/dist/activity/push-sender.d.ts.map +1 -0
  11. package/dist/activity/push-sender.js +131 -0
  12. package/dist/activity/push-sender.js.map +1 -0
  13. package/dist/activity/query.d.ts +16 -0
  14. package/dist/activity/query.d.ts.map +1 -0
  15. package/dist/activity/query.js +128 -0
  16. package/dist/activity/query.js.map +1 -0
  17. package/dist/activity/recorder.d.ts +48 -0
  18. package/dist/activity/recorder.d.ts.map +1 -0
  19. package/dist/activity/recorder.js +205 -0
  20. package/dist/activity/recorder.js.map +1 -0
  21. package/dist/activity/runtime.d.ts +31 -0
  22. package/dist/activity/runtime.d.ts.map +1 -0
  23. package/dist/activity/runtime.js +109 -0
  24. package/dist/activity/runtime.js.map +1 -0
  25. package/dist/activity/span-sink.d.ts +22 -0
  26. package/dist/activity/span-sink.d.ts.map +1 -0
  27. package/dist/activity/span-sink.js +138 -0
  28. package/dist/activity/span-sink.js.map +1 -0
  29. package/dist/activity/store.d.ts +207 -0
  30. package/dist/activity/store.d.ts.map +1 -0
  31. package/dist/activity/store.js +440 -0
  32. package/dist/activity/store.js.map +1 -0
  33. package/dist/activity/stream.d.ts +45 -0
  34. package/dist/activity/stream.d.ts.map +1 -0
  35. package/dist/activity/stream.js +312 -0
  36. package/dist/activity/stream.js.map +1 -0
  37. package/dist/app.d.ts.map +1 -1
  38. package/dist/app.js +16 -0
  39. package/dist/app.js.map +1 -1
  40. package/dist/cron/scheduler.d.ts.map +1 -1
  41. package/dist/cron/scheduler.js +20 -5
  42. package/dist/cron/scheduler.js.map +1 -1
  43. package/dist/db/client.d.ts.map +1 -1
  44. package/dist/db/client.js +32 -6
  45. package/dist/db/client.js.map +1 -1
  46. package/dist/index.d.ts +4 -0
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +7 -0
  49. package/dist/index.js.map +1 -1
  50. package/dist/routes/activity.d.ts +20 -0
  51. package/dist/routes/activity.d.ts.map +1 -0
  52. package/dist/routes/activity.js +300 -0
  53. package/dist/routes/activity.js.map +1 -0
  54. package/dist/routes/push.d.ts +6 -0
  55. package/dist/routes/push.d.ts.map +1 -0
  56. package/dist/routes/push.js +73 -0
  57. package/dist/routes/push.js.map +1 -0
  58. package/dist/routes/sessions.d.ts.map +1 -1
  59. package/dist/routes/sessions.js +20 -4
  60. package/dist/routes/sessions.js.map +1 -1
  61. package/dist/ws/bridge.d.ts +2 -1
  62. package/dist/ws/bridge.d.ts.map +1 -1
  63. package/dist/ws/bridge.js +27 -2
  64. package/dist/ws/bridge.js.map +1 -1
  65. package/dist/ws/connection.d.ts.map +1 -1
  66. package/dist/ws/connection.js +9 -1
  67. package/dist/ws/connection.js.map +1 -1
  68. package/dist/ws/dispatch.d.ts.map +1 -1
  69. package/dist/ws/dispatch.js +11 -0
  70. package/dist/ws/dispatch.js.map +1 -1
  71. package/dist/ws/host.d.ts +16 -0
  72. package/dist/ws/host.d.ts.map +1 -1
  73. package/dist/ws/host.js +2 -0
  74. package/dist/ws/host.js.map +1 -1
  75. package/dist/ws/run-session.d.ts.map +1 -1
  76. package/dist/ws/run-session.js +21 -1
  77. package/dist/ws/run-session.js.map +1 -1
  78. package/migrations/007_activity.sql +114 -0
  79. package/migrations/008_push_subscriptions.sql +24 -0
  80. package/package.json +5 -3
  81. package/src/activity/digest.ts +92 -0
  82. package/src/activity/notify.ts +285 -0
  83. package/src/activity/push-sender.ts +215 -0
  84. package/src/activity/query.ts +160 -0
  85. package/src/activity/recorder.ts +261 -0
  86. package/src/activity/runtime.ts +141 -0
  87. package/src/activity/span-sink.ts +152 -0
  88. package/src/activity/store.ts +774 -0
  89. package/src/activity/stream.ts +366 -0
  90. package/src/app.ts +17 -0
  91. package/src/cron/scheduler.ts +28 -8
  92. package/src/db/client.ts +31 -6
  93. package/src/index.ts +22 -0
  94. package/src/routes/activity.ts +363 -0
  95. package/src/routes/push.ts +90 -0
  96. package/src/routes/sessions.ts +24 -4
  97. package/src/ws/bridge.ts +29 -2
  98. package/src/ws/connection.ts +9 -1
  99. package/src/ws/dispatch.ts +13 -0
  100. package/src/ws/host.ts +18 -0
  101. package/src/ws/run-session.ts +28 -1
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Notification intents: the at-least-once layer between the activity record
3
+ * and every delivery channel.
4
+ *
5
+ * An intent is persisted when the triggering condition is DETECTED (a
6
+ * terminal failure span, a stuck run) and carries its own lifecycle:
7
+ * pending -> sent | send_failed | suppressed. The in-app inbox is the
8
+ * guaranteed tier — it lists unacknowledged intents regardless of delivery
9
+ * status — and web push (the accelerator) marks sent/send_failed on top.
10
+ * Undelivered intents survive restarts by construction; the boot/tick sweep
11
+ * simply finds them still pending.
12
+ *
13
+ * Detection runs on the server's always-on tick with its OWN change-log
14
+ * cursor (the live stream's cursor only advances for subscribers), so a
15
+ * foreign writer's failure — the cron wrapper recording an error while
16
+ * nobody watches — is noticed within one tick, not at the next app open.
17
+ *
18
+ * Storm safety: a run-scoped tag deduplicates (one active intent per tag),
19
+ * per-job debounce collapses repeats, and a global creation rate cap stops
20
+ * one bad API key from paging for every job at once.
21
+ */
22
+ import type { Database } from "bun:sqlite";
23
+ import type { Logger } from "@opentelemetry/api-logs";
24
+ import { isFailureOutcome } from "@schlessera/brain-ui-sdk/protocol";
25
+
26
+ import { getSetting } from "../db/settings.js";
27
+ import type { ActivityStore, SpanRow } from "./store.js";
28
+
29
+ export type IntentKind = "failure" | "completion" | "stuck";
30
+
31
+ export interface NotificationIntent {
32
+ id: number;
33
+ runId: string;
34
+ spanId: string | null;
35
+ kind: IntentKind;
36
+ tag: string;
37
+ title: string;
38
+ body: string;
39
+ status: "pending" | "sent" | "send_failed" | "suppressed";
40
+ acknowledged: boolean;
41
+ createdAt: number;
42
+ }
43
+
44
+ export interface ActivityNotifierDeps {
45
+ db: Database;
46
+ store: ActivityStore;
47
+ /** Was anyone watching this run/session when the event was recorded? */
48
+ isWatched: (scope: { runId?: string; sessionId?: string }) => boolean;
49
+ log?: Logger;
50
+ }
51
+
52
+ export interface ActivityNotifier {
53
+ /** Detect new terminal failures + stuck runs; create intents. */
54
+ tick(now?: number): void;
55
+ /** Unacknowledged intents, newest first (the inbox). */
56
+ inbox(limit?: number): NotificationIntent[];
57
+ /** Intents awaiting delivery (the push sender's queue). */
58
+ pending(limit?: number): NotificationIntent[];
59
+ markDelivered(id: number, status: "sent" | "send_failed"): void;
60
+ acknowledge(id: number): boolean;
61
+ acknowledgeAll(): number;
62
+ /** Retention: delete acknowledged intents untouched for longer than this. */
63
+ pruneAcknowledged(olderThanMs: number): number;
64
+ }
65
+
66
+ /** Global cap on intents CREATED per hour — failure-storm circuit. */
67
+ const MAX_INTENTS_PER_HOUR = 20;
68
+ /** Watchdog default: a live root run older than this is flagged as stuck. */
69
+ const DEFAULT_STUCK_THRESHOLD_MS = 45 * 60 * 1000;
70
+
71
+ export function createActivityNotifier(deps: ActivityNotifierDeps): ActivityNotifier {
72
+ const { db, store, isWatched, log } = deps;
73
+ let cursor = latestCursor(db);
74
+ /** Runs already flagged as stuck this process lifetime (tag also guards). */
75
+ const stuckFlagged = new Set<string>();
76
+
77
+ function latestCursor(database: Database): number {
78
+ const row = database
79
+ .query("SELECT COALESCE(MAX(change_id), 0) AS hi FROM activity_changes")
80
+ .get() as { hi: number };
81
+ return row.hi;
82
+ }
83
+
84
+ function createIntent(input: {
85
+ runId: string;
86
+ spanId?: string;
87
+ kind: IntentKind;
88
+ tag: string;
89
+ title: string;
90
+ body: string;
91
+ suppressed?: boolean;
92
+ }): void {
93
+ const now = Date.now();
94
+ // Tag dedupe: one live (unacknowledged) intent per tag — repeats coalesce.
95
+ const existing = db
96
+ .query(
97
+ "SELECT id FROM notification_intents WHERE tag = ? AND acknowledged = 0 LIMIT 1"
98
+ )
99
+ .get(input.tag);
100
+ if (existing) return;
101
+ // Global creation rate cap.
102
+ const recent = db
103
+ .query("SELECT COUNT(*) AS n FROM notification_intents WHERE created_at > ?")
104
+ .get(now - 60 * 60 * 1000) as { n: number };
105
+ const status =
106
+ input.suppressed || recent.n >= MAX_INTENTS_PER_HOUR ? "suppressed" : "pending";
107
+ db.query(
108
+ `INSERT INTO notification_intents
109
+ (run_id, span_id, kind, tag, title, body, status, acknowledged, created_at, updated_at)
110
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`
111
+ ).run(
112
+ input.runId,
113
+ input.spanId ?? null,
114
+ input.kind,
115
+ input.tag,
116
+ input.title,
117
+ input.body,
118
+ status,
119
+ now,
120
+ now
121
+ );
122
+ log?.emit({
123
+ severityText: "INFO",
124
+ body: "notification intent created",
125
+ attributes: { kind: input.kind, tag: input.tag, status },
126
+ });
127
+ }
128
+
129
+ function intentForSpan(span: SpanRow): void {
130
+ if (span.parentSpanId) return;
131
+ if (isFailureOutcome(span.outcome)) {
132
+ const label = span.jobName ?? span.name;
133
+ // "Unwatched" is evaluated when the failure is NOTICED — a live
134
+ // subscription to the run or its session means the user saw it happen,
135
+ // so the intent lands acknowledged-free but suppressed for delivery.
136
+ const watched = isWatched({
137
+ runId: span.runId,
138
+ ...(span.sessionId ? { sessionId: span.sessionId } : {}),
139
+ });
140
+ // Payload minimization: name + outcome word only. Lock screens render
141
+ // this outside the app's auth; detail is behind the tap-through.
142
+ createIntent({
143
+ runId: span.runId,
144
+ spanId: span.spanId,
145
+ kind: "failure",
146
+ tag: `failure:${span.jobName ?? span.sessionId ?? span.runId}`,
147
+ title: `${label} ${span.outcome === "interrupted" ? "was interrupted" : "failed"}`,
148
+ body: "Open to see the run.",
149
+ suppressed: watched,
150
+ });
151
+ return;
152
+ }
153
+ if (span.outcome === "success" && span.jobName) {
154
+ // Completion notifications are opt-in per job.
155
+ const optIn = getSetting<string[]>(db, "activity.notify.completions", [], log);
156
+ if (optIn.includes(span.jobName)) {
157
+ createIntent({
158
+ runId: span.runId,
159
+ spanId: span.spanId,
160
+ kind: "completion",
161
+ tag: `completion:${span.jobName}:${span.runId}`,
162
+ title: `${span.jobName} completed`,
163
+ body: "Open to see the run.",
164
+ });
165
+ }
166
+ }
167
+ }
168
+
169
+ return {
170
+ tick(now) {
171
+ try {
172
+ // New committed changes since our own cursor: terminal root spans
173
+ // among them become intents (SQL-side filter + join in the store).
174
+ // The head is read BEFORE the detection query so advancing past
175
+ // non-terminal changes can never skip a terminal write committed
176
+ // in between; a change seen twice is absorbed by the tag dedupe.
177
+ const head = latestCursor(db);
178
+ const rows = store.terminalRootChangesSince(cursor, 500);
179
+ for (const { span } of rows) intentForSpan(span);
180
+ const lastSeen = rows.length > 0 ? rows[rows.length - 1]!.changeId : cursor;
181
+ cursor = rows.length === 500 ? lastSeen : Math.max(head, lastSeen);
182
+
183
+ // Watchdog: an over-threshold LIVE root run is stuck — a signal, not
184
+ // an outcome. Threshold from settings, per-job override supported.
185
+ const defaultThreshold = getSetting<number>(
186
+ db,
187
+ "activity.watchdog.thresholdMs",
188
+ DEFAULT_STUCK_THRESHOLD_MS,
189
+ log
190
+ );
191
+ const overrides = getSetting<Record<string, number>>(
192
+ db,
193
+ "activity.watchdog.perJobMs",
194
+ {},
195
+ log
196
+ );
197
+ for (const span of store.findStuck(defaultThreshold, now)) {
198
+ if (stuckFlagged.has(span.runId)) continue;
199
+ const threshold = span.jobName ? (overrides[span.jobName] ?? defaultThreshold) : defaultThreshold;
200
+ if ((now ?? Date.now()) - span.startedAt < threshold) continue;
201
+ stuckFlagged.add(span.runId);
202
+ createIntent({
203
+ runId: span.runId,
204
+ spanId: span.spanId,
205
+ kind: "stuck",
206
+ tag: `stuck:${span.runId}`,
207
+ title: `${span.jobName ?? span.name} is taking unusually long`,
208
+ body: "Still running. Open to check on it.",
209
+ });
210
+ }
211
+ } catch (err) {
212
+ log?.emit({
213
+ severityText: "WARN",
214
+ body: "notifier tick failed",
215
+ attributes: { error: err instanceof Error ? err.message : String(err) },
216
+ });
217
+ }
218
+ },
219
+
220
+ inbox(limit = 50) {
221
+ return (
222
+ db
223
+ .query(
224
+ "SELECT * FROM notification_intents WHERE acknowledged = 0 ORDER BY created_at DESC LIMIT ?"
225
+ )
226
+ .all(limit) as any[]
227
+ ).map(rowToIntent);
228
+ },
229
+
230
+ pending(limit = 20) {
231
+ // Acknowledged means dismissed in the inbox — never push it later.
232
+ return (
233
+ db
234
+ .query(
235
+ "SELECT * FROM notification_intents WHERE status = 'pending' AND acknowledged = 0 ORDER BY created_at LIMIT ?"
236
+ )
237
+ .all(limit) as any[]
238
+ ).map(rowToIntent);
239
+ },
240
+
241
+ markDelivered(id, status) {
242
+ db.query("UPDATE notification_intents SET status = ?, updated_at = ? WHERE id = ?").run(
243
+ status,
244
+ Date.now(),
245
+ id
246
+ );
247
+ },
248
+
249
+ acknowledge(id) {
250
+ const res = db
251
+ .query("UPDATE notification_intents SET acknowledged = 1, updated_at = ? WHERE id = ?")
252
+ .run(Date.now(), id);
253
+ return res.changes > 0;
254
+ },
255
+
256
+ acknowledgeAll() {
257
+ const res = db
258
+ .query("UPDATE notification_intents SET acknowledged = 1, updated_at = ? WHERE acknowledged = 0")
259
+ .run(Date.now());
260
+ return res.changes;
261
+ },
262
+
263
+ pruneAcknowledged(olderThanMs) {
264
+ const res = db
265
+ .query("DELETE FROM notification_intents WHERE acknowledged = 1 AND updated_at < ?")
266
+ .run(Date.now() - olderThanMs);
267
+ return res.changes;
268
+ },
269
+ };
270
+ }
271
+
272
+ function rowToIntent(r: any): NotificationIntent {
273
+ return {
274
+ id: r.id,
275
+ runId: r.run_id,
276
+ spanId: r.span_id,
277
+ kind: r.kind,
278
+ tag: r.tag,
279
+ title: r.title,
280
+ body: r.body,
281
+ status: r.status,
282
+ acknowledged: r.acknowledged === 1,
283
+ createdAt: r.created_at,
284
+ };
285
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * The accelerator tier: web push delivery for pending notification intents.
3
+ *
4
+ * VAPID keys are generated at first boot into their own table (see migration
5
+ * 008 for why not the settings KV) and never leave the process. Payloads are
6
+ * MINIMIZED — title + outcome word + a deep link — because push renders on
7
+ * lock screens outside the app's auth; detail lives behind the authenticated
8
+ * tap-through.
9
+ *
10
+ * Delivery marks the intent sent/send_failed; the inbox (guaranteed tier)
11
+ * is untouched by any of it. A 404/410 response prunes the subscription —
12
+ * the push service says that device is gone. When every send in a pass
13
+ * fails, that is the all-devices signal (VAPID invalidated by a KV
14
+ * wipe/restore, typically) and it is logged loudly; the client surfaces a
15
+ * re-enable prompt when its subscription check disagrees with the server.
16
+ */
17
+ import type { Database } from "bun:sqlite";
18
+ import type { Logger } from "@opentelemetry/api-logs";
19
+ import webpush from "web-push";
20
+
21
+ import type { ActivityNotifier } from "./notify.js";
22
+
23
+ export interface PushSubscriptionRow {
24
+ endpoint: string;
25
+ p256dh: string;
26
+ auth: string;
27
+ label: string | null;
28
+ createdAt: number;
29
+ lastUsedAt: number | null;
30
+ }
31
+
32
+ export interface PushSender {
33
+ publicKey(): string;
34
+ subscribe(sub: { endpoint: string; keys: { p256dh: string; auth: string } }, label?: string): void;
35
+ unsubscribe(endpoint: string): boolean;
36
+ subscriptions(): PushSubscriptionRow[];
37
+ /** Deliver pending intents to every subscription. Returns sends attempted. */
38
+ deliverPending(notifier: ActivityNotifier): Promise<number>;
39
+ }
40
+
41
+ export interface CreatePushSenderOptions {
42
+ /** Injection seam for tests — the real webpush.sendNotification otherwise. */
43
+ send?: (
44
+ subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
45
+ payload: string,
46
+ options: {
47
+ vapidDetails: { subject: string; publicKey: string; privateKey: string };
48
+ timeout?: number;
49
+ }
50
+ ) => Promise<unknown>;
51
+ /** `mailto:` or https contact required by the VAPID spec. */
52
+ subject?: string;
53
+ log?: Logger;
54
+ }
55
+
56
+ /** Per-send cap: a hung push service rejects instead of wedging the
57
+ * delivering guard (the tick's reentrancy flag) forever. */
58
+ const SEND_TIMEOUT_MS = 10_000;
59
+
60
+ export function createPushSender(
61
+ db: Database,
62
+ options: CreatePushSenderOptions = {}
63
+ ): PushSender {
64
+ const log = options.log;
65
+ const subject = options.subject ?? "mailto:admin@localhost";
66
+ const send =
67
+ options.send ??
68
+ ((sub, payload, opts) =>
69
+ webpush.sendNotification(
70
+ { endpoint: sub.endpoint, keys: sub.keys },
71
+ payload,
72
+ { vapidDetails: opts.vapidDetails, timeout: opts.timeout }
73
+ ));
74
+
75
+ // Generate-once VAPID keys. Concurrent first boots are safe: the INSERT is
76
+ // OR IGNORE and the read-back wins.
77
+ function vapid(): { publicKey: string; privateKey: string } {
78
+ const row = db
79
+ .query("SELECT public_key AS publicKey, private_key AS privateKey FROM vapid_keys WHERE id = 1")
80
+ .get() as { publicKey: string; privateKey: string } | null;
81
+ if (row) return row;
82
+ const generated = webpush.generateVAPIDKeys();
83
+ db.query(
84
+ "INSERT OR IGNORE INTO vapid_keys (id, public_key, private_key, created_at) VALUES (1, ?, ?, ?)"
85
+ ).run(generated.publicKey, generated.privateKey, Date.now());
86
+ return vapid();
87
+ }
88
+
89
+ function rows(): PushSubscriptionRow[] {
90
+ return (
91
+ db.query("SELECT * FROM push_subscriptions ORDER BY created_at").all() as any[]
92
+ ).map((r) => ({
93
+ endpoint: r.endpoint,
94
+ p256dh: r.p256dh,
95
+ auth: r.auth,
96
+ label: r.label,
97
+ createdAt: r.created_at,
98
+ lastUsedAt: r.last_used_at,
99
+ }));
100
+ }
101
+
102
+ return {
103
+ publicKey() {
104
+ return vapid().publicKey;
105
+ },
106
+
107
+ subscribe(sub, label) {
108
+ db.query(
109
+ `INSERT INTO push_subscriptions (endpoint, p256dh, auth, label, created_at)
110
+ VALUES (?, ?, ?, ?, ?)
111
+ ON CONFLICT(endpoint) DO UPDATE SET p256dh = excluded.p256dh, auth = excluded.auth`
112
+ ).run(sub.endpoint, sub.keys.p256dh, sub.keys.auth, label ?? null, Date.now());
113
+ },
114
+
115
+ unsubscribe(endpoint) {
116
+ return db.query("DELETE FROM push_subscriptions WHERE endpoint = ?").run(endpoint)
117
+ .changes > 0;
118
+ },
119
+
120
+ subscriptions: rows,
121
+
122
+ async deliverPending(notifier) {
123
+ const pending = notifier.pending();
124
+ if (pending.length === 0) return 0;
125
+ if (rows().length === 0) {
126
+ // No devices: the inbox already has it; nothing to deliver. Leave
127
+ // the intents pending so a device subscribing later still gets them
128
+ // if they are fresh, and the boot sweep can retry.
129
+ return 0;
130
+ }
131
+ const keys = vapid();
132
+ let attempts = 0;
133
+
134
+ for (const intent of pending) {
135
+ // Re-read per intent: an endpoint pruned as dead during the previous
136
+ // intent's pass must not be attempted again in this one.
137
+ const subs = rows();
138
+ if (subs.length === 0) break;
139
+ // Minimized payload; the tag coalesces repeats per run across devices.
140
+ const payload = JSON.stringify({
141
+ title: intent.title,
142
+ body: intent.body,
143
+ tag: `brain-activity:${intent.runId}`,
144
+ url: `/#/activity/${encodeURIComponent(intent.runId)}`,
145
+ });
146
+ attempts += subs.length;
147
+ const results = await Promise.allSettled(
148
+ subs.map((sub) =>
149
+ send(
150
+ { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
151
+ payload,
152
+ {
153
+ vapidDetails: { subject, publicKey: keys.publicKey, privateKey: keys.privateKey },
154
+ timeout: SEND_TIMEOUT_MS,
155
+ }
156
+ )
157
+ )
158
+ );
159
+ // DB writes only after the whole settled pass — never concurrent
160
+ // with in-flight sends against the same handle.
161
+ let delivered = 0;
162
+ const touched: string[] = [];
163
+ const dead: string[] = [];
164
+ results.forEach((result, i) => {
165
+ const sub = subs[i]!;
166
+ if (result.status === "fulfilled") {
167
+ delivered++;
168
+ touched.push(sub.endpoint);
169
+ return;
170
+ }
171
+ const err = result.reason;
172
+ const status = (err as { statusCode?: number }).statusCode;
173
+ if (status === 404 || status === 410) {
174
+ // The push service says this device is gone.
175
+ dead.push(sub.endpoint);
176
+ log?.emit({
177
+ severityText: "INFO",
178
+ body: "pruned dead push subscription",
179
+ attributes: { status: status },
180
+ });
181
+ } else {
182
+ log?.emit({
183
+ severityText: "WARN",
184
+ body: "push send failed",
185
+ attributes: {
186
+ error: err instanceof Error ? err.message : String(err),
187
+ ...(status ? { status } : {}),
188
+ },
189
+ });
190
+ }
191
+ });
192
+ for (const endpoint of touched) {
193
+ db.query("UPDATE push_subscriptions SET last_used_at = ? WHERE endpoint = ?").run(
194
+ Date.now(),
195
+ endpoint
196
+ );
197
+ }
198
+ for (const endpoint of dead) {
199
+ db.query("DELETE FROM push_subscriptions WHERE endpoint = ?").run(endpoint);
200
+ }
201
+ notifier.markDelivered(intent.id, delivered > 0 ? "sent" : "send_failed");
202
+ if (delivered === 0) {
203
+ // Every device failed — the all-devices signal (invalid VAPID after
204
+ // a restore, typically). Loud, because it is silent on every phone.
205
+ log?.emit({
206
+ severityText: "ERROR",
207
+ body: "push delivery failed on every subscription",
208
+ attributes: { subscriptions: subs.length },
209
+ });
210
+ }
211
+ }
212
+ return attempts;
213
+ },
214
+ };
215
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The agent-facing read over the activity record — the same data the UI
3
+ * reads, shaped for model consumption and served through the backend
4
+ * bridge's `queryActivity` seam (the MCP tool calls it).
5
+ *
6
+ * Read-only by construction: nothing here writes. Free-text fields that
7
+ * originated outside the primary agent's own context (failure reasons /
8
+ * stderr tails, subagent transcript excerpts) are DATA, not instructions —
9
+ * the tool layer labels them as such before they reach a model.
10
+ */
11
+ import type { Database } from "bun:sqlite";
12
+ import type { ActivityQuery, ActivityQueryResult } from "@schlessera/brain-ui-sdk/server";
13
+ import { isFailureOutcome } from "@schlessera/brain-ui-sdk/protocol";
14
+
15
+ import { latestActivityDigest } from "./digest.js";
16
+ import type { ActivityNotifier } from "./notify.js";
17
+ import { rowToRunRollup, type ActivityStore, type SpanRow } from "./store.js";
18
+
19
+ export function runActivityQuery(
20
+ db: Database,
21
+ store: ActivityStore,
22
+ query: ActivityQuery,
23
+ notifier?: ActivityNotifier
24
+ ): ActivityQueryResult {
25
+ const limit = Math.min(Math.max(query.limit ?? 20, 1), 100);
26
+ const hoursBack = Math.min(Math.max(query.hoursBack ?? 24, 1), 24 * 90);
27
+ const since = Date.now() - hoursBack * 60 * 60 * 1000;
28
+
29
+ switch (query.scope) {
30
+ case "running": {
31
+ return {
32
+ running: store.openRootSpans().map(liveSummary),
33
+ };
34
+ }
35
+
36
+ case "recent": {
37
+ const rows = (
38
+ db
39
+ .query(
40
+ "SELECT * FROM activity_run_rollups WHERE started_at >= ? ORDER BY started_at DESC LIMIT ?"
41
+ )
42
+ .all(since, limit) as any[]
43
+ ).map(rowToRunRollup);
44
+ return {
45
+ windowHours: hoursBack,
46
+ running: store.openRootSpans().map(liveSummary),
47
+ finished: rows.map((r) => ({
48
+ runId: r.runId,
49
+ origin: r.origin,
50
+ name: r.name,
51
+ jobName: r.jobName,
52
+ sessionId: r.sessionId,
53
+ startedAt: iso(r.startedAt),
54
+ outcome: r.outcome,
55
+ durationMs: r.durationMs,
56
+ costUsd: r.costUsd,
57
+ failureReason: r.failureReason,
58
+ })),
59
+ };
60
+ }
61
+
62
+ case "run": {
63
+ if (!query.runId) return { error: "scope 'run' needs runId" };
64
+ const snapshot = store.snapshotRun(query.runId);
65
+ if (snapshot) {
66
+ return {
67
+ runId: query.runId,
68
+ spans: snapshot.spans.map((s) => ({
69
+ spanId: s.spanId,
70
+ parent: s.parentSpanId,
71
+ name: s.name,
72
+ kind: s.kind,
73
+ startedAt: iso(s.startedAt),
74
+ endedAt: s.endedAt ? iso(s.endedAt) : null,
75
+ outcome: s.outcome,
76
+ reason: s.outcomeReason,
77
+ costUsd: s.usage.costUsd,
78
+ tokens:
79
+ s.usage.inputTokens !== undefined || s.usage.outputTokens !== undefined
80
+ ? { in: s.usage.inputTokens, out: s.usage.outputTokens }
81
+ : undefined,
82
+ })),
83
+ };
84
+ }
85
+ const row = db
86
+ .query("SELECT * FROM activity_run_rollups WHERE run_id = ?")
87
+ .get(query.runId) as any;
88
+ if (!row) return { error: `unknown run ${query.runId}` };
89
+ const rollup = rowToRunRollup(row);
90
+ return {
91
+ runId: query.runId,
92
+ detailPruned: true,
93
+ rollup: {
94
+ name: rollup.name,
95
+ jobName: rollup.jobName,
96
+ outcome: rollup.outcome,
97
+ startedAt: iso(rollup.startedAt),
98
+ durationMs: rollup.durationMs,
99
+ costUsd: rollup.costUsd,
100
+ failureReason: rollup.failureReason,
101
+ },
102
+ };
103
+ }
104
+
105
+ case "rollups": {
106
+ const rows = (
107
+ db
108
+ .query("SELECT * FROM activity_run_rollups WHERE started_at >= ?")
109
+ .all(since) as any[]
110
+ ).map(rowToRunRollup);
111
+ const digest = latestActivityDigest(db);
112
+ return {
113
+ windowHours: hoursBack,
114
+ runs: rows.length,
115
+ failures: rows.filter((r) => isFailureOutcome(r.outcome)).length,
116
+ costUsd: round(rows.reduce((a, r) => a + (r.costUsd ?? 0), 0)),
117
+ inputTokens: rows.reduce((a, r) => a + (r.inputTokens ?? 0), 0),
118
+ outputTokens: rows.reduce((a, r) => a + (r.outputTokens ?? 0), 0),
119
+ ...(digest ? { lastDigestAt: iso(digest.generatedAt) } : {}),
120
+ };
121
+ }
122
+
123
+ case "inbox": {
124
+ // The unacknowledged notification inbox — the same list the UI shows.
125
+ if (!notifier) return { error: "inbox unavailable" };
126
+ return {
127
+ intents: notifier.inbox(limit).map((i) => ({
128
+ kind: i.kind,
129
+ title: i.title,
130
+ status: i.status,
131
+ createdAt: iso(i.createdAt),
132
+ runId: i.runId,
133
+ })),
134
+ };
135
+ }
136
+
137
+ default:
138
+ return { error: `unknown scope` };
139
+ }
140
+ }
141
+
142
+ function liveSummary(span: SpanRow) {
143
+ return {
144
+ runId: span.runId,
145
+ origin: span.origin,
146
+ name: span.name,
147
+ jobName: span.jobName,
148
+ sessionId: span.sessionId,
149
+ startedAt: iso(span.startedAt),
150
+ elapsedMs: Date.now() - span.startedAt,
151
+ };
152
+ }
153
+
154
+ function iso(ms: number): string {
155
+ return new Date(ms).toISOString();
156
+ }
157
+
158
+ function round(n: number): number {
159
+ return Math.round(n * 10000) / 10000;
160
+ }