@schlessera/brain-ui-server 0.17.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 (112) hide show
  1. package/README.md +1 -0
  2. package/dist/activity/digest.d.ts +29 -0
  3. package/dist/activity/digest.d.ts.map +1 -0
  4. package/dist/activity/digest.js +61 -0
  5. package/dist/activity/digest.js.map +1 -0
  6. package/dist/activity/notify.d.ts +62 -0
  7. package/dist/activity/notify.d.ts.map +1 -0
  8. package/dist/activity/notify.js +171 -0
  9. package/dist/activity/notify.js.map +1 -0
  10. package/dist/activity/push-sender.d.ts +63 -0
  11. package/dist/activity/push-sender.d.ts.map +1 -0
  12. package/dist/activity/push-sender.js +131 -0
  13. package/dist/activity/push-sender.js.map +1 -0
  14. package/dist/activity/query.d.ts +16 -0
  15. package/dist/activity/query.d.ts.map +1 -0
  16. package/dist/activity/query.js +128 -0
  17. package/dist/activity/query.js.map +1 -0
  18. package/dist/activity/recorder.d.ts +48 -0
  19. package/dist/activity/recorder.d.ts.map +1 -0
  20. package/dist/activity/recorder.js +205 -0
  21. package/dist/activity/recorder.js.map +1 -0
  22. package/dist/activity/runtime.d.ts +31 -0
  23. package/dist/activity/runtime.d.ts.map +1 -0
  24. package/dist/activity/runtime.js +109 -0
  25. package/dist/activity/runtime.js.map +1 -0
  26. package/dist/activity/span-sink.d.ts +22 -0
  27. package/dist/activity/span-sink.d.ts.map +1 -0
  28. package/dist/activity/span-sink.js +138 -0
  29. package/dist/activity/span-sink.js.map +1 -0
  30. package/dist/activity/store.d.ts +207 -0
  31. package/dist/activity/store.d.ts.map +1 -0
  32. package/dist/activity/store.js +440 -0
  33. package/dist/activity/store.js.map +1 -0
  34. package/dist/activity/stream.d.ts +45 -0
  35. package/dist/activity/stream.d.ts.map +1 -0
  36. package/dist/activity/stream.js +312 -0
  37. package/dist/activity/stream.js.map +1 -0
  38. package/dist/app.d.ts.map +1 -1
  39. package/dist/app.js +20 -1
  40. package/dist/app.js.map +1 -1
  41. package/dist/config/env.d.ts +5 -0
  42. package/dist/config/env.d.ts.map +1 -1
  43. package/dist/config/env.js +12 -0
  44. package/dist/config/env.js.map +1 -1
  45. package/dist/cron/scheduler.d.ts.map +1 -1
  46. package/dist/cron/scheduler.js +20 -5
  47. package/dist/cron/scheduler.js.map +1 -1
  48. package/dist/db/client.d.ts.map +1 -1
  49. package/dist/db/client.js +32 -6
  50. package/dist/db/client.js.map +1 -1
  51. package/dist/index.d.ts +4 -0
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +7 -0
  54. package/dist/index.js.map +1 -1
  55. package/dist/routes/activity.d.ts +20 -0
  56. package/dist/routes/activity.d.ts.map +1 -0
  57. package/dist/routes/activity.js +300 -0
  58. package/dist/routes/activity.js.map +1 -0
  59. package/dist/routes/push.d.ts +6 -0
  60. package/dist/routes/push.d.ts.map +1 -0
  61. package/dist/routes/push.js +73 -0
  62. package/dist/routes/push.js.map +1 -0
  63. package/dist/routes/sessions.d.ts.map +1 -1
  64. package/dist/routes/sessions.js +20 -4
  65. package/dist/routes/sessions.js.map +1 -1
  66. package/dist/ws/bridge.d.ts +2 -1
  67. package/dist/ws/bridge.d.ts.map +1 -1
  68. package/dist/ws/bridge.js +45 -3
  69. package/dist/ws/bridge.js.map +1 -1
  70. package/dist/ws/connection.d.ts.map +1 -1
  71. package/dist/ws/connection.js +41 -5
  72. package/dist/ws/connection.js.map +1 -1
  73. package/dist/ws/dispatch.d.ts.map +1 -1
  74. package/dist/ws/dispatch.js +11 -0
  75. package/dist/ws/dispatch.js.map +1 -1
  76. package/dist/ws/host.d.ts +16 -0
  77. package/dist/ws/host.d.ts.map +1 -1
  78. package/dist/ws/host.js +2 -0
  79. package/dist/ws/host.js.map +1 -1
  80. package/dist/ws/run-session.d.ts.map +1 -1
  81. package/dist/ws/run-session.js +25 -1
  82. package/dist/ws/run-session.js.map +1 -1
  83. package/dist/ws/turns.d.ts +22 -2
  84. package/dist/ws/turns.d.ts.map +1 -1
  85. package/dist/ws/turns.js +13 -0
  86. package/dist/ws/turns.js.map +1 -1
  87. package/migrations/007_activity.sql +114 -0
  88. package/migrations/008_push_subscriptions.sql +24 -0
  89. package/package.json +5 -3
  90. package/src/activity/digest.ts +92 -0
  91. package/src/activity/notify.ts +285 -0
  92. package/src/activity/push-sender.ts +215 -0
  93. package/src/activity/query.ts +160 -0
  94. package/src/activity/recorder.ts +261 -0
  95. package/src/activity/runtime.ts +141 -0
  96. package/src/activity/span-sink.ts +152 -0
  97. package/src/activity/store.ts +774 -0
  98. package/src/activity/stream.ts +366 -0
  99. package/src/app.ts +21 -1
  100. package/src/config/env.ts +19 -0
  101. package/src/cron/scheduler.ts +28 -8
  102. package/src/db/client.ts +31 -6
  103. package/src/index.ts +22 -0
  104. package/src/routes/activity.ts +363 -0
  105. package/src/routes/push.ts +90 -0
  106. package/src/routes/sessions.ts +24 -4
  107. package/src/ws/bridge.ts +47 -3
  108. package/src/ws/connection.ts +57 -5
  109. package/src/ws/dispatch.ts +13 -0
  110. package/src/ws/host.ts +18 -0
  111. package/src/ws/run-session.ts +32 -1
  112. package/src/ws/turns.ts +29 -1
@@ -0,0 +1,363 @@
1
+ import { Hono } from "hono";
2
+ import type { Database } from "bun:sqlite";
3
+ import {
4
+ isFailureOutcome,
5
+ type ActivityAggregate,
6
+ type ActivityRollups,
7
+ type ActivityRunDetail,
8
+ type ActivityRunRollup,
9
+ type ActivityRunSummary,
10
+ } from "@schlessera/brain-ui-sdk/protocol";
11
+
12
+ import { rowToRunRollup, type ActivityStore } from "../activity/store.js";
13
+ import { toWireEvent, toWireSpan } from "../activity/stream.js";
14
+ import type { ActivityNotifier } from "../activity/notify.js";
15
+ import {
16
+ dismissActivityDigest,
17
+ digestDismissedAt,
18
+ generateActivityDigest,
19
+ latestActivityDigest,
20
+ } from "../activity/digest.js";
21
+ import { getSetting, setSetting } from "../db/settings.js";
22
+
23
+ /**
24
+ * The activity record's read API. Auth-guarded like every /api route (the
25
+ * record leaks strictly more than /api/status, which is itself deliberately
26
+ * behind the guard).
27
+ *
28
+ * Resolution rules (origin R26): a run id that ever existed resolves — to
29
+ * the full span tree while detail is retained, to its rollup ("detail
30
+ * pruned") afterwards; only never-existed ids 404. Cost/token aggregates
31
+ * read per-run rollups, which carry ROOT-span accounting only.
32
+ */
33
+ export function createActivityRoutes(deps: {
34
+ db: Database;
35
+ store: ActivityStore;
36
+ notifier?: ActivityNotifier;
37
+ }): Hono {
38
+ const { db, store, notifier } = deps;
39
+
40
+ return new Hono()
41
+ .get("/activity/digest", (c) => {
42
+ try {
43
+ // An authenticated fetch IS the app opening — the marker the next
44
+ // digest window's framing leans on.
45
+ setSetting(db, "activity.lastVisitAt", Date.now());
46
+ return c.json({
47
+ digest: latestActivityDigest(db),
48
+ dismissedAt: digestDismissedAt(db),
49
+ });
50
+ } catch (err) {
51
+ return c.json(
52
+ { error: err instanceof Error ? err.message : "Failed to load digest" },
53
+ 500
54
+ );
55
+ }
56
+ })
57
+
58
+ .post("/activity/digest/dismiss", (c) => {
59
+ try {
60
+ dismissActivityDigest(db);
61
+ return c.json({ ok: true });
62
+ } catch (err) {
63
+ return c.json(
64
+ { error: err instanceof Error ? err.message : "Failed to dismiss" },
65
+ 500
66
+ );
67
+ }
68
+ })
69
+
70
+ .post("/activity/digest/generate", (c) => {
71
+ // Manual trigger (the cron job calls generateActivityDigest directly
72
+ // through the script; this exists for dev and for a pull-to-refresh).
73
+ try {
74
+ return c.json({ digest: generateActivityDigest(db) });
75
+ } catch (err) {
76
+ return c.json(
77
+ { error: err instanceof Error ? err.message : "Failed to generate" },
78
+ 500
79
+ );
80
+ }
81
+ })
82
+
83
+ .get("/activity/inbox", (c) => {
84
+ try {
85
+ return c.json({ intents: notifier?.inbox() ?? [] });
86
+ } catch (err) {
87
+ return c.json(
88
+ { error: err instanceof Error ? err.message : "Failed to list inbox" },
89
+ 500
90
+ );
91
+ }
92
+ })
93
+
94
+ .post("/activity/inbox/ack-all", (c) => {
95
+ try {
96
+ return c.json({ acknowledged: notifier?.acknowledgeAll() ?? 0 });
97
+ } catch (err) {
98
+ return c.json(
99
+ { error: err instanceof Error ? err.message : "Failed to acknowledge" },
100
+ 500
101
+ );
102
+ }
103
+ })
104
+
105
+ .post("/activity/inbox/:id/ack", (c) => {
106
+ const id = Number(c.req.param("id"));
107
+ if (!Number.isInteger(id) || id < 1) return c.json({ error: "Bad intent id" }, 400);
108
+ try {
109
+ const ok = notifier?.acknowledge(id) ?? false;
110
+ return ok ? c.json({ ok: true }) : c.json({ error: "Unknown intent" }, 404);
111
+ } catch (err) {
112
+ return c.json(
113
+ { error: err instanceof Error ? err.message : "Failed to acknowledge" },
114
+ 500
115
+ );
116
+ }
117
+ })
118
+ .get("/activity/runs", (c) => {
119
+ try {
120
+ const limit = Math.min(Math.max(Number(c.req.query("limit") ?? 50) || 50, 1), 200);
121
+ const before = Number(c.req.query("before")) || Date.now() + 1;
122
+ const origin = c.req.query("origin");
123
+ const job = c.req.query("job");
124
+ const session = c.req.query("session");
125
+ const status = c.req.query("status");
126
+
127
+ // Live first (open roots), then history from rollups. Rollups exist
128
+ // for every finished run (terminal writes upsert them), so one table
129
+ // serves the history list regardless of pruning state.
130
+ const live: ActivityRunSummary[] = store
131
+ .openRootSpans()
132
+ .filter((s) => !origin || s.origin === origin)
133
+ .filter((s) => !job || s.jobName === job)
134
+ .filter((s) => !session || s.sessionId === session)
135
+ .map((s) => ({
136
+ runId: s.runId,
137
+ origin: s.origin,
138
+ name: s.name,
139
+ sessionId: s.sessionId,
140
+ jobName: s.jobName,
141
+ startedAt: s.startedAt,
142
+ endedAt: null,
143
+ outcome: null,
144
+ running: true,
145
+ durationMs: null,
146
+ costUsd: s.usage.costUsd ?? null,
147
+ failureReason: null,
148
+ detailPruned: false,
149
+ }));
150
+
151
+ const filters: string[] = ["started_at < ?"];
152
+ const params: unknown[] = [before];
153
+ if (origin) {
154
+ filters.push("origin = ?");
155
+ params.push(origin);
156
+ }
157
+ if (job) {
158
+ filters.push("job_name = ?");
159
+ params.push(job);
160
+ }
161
+ if (session) {
162
+ filters.push("session_id = ?");
163
+ params.push(session);
164
+ }
165
+ if (status) {
166
+ filters.push("outcome = ?");
167
+ params.push(status);
168
+ }
169
+ if (live.length > 0) {
170
+ filters.push(`run_id NOT IN (${live.map(() => "?").join(", ")})`);
171
+ params.push(...live.map((r) => r.runId));
172
+ }
173
+ const history: ActivityRunSummary[] = (
174
+ db
175
+ .query(
176
+ `SELECT * FROM activity_run_rollups WHERE ${filters.join(" AND ")}
177
+ ORDER BY started_at DESC LIMIT ?`
178
+ )
179
+ .all(...(params as never[]), limit) as any[]
180
+ )
181
+ .map(rowToRunRollup)
182
+ .map((r) => ({
183
+ runId: r.runId,
184
+ origin: r.origin,
185
+ name: r.name,
186
+ sessionId: r.sessionId,
187
+ jobName: r.jobName,
188
+ startedAt: r.startedAt,
189
+ endedAt: r.endedAt,
190
+ outcome: r.outcome,
191
+ running: false,
192
+ durationMs: r.durationMs,
193
+ costUsd: r.costUsd,
194
+ failureReason: r.failureReason,
195
+ detailPruned: r.detailPruned,
196
+ }));
197
+
198
+ return c.json({ live, history });
199
+ } catch (err) {
200
+ return c.json(
201
+ { error: err instanceof Error ? err.message : "Failed to list activity" },
202
+ 500
203
+ );
204
+ }
205
+ })
206
+
207
+ .get("/activity/runs/:runId", (c) => {
208
+ const runId = c.req.param("runId");
209
+ try {
210
+ const snapshot = store.snapshotRun(runId);
211
+ if (snapshot) {
212
+ // Through the SAME wire mappers the live stream uses: a raw
213
+ // SpanRow serializes null fields where the wire contract omits
214
+ // them, which broke the client's `outcome === undefined`
215
+ // liveness test on REST-loaded runs.
216
+ const detail: ActivityRunDetail = {
217
+ runId,
218
+ detailPruned: false,
219
+ spans: snapshot.spans.map(toWireSpan),
220
+ events: snapshot.events.map(toWireEvent),
221
+ highWaterSeq: snapshot.highWaterSeq,
222
+ };
223
+ return c.json(detail);
224
+ }
225
+ const row = db
226
+ .query("SELECT * FROM activity_run_rollups WHERE run_id = ?")
227
+ .get(runId) as any;
228
+ if (!row) return c.json({ error: "Unknown run" }, 404);
229
+ const r = rowToRunRollup(row);
230
+ const rollup: ActivityRunRollup = {
231
+ origin: r.origin,
232
+ name: r.name,
233
+ sessionId: r.sessionId,
234
+ jobName: r.jobName,
235
+ startedAt: r.startedAt,
236
+ endedAt: r.endedAt,
237
+ outcome: r.outcome,
238
+ durationMs: r.durationMs,
239
+ spanCount: r.spanCount,
240
+ costUsd: r.costUsd,
241
+ failureReason: r.failureReason,
242
+ };
243
+ const detail: ActivityRunDetail = { runId, detailPruned: true, rollup };
244
+ return c.json(detail);
245
+ } catch (err) {
246
+ return c.json(
247
+ { error: err instanceof Error ? err.message : "Failed to load run" },
248
+ 500
249
+ );
250
+ }
251
+ })
252
+
253
+ .get("/activity/rollups", (c) => {
254
+ try {
255
+ const days = Math.min(Number(c.req.query("days") ?? 7) || 7, 90);
256
+ const since = Date.now() - days * 24 * 60 * 60 * 1000;
257
+ // The day boundary is the USER'S day, not UTC's — cron runs in UTC
258
+ // but nobody reviews spend in it. Configurable server-side.
259
+ const timeZone = getSetting<string>(db, "activity.timezone", "UTC");
260
+ const dayOf = makeDayFormatter(timeZone);
261
+
262
+ // Job/session aggregates group SQL-side; the per-day fold stays in
263
+ // JS because only Intl knows the configured timezone's day boundary.
264
+ const rows = db
265
+ .query(
266
+ `SELECT started_at, outcome, duration_ms, cost_usd, input_tokens,
267
+ output_tokens, cache_read_tokens, cache_creation_tokens
268
+ FROM activity_run_rollups WHERE started_at >= ?`
269
+ )
270
+ .all(since) as any[];
271
+ const byDay = new Map<string, ActivityAggregate>();
272
+ for (const r of rows) {
273
+ add(byDay, dayOf(r.started_at), r);
274
+ }
275
+
276
+ const rollups: ActivityRollups = {
277
+ timeZone,
278
+ days: [...byDay.entries()]
279
+ .map(([key, a]) => ({ day: key, ...a }))
280
+ .sort((a, b) => (a.day < b.day ? 1 : -1)),
281
+ jobs: groupedAggregates(db, since, "job_name").map(({ key, ...a }) => ({
282
+ jobName: key,
283
+ ...a,
284
+ })),
285
+ sessions: groupedAggregates(db, since, "session_id").map(({ key, ...a }) => ({
286
+ sessionId: key,
287
+ ...a,
288
+ })),
289
+ };
290
+ return c.json(rollups);
291
+ } catch (err) {
292
+ return c.json(
293
+ { error: err instanceof Error ? err.message : "Failed to aggregate" },
294
+ 500
295
+ );
296
+ }
297
+ });
298
+ }
299
+
300
+ /**
301
+ * SQL-side aggregation per group key. The failure predicate mirrors the
302
+ * SDK's `isFailureOutcome` — it cannot be shared into SQL, so keep the two
303
+ * in sync.
304
+ */
305
+ function groupedAggregates(
306
+ db: Database,
307
+ since: number,
308
+ column: "job_name" | "session_id"
309
+ ): Array<ActivityAggregate & { key: string }> {
310
+ return db
311
+ .query(
312
+ `SELECT ${column} AS key,
313
+ COUNT(*) AS runs,
314
+ SUM(CASE WHEN outcome IN ('error', 'timeout', 'interrupted') THEN 1 ELSE 0 END) AS failures,
315
+ SUM(COALESCE(cost_usd, 0)) AS costUsd,
316
+ SUM(COALESCE(input_tokens, 0)) AS inputTokens,
317
+ SUM(COALESCE(output_tokens, 0)) AS outputTokens,
318
+ SUM(COALESCE(cache_read_tokens, 0)) AS cacheReadTokens,
319
+ SUM(COALESCE(cache_creation_tokens, 0)) AS cacheCreationTokens,
320
+ SUM(COALESCE(duration_ms, 0)) AS durationMs
321
+ FROM activity_run_rollups
322
+ WHERE started_at >= ? AND ${column} IS NOT NULL
323
+ GROUP BY ${column}`
324
+ )
325
+ .all(since) as Array<ActivityAggregate & { key: string }>;
326
+ }
327
+
328
+ function add(map: Map<string, ActivityAggregate>, key: string, r: any): void {
329
+ const a =
330
+ map.get(key) ??
331
+ ({
332
+ runs: 0,
333
+ failures: 0,
334
+ costUsd: 0,
335
+ inputTokens: 0,
336
+ outputTokens: 0,
337
+ cacheReadTokens: 0,
338
+ cacheCreationTokens: 0,
339
+ durationMs: 0,
340
+ } satisfies ActivityAggregate);
341
+ a.runs += 1;
342
+ if (isFailureOutcome(r.outcome)) {
343
+ a.failures += 1;
344
+ }
345
+ a.costUsd += r.cost_usd ?? 0;
346
+ a.inputTokens += r.input_tokens ?? 0;
347
+ a.outputTokens += r.output_tokens ?? 0;
348
+ a.cacheReadTokens += r.cache_read_tokens ?? 0;
349
+ a.cacheCreationTokens += r.cache_creation_tokens ?? 0;
350
+ a.durationMs += r.duration_ms ?? 0;
351
+ map.set(key, a);
352
+ }
353
+
354
+ /** YYYY-MM-DD in the given zone; a bad zone degrades loudly to UTC once. */
355
+ function makeDayFormatter(timeZone: string): (ms: number) => string {
356
+ let fmt: Intl.DateTimeFormat;
357
+ try {
358
+ fmt = new Intl.DateTimeFormat("en-CA", { timeZone, dateStyle: "short" });
359
+ } catch {
360
+ fmt = new Intl.DateTimeFormat("en-CA", { timeZone: "UTC", dateStyle: "short" });
361
+ }
362
+ return (ms) => fmt.format(new Date(ms));
363
+ }
@@ -0,0 +1,90 @@
1
+ import { Hono } from "hono";
2
+ import { z } from "zod";
3
+
4
+ import type { PushSender } from "../activity/push-sender.js";
5
+
6
+ /**
7
+ * Push subscription lifecycle. Behind the auth guard by mount position —
8
+ * the public key is per-deployment (not build-time), and subscribing is a
9
+ * write into the notification fan-out, which only the authenticated user
10
+ * may do. The VAPID PRIVATE key has no route, here or anywhere.
11
+ */
12
+ const subscribeSchema = z.object({
13
+ subscription: z.object({
14
+ endpoint: z.url().max(2048),
15
+ keys: z.object({
16
+ p256dh: z.string().min(1).max(512),
17
+ auth: z.string().min(1).max(512),
18
+ }),
19
+ }),
20
+ label: z.string().max(120).optional(),
21
+ });
22
+
23
+ const unsubscribeSchema = z.object({
24
+ endpoint: z.url().max(2048),
25
+ });
26
+
27
+ export function createPushRoutes(deps: { sender: PushSender }): Hono {
28
+ const { sender } = deps;
29
+
30
+ return new Hono()
31
+ .get("/push/public-key", (c) => {
32
+ try {
33
+ return c.json({ publicKey: sender.publicKey() });
34
+ } catch (err) {
35
+ return c.json(
36
+ { error: err instanceof Error ? err.message : "Push unavailable" },
37
+ 500
38
+ );
39
+ }
40
+ })
41
+
42
+ .get("/push/subscriptions", (c) => {
43
+ try {
44
+ // Endpoints are capability URLs — list only metadata.
45
+ return c.json({
46
+ subscriptions: sender.subscriptions().map((s) => ({
47
+ label: s.label,
48
+ createdAt: s.createdAt,
49
+ lastUsedAt: s.lastUsedAt,
50
+ // Enough for the client to recognize its own registration.
51
+ endpointHash: hashEndpoint(s.endpoint),
52
+ })),
53
+ });
54
+ } catch (err) {
55
+ return c.json(
56
+ { error: err instanceof Error ? err.message : "Failed to list" },
57
+ 500
58
+ );
59
+ }
60
+ })
61
+
62
+ .post("/push/subscribe", async (c) => {
63
+ try {
64
+ const body = subscribeSchema.parse(await c.req.json());
65
+ sender.subscribe(body.subscription, body.label);
66
+ return c.json({ ok: true });
67
+ } catch (err) {
68
+ return c.json(
69
+ { error: err instanceof Error ? err.message : "Bad subscription" },
70
+ 400
71
+ );
72
+ }
73
+ })
74
+
75
+ .post("/push/unsubscribe", async (c) => {
76
+ try {
77
+ const body = unsubscribeSchema.parse(await c.req.json());
78
+ return c.json({ removed: sender.unsubscribe(body.endpoint) });
79
+ } catch (err) {
80
+ return c.json(
81
+ { error: err instanceof Error ? err.message : "Bad request" },
82
+ 400
83
+ );
84
+ }
85
+ });
86
+ }
87
+
88
+ function hashEndpoint(endpoint: string): string {
89
+ return new Bun.CryptoHasher("sha256").update(endpoint).digest("hex").slice(0, 16);
90
+ }
@@ -19,13 +19,33 @@ export function createSessionRoutes(deps: {
19
19
  .get("/sessions", async (c) => {
20
20
  try {
21
21
  const backends = await registry.getBackends();
22
+ // The catalog's accounting (total_cost_usd, num_turns) was write-only
23
+ // for years on the default backend, whose listSessions hardcodes
24
+ // zeros — merge the stored numbers in so the drawer's cost renders
25
+ // from what the server actually recorded.
26
+ const accounting = new Map(
27
+ (
28
+ db
29
+ .query("SELECT id, total_cost_usd AS cost, num_turns AS turns FROM sessions")
30
+ .all() as Array<{ id: string; cost: number | null; turns: number | null }>
31
+ ).map((r) => [r.id, r])
32
+ );
22
33
  const sessions = (
23
34
  await Promise.all(
24
35
  backends.map(async (backend) =>
25
- (await backend.listSessions()).map((session) => ({
26
- ...session,
27
- backendId: backend.id,
28
- }))
36
+ (await backend.listSessions()).map((session) => {
37
+ const stored = accounting.get(session.id);
38
+ return {
39
+ ...session,
40
+ ...(stored?.cost && session.totalCostUsd === 0
41
+ ? { totalCostUsd: stored.cost }
42
+ : {}),
43
+ ...(stored?.turns && session.numTurns === 0
44
+ ? { numTurns: stored.turns }
45
+ : {}),
46
+ backendId: backend.id,
47
+ };
48
+ })
29
49
  )
30
50
  )
31
51
  )
package/src/ws/bridge.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ ActivityQuery,
2
3
  BackendBridge,
3
4
  PermissionDecision,
4
5
  AskUserResult,
@@ -8,13 +9,15 @@ import { BackendBusyError, BackendRequestError } from "@schlessera/brain-ui-sdk/
8
9
  import { withTurnScope } from "./frames.js";
9
10
  import type { RunningTurn } from "./turns.js";
10
11
  import type { WsHost } from "./host.js";
12
+ import type { TurnRecorder } from "../activity/recorder.js";
11
13
 
12
14
  /** Build the per-turn bridge the backend drives. */
13
15
  export function makeBridge(
14
16
  host: WsHost,
15
17
  turn: RunningTurn,
16
18
  promptText: string,
17
- backendId: string
19
+ backendId: string,
20
+ recorder?: TurnRecorder
18
21
  ): BackendBridge {
19
22
  const { coordinator, catalog } = host;
20
23
  // Capture the turn identity at construction: the slot's turnId is re-minted
@@ -22,6 +25,7 @@ export function makeBridge(
22
25
  // through this bridge after its startTurn resolved. Stamping from the live
23
26
  // field would attribute those to the NEXT turn.
24
27
  const turnId = turn.turnId;
28
+ const queryActivity = host.activity?.query;
25
29
  return {
26
30
  emit: (message) => {
27
31
  let msg = message;
@@ -36,6 +40,11 @@ export function makeBridge(
36
40
  // later fails or is cancelled must not leave an unowned transcript.
37
41
  catalog.persistSessionStub(msg.sessionId, promptText, turn.providerId, backendId);
38
42
  }
43
+ // Persist-then-emit: the span write commits before the frame goes out,
44
+ // so a subscriber's snapshot can never be behind what it just saw live.
45
+ // Late frames through a previous turn's bridge stay un-recorded — the
46
+ // recorder belongs to ONE turn identity.
47
+ if (turn.turnId === turnId) recorder?.observeFrame(msg);
39
48
  host.sendToClients(withTurnScope(msg, turn, turnId));
40
49
  if (msg.type === "result") {
41
50
  // Only the live turn's own result may set its disposition — a late
@@ -52,6 +61,16 @@ export function makeBridge(
52
61
  }
53
62
  },
54
63
  requestPermission: (req) => {
64
+ if (!host.clients.hasClients()) {
65
+ // Not a failure — the card is parked and re-delivered on reconnect
66
+ // (see resendPendingInteractive) — but the wait was invisible before
67
+ // this line existed, and it is bounded only by the turn timeout.
68
+ host.log.emit({
69
+ severityText: "WARN",
70
+ body: "approval requested with no client connected; holding for reconnect",
71
+ attributes: { "tool.name": req.toolName, "toolUse.id": req.toolUseId },
72
+ });
73
+ }
55
74
  host.sendToClients(
56
75
  withTurnScope(
57
76
  {
@@ -77,9 +96,27 @@ export function makeBridge(
77
96
  resolve({ behavior: "deny", message: "Duplicate tool-approval id" });
78
97
  return;
79
98
  }
80
- coordinator.pendingApprovals.set(req.toolUseId, { turn, turnId, resolve });
99
+ // The decision stamps the wait/execution boundary on the tool span
100
+ // (grant) or lands the denied outcome (deny) before the backend's
101
+ // own error tool_result can mislabel it — write-once protects it.
102
+ const recorded = (decision: PermissionDecision) => {
103
+ recorder?.onApprovalDecision(req.toolUseId, decision.behavior === "allow");
104
+ resolve(decision);
105
+ };
106
+ coordinator.pendingApprovals.set(req.toolUseId, {
107
+ turn,
108
+ turnId,
109
+ request: req,
110
+ resolve: recorded,
111
+ });
81
112
  });
82
113
  },
114
+ ...(recorder
115
+ ? { activity: (event: Parameters<NonNullable<BackendBridge["activity"]>>[0]) => recorder.observeActivity(event) }
116
+ : {}),
117
+ ...(queryActivity
118
+ ? { queryActivity: async (query: ActivityQuery) => queryActivity(query) }
119
+ : {}),
83
120
  askUser: (requestId, questions) => {
84
121
  host.sendToClients(
85
122
  withTurnScope({ type: "ask_user_request", requestId, questions }, turn, turnId)
@@ -96,7 +133,14 @@ export function makeBridge(
96
133
  reject(new Error("Duplicate ask-user request id"));
97
134
  return;
98
135
  }
99
- coordinator.pendingAskUser.set(requestId, { turn, turnId, resolve, reject });
136
+ coordinator.pendingAskUser.set(requestId, {
137
+ turn,
138
+ turnId,
139
+ requestId,
140
+ questions,
141
+ resolve,
142
+ reject,
143
+ });
100
144
  });
101
145
  },
102
146
  getLocation: (options) => {
@@ -2,6 +2,45 @@ import { upgradeWebSocket, websocket } from "hono/bun";
2
2
  import { PROTOCOL_REV } from "@schlessera/brain-ui-sdk/protocol";
3
3
  import { parseClientMessage } from "@schlessera/brain-ui-sdk/schemas";
4
4
  import { withTurnScope } from "./frames.js";
5
+ import type { WSContext as WSContextType } from "./clients.js";
6
+
7
+ /**
8
+ * Re-send every pending approval and ask-user card to a client that just
9
+ * connected. These survive disconnects (see drainClientBoundForTurn) precisely
10
+ * so this re-delivery can happen: a phone that dropped its socket at screen
11
+ * lock reconnects and finds the card waiting instead of a dead turn. The
12
+ * frames carry their original turn scope, so answering them resolves the
13
+ * correct turn's promise through the normal dispatch path.
14
+ */
15
+ function resendPendingInteractive(host: WsHost, ws: WSContextType): void {
16
+ const { coordinator } = host;
17
+ for (const p of coordinator.pendingApprovals.values()) {
18
+ host.sendMessage(
19
+ ws,
20
+ withTurnScope(
21
+ {
22
+ type: "tool_approval_request",
23
+ toolUseId: p.request.toolUseId,
24
+ toolName: p.request.toolName,
25
+ input: p.request.input,
26
+ description: p.request.description,
27
+ },
28
+ p.turn,
29
+ p.turnId
30
+ )
31
+ );
32
+ }
33
+ for (const p of coordinator.pendingAskUser.values()) {
34
+ host.sendMessage(
35
+ ws,
36
+ withTurnScope(
37
+ { type: "ask_user_request", requestId: p.requestId, questions: p.questions },
38
+ p.turn,
39
+ p.turnId
40
+ )
41
+ );
42
+ }
43
+ }
5
44
  import { sendSessionHistory } from "./history.js";
6
45
  import { handleClientMessage, type ConnectionState } from "./dispatch.js";
7
46
  import type { WsHost } from "./host.js";
@@ -36,7 +75,14 @@ export function createWsHandlers(host: WsHost) {
36
75
  host.sendMessage(ws, {
37
76
  type: "server_hello",
38
77
  protocolRev: PROTOCOL_REV,
39
- capabilities: { multiSession: true, askUser: true, location: true },
78
+ capabilities: {
79
+ multiSession: true,
80
+ askUser: true,
81
+ location: true,
82
+ // Advertised only when this host records activity — a client on an
83
+ // activity-less host knows subscribing would be pointless.
84
+ ...(host.activity ? { activity: true } : {}),
85
+ },
40
86
  });
41
87
 
42
88
  // Snapshot-on-connect only for the single-running-session case (backward
@@ -88,6 +134,7 @@ export function createWsHandlers(host: WsHost) {
88
134
  turn
89
135
  )
90
136
  );
137
+ resendPendingInteractive(host, ws);
91
138
  }
92
139
  return;
93
140
  }
@@ -98,6 +145,7 @@ export function createWsHandlers(host: WsHost) {
98
145
  status: "idle",
99
146
  detail: `Connected to ${host.appName}`,
100
147
  });
148
+ resendPendingInteractive(host, ws);
101
149
  },
102
150
 
103
151
  onMessage(evt: MessageEvent, ws: WSContext) {
@@ -173,12 +221,16 @@ export function createWsHandlers(host: WsHost) {
173
221
  host.reportAbnormalClose(code);
174
222
  }
175
223
  host.clients.remove(ws);
176
- // Turns keep running in the background. Only reject pending interactive
177
- // requests once the LAST client leaves while another client remains it
178
- // can still answer them.
224
+ host.activity?.stream.dropConnection(ws);
225
+ // Turns keep running in the background. Once the LAST client leaves,
226
+ // reject only the requests that need a live client RIGHT NOW (location,
227
+ // mask). Approvals and ask-user cards survive the disconnect and are
228
+ // re-delivered on reconnect — a phone drops its socket at every screen
229
+ // lock, and denying pending approvals on that signal killed real work.
230
+ // The turn timeout remains their upper bound.
179
231
  if (host.clients.hasClients()) return;
180
232
  for (const turn of host.coordinator.running) {
181
- host.coordinator.drainPendingForTurn(turn, "Client disconnected");
233
+ host.coordinator.drainClientBoundForTurn(turn, "Client disconnected");
182
234
  }
183
235
  },
184
236
  };