@schlessera/brain-ui-server 0.19.0 → 0.21.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 (78) hide show
  1. package/README.md +5 -1
  2. package/data/model-prices.json +63 -0
  3. package/dist/activity/digest.d.ts.map +1 -1
  4. package/dist/activity/digest.js +30 -2
  5. package/dist/activity/digest.js.map +1 -1
  6. package/dist/activity/notify.d.ts.map +1 -1
  7. package/dist/activity/notify.js +26 -4
  8. package/dist/activity/notify.js.map +1 -1
  9. package/dist/activity/query.d.ts.map +1 -1
  10. package/dist/activity/query.js +28 -1
  11. package/dist/activity/query.js.map +1 -1
  12. package/dist/activity/recorder.d.ts +5 -0
  13. package/dist/activity/recorder.d.ts.map +1 -1
  14. package/dist/activity/recorder.js +37 -4
  15. package/dist/activity/recorder.js.map +1 -1
  16. package/dist/activity/runtime.d.ts +7 -1
  17. package/dist/activity/runtime.d.ts.map +1 -1
  18. package/dist/activity/runtime.js +28 -10
  19. package/dist/activity/runtime.js.map +1 -1
  20. package/dist/activity/store.d.ts +51 -2
  21. package/dist/activity/store.d.ts.map +1 -1
  22. package/dist/activity/store.js +236 -19
  23. package/dist/activity/store.js.map +1 -1
  24. package/dist/activity/stream.d.ts +6 -2
  25. package/dist/activity/stream.d.ts.map +1 -1
  26. package/dist/activity/stream.js +42 -1
  27. package/dist/activity/stream.js.map +1 -1
  28. package/dist/agent/backend.d.ts +13 -4
  29. package/dist/agent/backend.d.ts.map +1 -1
  30. package/dist/agent/backend.js +68 -5
  31. package/dist/agent/backend.js.map +1 -1
  32. package/dist/app.d.ts.map +1 -1
  33. package/dist/app.js +18 -3
  34. package/dist/app.js.map +1 -1
  35. package/dist/config/env.d.ts +36 -0
  36. package/dist/config/env.d.ts.map +1 -1
  37. package/dist/config/env.js +66 -2
  38. package/dist/config/env.js.map +1 -1
  39. package/dist/db/settings.d.ts +15 -0
  40. package/dist/db/settings.d.ts.map +1 -1
  41. package/dist/db/settings.js +43 -0
  42. package/dist/db/settings.js.map +1 -1
  43. package/dist/index.d.ts +1 -1
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/pricing/model-pricing.d.ts +61 -0
  47. package/dist/pricing/model-pricing.d.ts.map +1 -0
  48. package/dist/pricing/model-pricing.js +379 -0
  49. package/dist/pricing/model-pricing.js.map +1 -0
  50. package/dist/routes/activity.d.ts.map +1 -1
  51. package/dist/routes/activity.js +40 -32
  52. package/dist/routes/activity.js.map +1 -1
  53. package/dist/routes/models.d.ts +6 -0
  54. package/dist/routes/models.d.ts.map +1 -1
  55. package/dist/routes/models.js +38 -3
  56. package/dist/routes/models.js.map +1 -1
  57. package/dist/ws/run-session.d.ts.map +1 -1
  58. package/dist/ws/run-session.js +36 -2
  59. package/dist/ws/run-session.js.map +1 -1
  60. package/migrations/009_activity_followups.sql +22 -0
  61. package/migrations/010_effective_cost.sql +21 -0
  62. package/package.json +4 -3
  63. package/src/activity/digest.ts +30 -2
  64. package/src/activity/notify.ts +30 -8
  65. package/src/activity/query.ts +28 -1
  66. package/src/activity/recorder.ts +57 -5
  67. package/src/activity/runtime.ts +41 -13
  68. package/src/activity/store.ts +316 -21
  69. package/src/activity/stream.ts +39 -9
  70. package/src/agent/backend.ts +91 -10
  71. package/src/app.ts +19 -3
  72. package/src/config/env.ts +90 -2
  73. package/src/db/settings.ts +56 -0
  74. package/src/index.ts +1 -0
  75. package/src/pricing/model-pricing.ts +497 -0
  76. package/src/routes/activity.ts +41 -33
  77. package/src/routes/models.ts +57 -6
  78. package/src/ws/run-session.ts +45 -3
@@ -13,12 +13,13 @@ import type { Database } from "bun:sqlite";
13
13
  import type { Logger } from "@opentelemetry/api-logs";
14
14
  import type { ActivityQuery, ActivityQueryResult } from "@schlessera/brain-ui-sdk/server";
15
15
 
16
- import { createActivityStore, type ActivityStore } from "./store.js";
16
+ import { createActivityStore, type ActivityStore, type RollupPricing } from "./store.js";
17
17
  import { createActivityStream, type ActivityStream } from "./stream.js";
18
18
  import { createActivityNotifier, type ActivityNotifier } from "./notify.js";
19
19
  import { createPushSender, type PushSender } from "./push-sender.js";
20
20
  import { digestRetentionFloor } from "./digest.js";
21
21
  import { runActivityQuery } from "./query.js";
22
+ import { getDetailRetentionDays } from "../db/settings.js";
22
23
 
23
24
  // Activity lifecycle cadence. The stale threshold must comfortably exceed
24
25
  // the cron wrapper's heartbeat interval (~30s) so a live writer is never
@@ -43,12 +44,33 @@ export interface ActivityRuntime {
43
44
 
44
45
  export function createActivityRuntime(
45
46
  db: Database,
46
- deps: { log: Logger }
47
+ deps: {
48
+ log: Logger;
49
+ /**
50
+ * The app's shared model-pricing instance, for rollup-time effective
51
+ * cost. Optional so embedders without one fall back to the store's own
52
+ * env-derived default (the same instance shape, just not shared).
53
+ */
54
+ pricing?: RollupPricing;
55
+ }
47
56
  ): ActivityRuntime {
48
57
  const { log } = deps;
49
- const store = createActivityStore(db);
58
+ const store = createActivityStore(db, deps.pricing ? { pricing: deps.pricing } : {});
50
59
  const stream = createActivityStream(store, log);
51
60
 
61
+ // The notifier is created BEFORE the boot sweep: its change cursor starts
62
+ // at the current head, so the terminal changes the sweep writes are above
63
+ // it and the first tick turns restart-interrupted runs into failure
64
+ // intents. Created after, the sweep's changes would sit below the cursor
65
+ // and a turn killed by a restart would never be notified.
66
+ const notifier = createActivityNotifier({
67
+ db,
68
+ store,
69
+ isWatched: (scope) => stream.isWatched(scope),
70
+ log,
71
+ });
72
+ const pushSender = createPushSender(db, { log });
73
+
52
74
  // Boot sweep: close this server's orphans from a previous life (interrupted).
53
75
  try {
54
76
  const orphans = store.sweepOwnOrphans();
@@ -67,14 +89,6 @@ export function createActivityRuntime(
67
89
  });
68
90
  }
69
91
 
70
- const notifier = createActivityNotifier({
71
- db,
72
- store,
73
- isWatched: (scope) => stream.isWatched(scope),
74
- log,
75
- });
76
- const pushSender = createPushSender(db, { log });
77
-
78
92
  let delivering = false;
79
93
  let lastPrune = 0;
80
94
  const tick = setInterval(() => {
@@ -108,10 +122,24 @@ export function createActivityRuntime(
108
122
  }
109
123
  if (Date.now() - lastPrune > ACTIVITY_PRUNE_INTERVAL_MS) {
110
124
  lastPrune = Date.now();
125
+ // Self-heal the pricing table on server traffic: without this, a
126
+ // long-lived server rolls up from whatever the boot-time refresh
127
+ // fetched. Single-flight + TTL inside ensureFresh make the hourly
128
+ // call free when fresh. The rollup path itself stays synchronous —
129
+ // never refresh from inside the store.
130
+ const pricing = deps.pricing as
131
+ | { ensureFresh?: () => Promise<void> }
132
+ | undefined;
133
+ if (typeof pricing?.ensureFresh === "function") {
134
+ void pricing.ensureFresh().catch(() => {});
135
+ }
111
136
  store.prune({
112
- // Full detail survives until the digest has covered it; the hard
113
- // ceiling bounds growth even if the digest job silently dies.
137
+ // Full detail survives until the digest has covered it AND the
138
+ // retention window has passed; the hard ceiling bounds growth even
139
+ // if the digest job silently dies. The setting is read every pass
140
+ // so a change applies without a restart.
114
141
  digestFloorAt: digestRetentionFloor(db),
142
+ detailRetentionMs: getDetailRetentionDays(db, log) * 24 * 60 * 60 * 1000,
115
143
  hardCeilingMs: ACTIVITY_HARD_CEILING_MS,
116
144
  });
117
145
  notifier.pruneAcknowledged(INTENT_RETENTION_MS);
@@ -31,6 +31,13 @@
31
31
  * loud one.
32
32
  */
33
33
  import type { Database } from "bun:sqlite";
34
+ import { isBillingMode, type BillingMode } from "@schlessera/brain-ui-sdk/protocol";
35
+
36
+ import {
37
+ resolveAmbientBillingMode,
38
+ resolveStandalonePricingConfig,
39
+ } from "../config/env.js";
40
+ import { createModelPricing, type PricingRates } from "../pricing/model-pricing.js";
34
41
 
35
42
  export const SPAN_OUTCOMES = [
36
43
  "success",
@@ -101,6 +108,16 @@ export interface RunRollupRow {
101
108
  cacheReadTokens: number | null;
102
109
  cacheCreationTokens: number | null;
103
110
  costUsd: number | null;
111
+ /**
112
+ * What the run actually cost, frozen at first computation (migration 010):
113
+ * 0 for subscription-billed work regardless of tokens, priced usage for
114
+ * api-billed. NULL = unknown, 0 = genuinely free.
115
+ */
116
+ effectiveCostUsd: number | null;
117
+ /** Billing classification behind `effectiveCostUsd`; NULL = unknown. */
118
+ billingMode: BillingMode | null;
119
+ /** True when the effective cost was computed from estimated rates. */
120
+ pricingEstimate: boolean | null;
104
121
  failureReason: string | null;
105
122
  detailPruned: boolean;
106
123
  }
@@ -123,11 +140,34 @@ export function rowToRunRollup(r: any): RunRollupRow {
123
140
  cacheReadTokens: r.cache_read_tokens,
124
141
  cacheCreationTokens: r.cache_creation_tokens,
125
142
  costUsd: r.cost_usd,
143
+ effectiveCostUsd: r.effective_cost_usd ?? null,
144
+ // Migration 010 CHECKs this column, but a row written around them (older
145
+ // binary, manual edit) must degrade to unknown, never to a wrong mode.
146
+ billingMode: isBillingMode(r.billing_mode) ? r.billing_mode : null,
147
+ pricingEstimate: r.pricing_estimate == null ? null : r.pricing_estimate === 1,
126
148
  failureReason: r.failure_reason,
127
149
  detailPruned: r.detail_pruned === 1,
128
150
  };
129
151
  }
130
152
 
153
+ /**
154
+ * Sum-of-KNOWNS over effective costs: unknown (NULL) rows contribute nothing
155
+ * to the sum and are counted instead, so no aggregate can pass an unknown off
156
+ * as $0 (AE3). One definition for every reader that folds rollup rows.
157
+ */
158
+ export function sumEffectiveCost(rows: Array<{ effectiveCostUsd: number | null }>): {
159
+ effectiveCostUsd: number;
160
+ unpricedRuns: number;
161
+ } {
162
+ let effectiveCostUsd = 0;
163
+ let unpricedRuns = 0;
164
+ for (const row of rows) {
165
+ if (row.effectiveCostUsd === null) unpricedRuns += 1;
166
+ else effectiveCostUsd += row.effectiveCostUsd;
167
+ }
168
+ return { effectiveCostUsd, unpricedRuns };
169
+ }
170
+
131
171
  /** One committed write, as the delta stream sees it. */
132
172
  export type ActivityChange =
133
173
  | { changeId: number; runId: string; seq: number; kind: "span"; span: SpanRow }
@@ -169,6 +209,10 @@ export interface PruneOptions {
169
209
  /** Spans of runs that ended before this are prunable (digest floor).
170
210
  * Absolute epoch ms, not an offset. */
171
211
  digestFloorAt: number;
212
+ /** Minimum age (ms since ended) a run must reach before the digest floor
213
+ * may prune its detail. 0 reproduces floor-only pruning. The hard
214
+ * ceiling ignores it. */
215
+ detailRetentionMs: number;
172
216
  /** Runs older than this are pruned REGARDLESS of the digest floor. */
173
217
  hardCeilingMs: number;
174
218
  now?: number;
@@ -178,7 +222,6 @@ export interface PruneOptions {
178
222
 
179
223
  /** A single event payload is capped so no delta can approach the WS frame cap. */
180
224
  export const MAX_EVENT_PAYLOAD_BYTES = 16_384;
181
- export const TRUNCATION_MARKER = "…[truncated]";
182
225
 
183
226
  export interface ActivityStore {
184
227
  readonly writer: string;
@@ -195,7 +238,19 @@ export interface ActivityStore {
195
238
  spanId: string,
196
239
  patch: { attrs?: Record<string, unknown>; usage?: SpanUsage; waitUntil?: number }
197
240
  ): boolean;
198
- appendEvent(spanId: string, eventType: string, payload: unknown, ts?: number): SpanEventRow | null;
241
+ /**
242
+ * Append one event; null (nothing written) for an unknown span. `cap`
243
+ * lowers the payload size cap for this call — the store's 16 KB invariant
244
+ * is the ceiling regardless. A capped payload is clipped and flagged
245
+ * `truncated`.
246
+ */
247
+ appendEvent(
248
+ spanId: string,
249
+ eventType: string,
250
+ payload: unknown,
251
+ ts?: number,
252
+ cap?: number
253
+ ): SpanEventRow | null;
199
254
  /** External writers touch their root span so staleness is heartbeat-age based. */
200
255
  heartbeat(spanId: string, at?: number): void;
201
256
  /** Close every open span of a run as `outcome` (children first), reason on all. */
@@ -227,9 +282,27 @@ export interface ActivityStore {
227
282
  prune(options: PruneOptions): { runsPruned: number; spansDeleted: number };
228
283
  }
229
284
 
285
+ /**
286
+ * The pricing dependency rollups price through. `resolve` MUST be synchronous
287
+ * — it is called inside the rollup write transaction, which never awaits.
288
+ */
289
+ export interface RollupPricing {
290
+ resolve(modelId: string): PricingRates | null;
291
+ }
292
+
230
293
  export interface CreateActivityStoreOptions {
231
294
  /** Process identity stamped on every span this store writes. */
232
295
  writer?: string;
296
+ /**
297
+ * Pricing seam for rollup-time effective cost. Defaults to an env-derived
298
+ * `createModelPricing` instance (the `$BRAIN_PATH` cache and the bundled
299
+ * snapshot are both synchronous reads), constructed on first rollup — so
300
+ * the cron wrapper's bare `createActivityStore(db)` prices identically to
301
+ * the server with no wiring of its own. Tests inject a fake to stay
302
+ * network- and disk-free (the default is also disabled under NODE_ENV=test,
303
+ * mirroring the discovery flag's default).
304
+ */
305
+ pricing?: RollupPricing;
233
306
  }
234
307
 
235
308
  export function createActivityStore(
@@ -238,6 +311,16 @@ export function createActivityStore(
238
311
  ): ActivityStore {
239
312
  const writer = options.writer ?? `pid:${process.pid}:${Date.now()}`;
240
313
 
314
+ // Lazy so a store that never rolls up (span-only paths, most tests)
315
+ // touches neither the env nor the disk. The env reads live in
316
+ // config/env.ts — the one chokepoint the env-access gate allows. Every
317
+ // wrapper whose transaction can reach rollupRunInTx calls this BEFORE
318
+ // entering `inWrite`, so the SQLite write lock never covers the two
319
+ // synchronous JSON reads construction costs.
320
+ let pricing: RollupPricing | undefined = options.pricing;
321
+ const getPricing = (): RollupPricing =>
322
+ (pricing ??= createModelPricing(resolveStandalonePricingConfig()));
323
+
241
324
  // bun:sqlite transactions: `.immediate` takes the write lock up front, so a
242
325
  // concurrent writer waits (busy_timeout) instead of failing mid-upgrade.
243
326
  const inWrite = <T>(fn: () => T): T => db.transaction(fn).immediate();
@@ -375,19 +458,95 @@ export function createActivityStore(
375
458
  spans.find(
376
459
  (s) => (s.outcome === "error" || s.outcome === "timeout") && s.outcomeReason != null
377
460
  )?.outcomeReason ?? (root.outcome === "interrupted" ? "interrupted" : null);
461
+
462
+ // Billing rides the root span when the recorder knew it at run start
463
+ // (session runs, U3). A root without the attr classifies from THIS
464
+ // process's env ONLY for non-session origins: for cron rollups the
465
+ // executing process is the wrapper itself — exactly the credential set
466
+ // the job's agent authenticated under — so classification and reality
467
+ // move together. A SESSION root without the attr (a custom backend the
468
+ // registry could not classify, or a failed profile resolution) stays
469
+ // unknown instead: this process's ambient credentials say nothing about
470
+ // whichever backend ran the turn, and a wrong subscription-$0 would
471
+ // freeze forever where unknown stays honestly unpriced.
472
+ const attrBilling = root.attrs["brain.billing_mode"];
473
+ const billingMode: BillingMode | null = isBillingMode(attrBilling)
474
+ ? attrBilling
475
+ : root.origin === "session"
476
+ ? null
477
+ : resolveAmbientBillingMode();
478
+
479
+ // List-price math runs regardless of billing mode: it gap-fills a
480
+ // missing backend cost_usd (pi without snapshots, cron) AND provides the
481
+ // api-billed effective number. resolve() is synchronous by contract —
482
+ // nothing here may await inside the write transaction.
483
+ const usage = usageForPricing(root, spans);
484
+ const priced =
485
+ usage.kind === "usage" ? priceUsage(usage.byModel, getPricing()) : null;
486
+
487
+ // Effective-cost semantics (NULL = unknown, 0 = genuinely free):
488
+ // subscription → 0 regardless of tokens (AE1); api → the priced sum —
489
+ // 0 when the run verifiably consumed nothing, NULL when usage was never
490
+ // recorded (denied before inference) or any contributing model/rate is
491
+ // missing. The estimate flag qualifies the effective number, so it is
492
+ // NULL exactly when that is, and 0 for a subscription $0 (exact, not
493
+ // estimated).
494
+ // An unclassified run (billingMode null — session root without the
495
+ // attr) prices as unknown: no subscription-zero, no api pricing.
496
+ const effectiveCostUsd =
497
+ billingMode === null ? null : billingMode === "subscription" ? 0 : (priced?.costUsd ?? null);
498
+ const pricingEstimate =
499
+ billingMode === null
500
+ ? null
501
+ : billingMode === "subscription"
502
+ ? 0
503
+ : priced
504
+ ? (priced.estimate ? 1 : 0)
505
+ : null;
506
+ const costUsd = root.usage.costUsd ?? priced?.costUsd ?? null;
507
+
378
508
  db.query(
379
509
  `INSERT INTO activity_run_rollups
380
510
  (run_id, origin, name, session_id, job_name, started_at, ended_at, outcome,
381
511
  duration_ms, span_count, input_tokens, output_tokens, cache_read_tokens,
382
- cache_creation_tokens, cost_usd, failure_reason, detail_pruned)
383
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
512
+ cache_creation_tokens, cost_usd, effective_cost_usd, billing_mode,
513
+ pricing_estimate, failure_reason, detail_pruned)
514
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
384
515
  ON CONFLICT(run_id) DO UPDATE SET
385
516
  ended_at = excluded.ended_at, outcome = excluded.outcome,
386
517
  duration_ms = excluded.duration_ms, span_count = excluded.span_count,
387
518
  input_tokens = excluded.input_tokens, output_tokens = excluded.output_tokens,
388
519
  cache_read_tokens = excluded.cache_read_tokens,
389
520
  cache_creation_tokens = excluded.cache_creation_tokens,
390
- cost_usd = excluded.cost_usd, failure_reason = excluded.failure_reason`
521
+ cost_usd = COALESCE(activity_run_rollups.cost_usd, excluded.cost_usd),
522
+ effective_cost_usd = CASE
523
+ WHEN activity_run_rollups.effective_cost_usd IS NOT NULL
524
+ THEN activity_run_rollups.effective_cost_usd
525
+ WHEN activity_run_rollups.billing_mode IS NULL
526
+ OR activity_run_rollups.billing_mode = excluded.billing_mode
527
+ THEN excluded.effective_cost_usd
528
+ ELSE NULL
529
+ END,
530
+ billing_mode = COALESCE(activity_run_rollups.billing_mode, excluded.billing_mode),
531
+ pricing_estimate = CASE
532
+ WHEN activity_run_rollups.pricing_estimate IS NOT NULL
533
+ THEN activity_run_rollups.pricing_estimate
534
+ WHEN activity_run_rollups.billing_mode IS NULL
535
+ OR activity_run_rollups.billing_mode = excluded.billing_mode
536
+ THEN excluded.pricing_estimate
537
+ ELSE NULL
538
+ END,
539
+ failure_reason = excluded.failure_reason`
540
+ // Every cost column is FROZEN at first non-NULL write (AE5): a re-rollup
541
+ // after a pricing refresh must not silently reprice history — only a
542
+ // still-NULL slot may be filled by a later computation. cost_usd gets
543
+ // the plain first-write-wins COALESCE (the terminal endSpan already
544
+ // merged the backend's authoritative number before the first rollup).
545
+ // The effective/estimate pair additionally requires the LATER fill to
546
+ // agree with the frozen classification: billing_mode is first-write-wins,
547
+ // and a slot left NULL under one classification must never be filled by
548
+ // a number computed under a different one (a subscription $0 landing on
549
+ // an api-classified row would fabricate a cross-classified price).
391
550
  ).run(
392
551
  runId,
393
552
  root.origin,
@@ -403,7 +562,10 @@ export function createActivityStore(
403
562
  root.usage.outputTokens ?? null,
404
563
  root.usage.cacheReadTokens ?? null,
405
564
  root.usage.cacheCreationTokens ?? null,
406
- root.usage.costUsd ?? null,
565
+ costUsd,
566
+ effectiveCostUsd,
567
+ billingMode,
568
+ pricingEstimate,
407
569
  failure
408
570
  );
409
571
  }
@@ -477,7 +639,7 @@ export function createActivityStore(
477
639
  });
478
640
  },
479
641
 
480
- appendEvent(spanId, eventType, payload, ts) {
642
+ appendEvent(spanId, eventType, payload, ts, cap) {
481
643
  return inWrite(() => {
482
644
  const span = db
483
645
  .query("SELECT run_id FROM activity_spans WHERE span_id = ?")
@@ -488,15 +650,21 @@ export function createActivityStore(
488
650
  "SELECT COALESCE(MAX(event_index), -1) + 1 AS idx FROM activity_events WHERE span_id = ?"
489
651
  )
490
652
  .get(spanId) as { idx: number };
491
- const stored = capPayload(payload);
653
+ const stored = capPayload(payload, cap);
654
+ const at = ts ?? Date.now();
492
655
  db.query(
493
656
  "INSERT INTO activity_events (span_id, event_index, ts, event_type, payload) VALUES (?, ?, ?, ?, ?)"
494
- ).run(spanId, next.idx, ts ?? Date.now(), eventType, JSON.stringify(stored));
657
+ ).run(spanId, next.idx, at, eventType, JSON.stringify(stored));
495
658
  logChange(span.run_id, nextSeq(span.run_id), spanId, next.idx);
496
- const r = db
497
- .query("SELECT * FROM activity_events WHERE span_id = ? AND event_index = ?")
498
- .get(spanId, next.idx);
499
- return rowToEvent(r);
659
+ // Built from the values just written — no read-back needed.
660
+ return {
661
+ spanId,
662
+ eventIndex: next.idx,
663
+ ts: at,
664
+ eventType,
665
+ payload: stored.v,
666
+ truncated: stored.truncated === true,
667
+ };
500
668
  });
501
669
  },
502
670
 
@@ -511,6 +679,7 @@ export function createActivityStore(
511
679
  },
512
680
 
513
681
  cascadeClose(runId, outcome, reason) {
682
+ getPricing(); // construct outside the write lock; rollup reuses it
514
683
  return inWrite(() => closeRunInTx(runId, outcome, reason));
515
684
  },
516
685
 
@@ -533,6 +702,7 @@ export function createActivityStore(
533
702
  )
534
703
  .all() as Array<{ run_id: string }>;
535
704
  if (open.length === 0) return 0;
705
+ getPricing(); // construct outside the write lock; rollup reuses it
536
706
  return inWrite(() => {
537
707
  let closed = 0;
538
708
  for (const { run_id } of open) {
@@ -555,6 +725,7 @@ export function createActivityStore(
555
725
  )
556
726
  .all(writer, cutoff) as Array<{ span_id: string; run_id: string }>;
557
727
  if (staleRoots.length === 0) return 0;
728
+ getPricing(); // construct outside the write lock; rollup reuses it
558
729
  return inWrite(() => {
559
730
  let closed = 0;
560
731
  for (const { run_id } of staleRoots) {
@@ -692,6 +863,7 @@ export function createActivityStore(
692
863
  },
693
864
 
694
865
  rollupRun(runId) {
866
+ getPricing(); // construct outside the write lock; rollup reuses it
695
867
  inWrite(() => rollupRunInTx(runId));
696
868
  },
697
869
 
@@ -700,8 +872,15 @@ export function createActivityStore(
700
872
  const batch = options.batch ?? 50;
701
873
  // The digest floor gates pruning, but a dead digest job must not freeze
702
874
  // it forever: the hard ceiling prunes regardless (marking the rollup so
703
- // the coverage gap is visible).
875
+ // the coverage gap is visible). The floor alone is NOT enough: it meant
876
+ // "safe to prune once summarized", but drill-in debugging is a second
877
+ // reader of detail with a different clock — nightly cron runs end
878
+ // before the morning digest, day sessions after, so floor-only pruning
879
+ // took cron span trees within the hour while sessions kept theirs (it
880
+ // looked cron-specific; it was clock skew). The retention cutoff ANDs
881
+ // with the floor so covered runs still keep detail for a minimum window.
704
882
  const floor = Math.max(options.digestFloorAt, 0);
883
+ const retentionCutoff = now - options.detailRetentionMs;
705
884
  const ceiling = now - options.hardCeilingMs;
706
885
  return inWrite(() => {
707
886
  // Candidates come from the rollups (they exist for every finished
@@ -711,14 +890,14 @@ export function createActivityStore(
711
890
  .query(
712
891
  `SELECT run_id, ended_at AS ended FROM activity_run_rollups r
713
892
  WHERE detail_pruned = 0 AND ended_at IS NOT NULL
714
- AND (ended_at < ? OR ended_at < ?)
893
+ AND ((ended_at < ? AND ended_at < ?) OR ended_at < ?)
715
894
  AND NOT EXISTS (
716
895
  SELECT 1 FROM activity_spans s
717
896
  WHERE s.run_id = r.run_id AND s.outcome IS NULL
718
897
  )
719
898
  LIMIT ?`
720
899
  )
721
- .all(floor, ceiling, batch) as Array<{ run_id: string; ended: number }>;
900
+ .all(floor, retentionCutoff, ceiling, batch) as Array<{ run_id: string; ended: number }>;
722
901
  let spansDeleted = 0;
723
902
  for (const { run_id, ended } of candidates) {
724
903
  db.query(
@@ -754,6 +933,120 @@ export function createActivityStore(
754
933
  };
755
934
  }
756
935
 
936
+ /** Per-model token counts, as priced; absent fields count as zero consumed. */
937
+ interface PricedTokens {
938
+ inputTokens?: number;
939
+ outputTokens?: number;
940
+ cacheReadTokens?: number;
941
+ cacheCreationTokens?: number;
942
+ }
943
+
944
+ type PricingUsage =
945
+ /** Nothing recorded at all (denied before inference, pre-feature spans) → unknown. */
946
+ | { kind: "none" }
947
+ /** Usage exists but cannot be attributed to a model → whole run unknown. */
948
+ | { kind: "unpriceable" }
949
+ | { kind: "usage"; byModel: Record<string, PricedTokens> };
950
+
951
+ function tokenCount(value: unknown): number | undefined {
952
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
953
+ }
954
+
955
+ /**
956
+ * The usage a run is priced from. Session runs carry the SDK's per-model
957
+ * breakdown as a root attr (result-level accounting, subagents included —
958
+ * the same root-only scope the token rollup uses). Cron runs have no root
959
+ * aggregate: the wrapper's children carry the usage, summed per model here
960
+ * — the sanctioned origin-scoped exception to root-only aggregation, safe
961
+ * because sink children are not double-counted in their root.
962
+ */
963
+ function usageForPricing(root: SpanRow, spans: SpanRow[]): PricingUsage {
964
+ if (root.origin === "cron") {
965
+ // Null-prototype: model ids are foreign strings — an id like "__proto__"
966
+ // must be an ordinary key, never a prototype write.
967
+ const byModel: Record<string, PricedTokens> = Object.create(null);
968
+ let sawUsage = false;
969
+ for (const span of spans) {
970
+ if (span.spanId === root.spanId) continue;
971
+ const u = span.usage;
972
+ if (
973
+ u.inputTokens == null &&
974
+ u.outputTokens == null &&
975
+ u.cacheReadTokens == null &&
976
+ u.cacheCreationTokens == null
977
+ ) {
978
+ continue; // no inference on this span (tool spans etc.) — not "unknown"
979
+ }
980
+ sawUsage = true;
981
+ // Tokens without a model cannot be priced at any rate — guessing one
982
+ // would silently misprice, so the whole run goes unknown.
983
+ if (!u.model) return { kind: "unpriceable" };
984
+ const agg = (byModel[u.model] ??= {});
985
+ agg.inputTokens = (agg.inputTokens ?? 0) + (u.inputTokens ?? 0);
986
+ agg.outputTokens = (agg.outputTokens ?? 0) + (u.outputTokens ?? 0);
987
+ agg.cacheReadTokens = (agg.cacheReadTokens ?? 0) + (u.cacheReadTokens ?? 0);
988
+ agg.cacheCreationTokens = (agg.cacheCreationTokens ?? 0) + (u.cacheCreationTokens ?? 0);
989
+ }
990
+ return sawUsage ? { kind: "usage", byModel } : { kind: "none" };
991
+ }
992
+ const perModel = root.attrs["gen_ai.usage.per_model"];
993
+ if (typeof perModel !== "object" || perModel === null || Array.isArray(perModel)) {
994
+ return { kind: "none" };
995
+ }
996
+ const byModel: Record<string, PricedTokens> = Object.create(null);
997
+ for (const [model, value] of Object.entries(perModel as Record<string, unknown>)) {
998
+ if (typeof value !== "object" || value === null) continue;
999
+ const v = value as Record<string, unknown>;
1000
+ byModel[model] = {
1001
+ inputTokens: tokenCount(v.inputTokens) ?? 0,
1002
+ outputTokens: tokenCount(v.outputTokens) ?? 0,
1003
+ cacheReadTokens: tokenCount(v.cacheReadTokens) ?? 0,
1004
+ cacheCreationTokens: tokenCount(v.cacheCreationTokens) ?? 0,
1005
+ };
1006
+ }
1007
+ return Object.keys(byModel).length > 0 ? { kind: "usage", byModel } : { kind: "none" };
1008
+ }
1009
+
1010
+ /**
1011
+ * Price one run's per-model usage at list rates. Null = unknown: an
1012
+ * unresolvable model with consumed tokens, or a consumed token class with no
1013
+ * rate, poisons the WHOLE run — cache reads dominate Claude usage, so
1014
+ * partial pricing would systematically understate (the binding
1015
+ * missing-cache-rate decision). A model with zero consumption contributes
1016
+ * nothing and needs no rate. Variant/snapshot fallbacks are the pricing
1017
+ * service's job (`resolve()`); the store only propagates the estimate flag.
1018
+ */
1019
+ function priceUsage(
1020
+ byModel: Record<string, PricedTokens>,
1021
+ pricing: RollupPricing
1022
+ ): { costUsd: number; estimate: boolean } | null {
1023
+ let costUsd = 0;
1024
+ let estimate = false;
1025
+ for (const [model, tokens] of Object.entries(byModel)) {
1026
+ const classes: Array<
1027
+ [count: number, rate: (r: PricingRates) => number | null]
1028
+ > = [
1029
+ [tokens.inputTokens ?? 0, (r) => r.input],
1030
+ [tokens.outputTokens ?? 0, (r) => r.output],
1031
+ [tokens.cacheReadTokens ?? 0, (r) => r.cacheRead],
1032
+ [tokens.cacheCreationTokens ?? 0, (r) => r.cacheWrite],
1033
+ ];
1034
+ if (!classes.some(([count]) => count > 0)) continue;
1035
+ const rates = pricing.resolve(model);
1036
+ if (!rates) return null;
1037
+ for (const [count, rateOf] of classes) {
1038
+ if (count <= 0) continue;
1039
+ const perToken = rateOf(rates);
1040
+ if (perToken === null) return null;
1041
+ costUsd += count * perToken;
1042
+ }
1043
+ if (rates.estimate) estimate = true;
1044
+ }
1045
+ // A non-finite sum (overflowed or poisoned rates) must surface as unknown —
1046
+ // once frozen into the rollup it would render as an exact number forever.
1047
+ return Number.isFinite(costUsd) ? { costUsd, estimate } : null;
1048
+ }
1049
+
757
1050
  function safeParse(text: string | null): Record<string, unknown> | undefined {
758
1051
  if (!text) return undefined;
759
1052
  try {
@@ -763,12 +1056,14 @@ function safeParse(text: string | null): Record<string, unknown> | undefined {
763
1056
  }
764
1057
  }
765
1058
 
766
- /** Cap a payload's serialized size, storing an explicit truncation marker. */
767
- function capPayload(payload: unknown): { v: unknown; truncated?: boolean } {
1059
+ /** Cap a payload's serialized size; a clipped payload carries the `truncated`
1060
+ * flag (which rides the wire), so no in-text marker is stored. */
1061
+ function capPayload(payload: unknown, cap?: number): { v: unknown; truncated?: boolean } {
1062
+ const limit = Math.min(cap ?? MAX_EVENT_PAYLOAD_BYTES, MAX_EVENT_PAYLOAD_BYTES);
768
1063
  const json = JSON.stringify(payload ?? null);
769
- if (json.length <= MAX_EVENT_PAYLOAD_BYTES) return { v: payload ?? null };
1064
+ if (json.length <= limit) return { v: payload ?? null };
770
1065
  if (typeof payload === "string") {
771
- return { v: payload.slice(0, MAX_EVENT_PAYLOAD_BYTES) + TRUNCATION_MARKER, truncated: true };
1066
+ return { v: payload.slice(0, limit), truncated: true };
772
1067
  }
773
- return { v: json.slice(0, MAX_EVENT_PAYLOAD_BYTES) + TRUNCATION_MARKER, truncated: true };
1068
+ return { v: json.slice(0, limit), truncated: true };
774
1069
  }
@@ -17,19 +17,27 @@
17
17
  * This is deliberately the only place broadcast is per-connection filtered;
18
18
  * every other frame keeps the host's broadcast-to-all semantics.
19
19
  */
20
- import type {
21
- ActivitySpan,
22
- ActivitySpanEvent,
23
- ClientActivitySubscribe,
24
- ClientActivityUnsubscribe,
25
- ServerActivityDelta,
26
- ServerActivitySnapshot,
20
+ import {
21
+ SPAN_TOOL_NAME_PREFIX,
22
+ type ActivityRunRollup,
23
+ type ActivitySpan,
24
+ type ActivitySpanEvent,
25
+ type ClientActivitySubscribe,
26
+ type ClientActivityUnsubscribe,
27
+ type ServerActivityDelta,
28
+ type ServerActivitySnapshot,
27
29
  } from "@schlessera/brain-ui-sdk/protocol";
28
30
  import type { Logger } from "@opentelemetry/api-logs";
29
31
 
30
32
  import type { WSContext } from "../ws/clients.js";
31
33
  import { sendTo } from "../ws/clients.js";
32
- import type { ActivityChange, ActivityStore, SpanEventRow, SpanRow } from "./store.js";
34
+ import type {
35
+ ActivityChange,
36
+ ActivityStore,
37
+ RunRollupRow,
38
+ SpanEventRow,
39
+ SpanRow,
40
+ } from "./store.js";
33
41
 
34
42
  /** Events per snapshot frame — keeps each frame far below the WS size cap. */
35
43
  const SNAPSHOT_EVENT_CHUNK = 100;
@@ -38,7 +46,7 @@ const SNAPSHOT_SPAN_CHUNK = 100;
38
46
  /** Fast-poll cadence while subscriptions exist (foreign-writer liveness). */
39
47
  const POLL_INTERVAL_MS = 1500;
40
48
 
41
- const TOOL_NAME_PREFIX = "execute_tool ";
49
+ const TOOL_NAME_PREFIX = SPAN_TOOL_NAME_PREFIX;
42
50
 
43
51
  interface Subscription {
44
52
  view: "index" | "session" | "run";
@@ -105,6 +113,28 @@ export function toWireEvent(event: SpanEventRow): ActivitySpanEvent {
105
113
  };
106
114
  }
107
115
 
116
+ /** Store rollup → wire rollup: nullable columns become omitted-when-null per
117
+ * the optional wire contract (effectiveCostUsd stays explicit — null means
118
+ * unknown, and omitting it would let a client mistake unknown for absent). */
119
+ export function toWireRollup(r: RunRollupRow): ActivityRunRollup {
120
+ return {
121
+ origin: r.origin,
122
+ name: r.name,
123
+ sessionId: r.sessionId,
124
+ jobName: r.jobName,
125
+ startedAt: r.startedAt,
126
+ endedAt: r.endedAt,
127
+ outcome: r.outcome,
128
+ durationMs: r.durationMs,
129
+ spanCount: r.spanCount,
130
+ costUsd: r.costUsd,
131
+ effectiveCostUsd: r.effectiveCostUsd,
132
+ billingMode: r.billingMode ?? undefined,
133
+ pricingEstimate: r.pricingEstimate ?? undefined,
134
+ failureReason: r.failureReason,
135
+ };
136
+ }
137
+
108
138
  export interface ActivityStream {
109
139
  handleSubscribe(ws: WSContext, msg: ClientActivitySubscribe): void;
110
140
  handleUnsubscribe(ws: WSContext, msg: ClientActivityUnsubscribe): void;