@schlessera/brain-ui-server 0.34.0 → 0.35.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 (169) hide show
  1. package/README.md +28 -0
  2. package/dist/activity/notify.d.ts.map +1 -1
  3. package/dist/activity/notify.js +18 -8
  4. package/dist/activity/notify.js.map +1 -1
  5. package/dist/activity/push-sender.d.ts +7 -3
  6. package/dist/activity/push-sender.d.ts.map +1 -1
  7. package/dist/activity/push-sender.js +45 -11
  8. package/dist/activity/push-sender.js.map +1 -1
  9. package/dist/activity/query.d.ts.map +1 -1
  10. package/dist/activity/query.js +8 -0
  11. package/dist/activity/query.js.map +1 -1
  12. package/dist/activity/recorder.d.ts +11 -1
  13. package/dist/activity/recorder.d.ts.map +1 -1
  14. package/dist/activity/recorder.js +52 -2
  15. package/dist/activity/recorder.js.map +1 -1
  16. package/dist/activity/runtime.d.ts.map +1 -1
  17. package/dist/activity/runtime.js +2 -5
  18. package/dist/activity/runtime.js.map +1 -1
  19. package/dist/activity/sql.d.ts +34 -0
  20. package/dist/activity/sql.d.ts.map +1 -0
  21. package/dist/activity/sql.js +114 -0
  22. package/dist/activity/sql.js.map +1 -0
  23. package/dist/activity/store.d.ts +7 -0
  24. package/dist/activity/store.d.ts.map +1 -1
  25. package/dist/activity/store.js +48 -113
  26. package/dist/activity/store.js.map +1 -1
  27. package/dist/activity/stream.d.ts +3 -1
  28. package/dist/activity/stream.d.ts.map +1 -1
  29. package/dist/activity/stream.js +32 -17
  30. package/dist/activity/stream.js.map +1 -1
  31. package/dist/app-env.d.ts +8 -0
  32. package/dist/app-env.d.ts.map +1 -0
  33. package/dist/app-env.js +2 -0
  34. package/dist/app-env.js.map +1 -0
  35. package/dist/app.d.ts.map +1 -1
  36. package/dist/app.js +28 -3
  37. package/dist/app.js.map +1 -1
  38. package/dist/brain/client.d.ts +4 -2
  39. package/dist/brain/client.d.ts.map +1 -1
  40. package/dist/brain/client.js +60 -20
  41. package/dist/brain/client.js.map +1 -1
  42. package/dist/brain/types.d.ts +9 -0
  43. package/dist/brain/types.d.ts.map +1 -1
  44. package/dist/cron/run-job.d.ts.map +1 -1
  45. package/dist/cron/run-job.js +60 -10
  46. package/dist/cron/run-job.js.map +1 -1
  47. package/dist/db/principals.d.ts +72 -0
  48. package/dist/db/principals.d.ts.map +1 -0
  49. package/dist/db/principals.js +294 -0
  50. package/dist/db/principals.js.map +1 -0
  51. package/dist/files/walker.d.ts.map +1 -1
  52. package/dist/files/walker.js +6 -1
  53. package/dist/files/walker.js.map +1 -1
  54. package/dist/index.d.ts +4 -1
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +1 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/middleware/auth.d.ts +37 -9
  59. package/dist/middleware/auth.d.ts.map +1 -1
  60. package/dist/middleware/auth.js +148 -74
  61. package/dist/middleware/auth.js.map +1 -1
  62. package/dist/middleware/passkeys.d.ts +9 -7
  63. package/dist/middleware/passkeys.d.ts.map +1 -1
  64. package/dist/middleware/passkeys.js +25 -7
  65. package/dist/middleware/passkeys.js.map +1 -1
  66. package/dist/middleware/principals.d.ts +18 -0
  67. package/dist/middleware/principals.d.ts.map +1 -0
  68. package/dist/middleware/principals.js +138 -0
  69. package/dist/middleware/principals.js.map +1 -0
  70. package/dist/middleware/require-owner.d.ts +5 -0
  71. package/dist/middleware/require-owner.d.ts.map +1 -0
  72. package/dist/middleware/require-owner.js +10 -0
  73. package/dist/middleware/require-owner.js.map +1 -0
  74. package/dist/observability/loggers.js +1 -1
  75. package/dist/observability/loggers.js.map +1 -1
  76. package/dist/routes/activity.d.ts.map +1 -1
  77. package/dist/routes/activity.js +3 -0
  78. package/dist/routes/activity.js.map +1 -1
  79. package/dist/routes/brain.d.ts.map +1 -1
  80. package/dist/routes/brain.js +102 -63
  81. package/dist/routes/brain.js.map +1 -1
  82. package/dist/routes/files.d.ts.map +1 -1
  83. package/dist/routes/files.js +7 -2
  84. package/dist/routes/files.js.map +1 -1
  85. package/dist/routes/push.d.ts +61 -2
  86. package/dist/routes/push.d.ts.map +1 -1
  87. package/dist/routes/push.js +8 -3
  88. package/dist/routes/push.js.map +1 -1
  89. package/dist/routes/sessions.d.ts.map +1 -1
  90. package/dist/routes/sessions.js +30 -4
  91. package/dist/routes/sessions.js.map +1 -1
  92. package/dist/share/staging.d.ts.map +1 -1
  93. package/dist/share/staging.js +11 -4
  94. package/dist/share/staging.js.map +1 -1
  95. package/dist/ws/bridge.d.ts.map +1 -1
  96. package/dist/ws/bridge.js +6 -2
  97. package/dist/ws/bridge.js.map +1 -1
  98. package/dist/ws/clients.d.ts +9 -2
  99. package/dist/ws/clients.d.ts.map +1 -1
  100. package/dist/ws/clients.js +44 -6
  101. package/dist/ws/clients.js.map +1 -1
  102. package/dist/ws/connection.d.ts +7 -4
  103. package/dist/ws/connection.d.ts.map +1 -1
  104. package/dist/ws/connection.js +83 -12
  105. package/dist/ws/connection.js.map +1 -1
  106. package/dist/ws/dispatch.d.ts +12 -1
  107. package/dist/ws/dispatch.d.ts.map +1 -1
  108. package/dist/ws/dispatch.js +46 -6
  109. package/dist/ws/dispatch.js.map +1 -1
  110. package/dist/ws/history.d.ts.map +1 -1
  111. package/dist/ws/history.js +7 -5
  112. package/dist/ws/history.js.map +1 -1
  113. package/dist/ws/host.d.ts +22 -0
  114. package/dist/ws/host.d.ts.map +1 -1
  115. package/dist/ws/host.js +39 -1
  116. package/dist/ws/host.js.map +1 -1
  117. package/dist/ws/run-session.d.ts +7 -7
  118. package/dist/ws/run-session.d.ts.map +1 -1
  119. package/dist/ws/run-session.js +208 -52
  120. package/dist/ws/run-session.js.map +1 -1
  121. package/dist/ws/shrink.d.ts +3 -1
  122. package/dist/ws/shrink.d.ts.map +1 -1
  123. package/dist/ws/shrink.js +46 -16
  124. package/dist/ws/shrink.js.map +1 -1
  125. package/dist/ws/turns.d.ts +58 -3
  126. package/dist/ws/turns.d.ts.map +1 -1
  127. package/dist/ws/turns.js +101 -2
  128. package/dist/ws/turns.js.map +1 -1
  129. package/migrations/011_principals.sql +13 -0
  130. package/migrations/012_activity_principals.sql +12 -0
  131. package/migrations/013_push_subscription_principals.sql +7 -0
  132. package/migrations/014_invalidate_legacy_sessions.sql +14 -0
  133. package/package.json +3 -3
  134. package/src/activity/notify.ts +16 -7
  135. package/src/activity/push-sender.ts +79 -16
  136. package/src/activity/query.ts +8 -0
  137. package/src/activity/recorder.ts +68 -3
  138. package/src/activity/runtime.ts +2 -5
  139. package/src/activity/sql.ts +122 -0
  140. package/src/activity/store.ts +73 -155
  141. package/src/activity/stream.ts +37 -17
  142. package/src/app-env.ts +8 -0
  143. package/src/app.ts +33 -4
  144. package/src/brain/client.ts +61 -20
  145. package/src/brain/types.ts +10 -0
  146. package/src/cron/run-job.ts +41 -17
  147. package/src/db/principals.ts +452 -0
  148. package/src/files/walker.ts +5 -1
  149. package/src/index.ts +6 -1
  150. package/src/middleware/auth.ts +241 -81
  151. package/src/middleware/passkeys.ts +35 -14
  152. package/src/middleware/principals.ts +189 -0
  153. package/src/middleware/require-owner.ts +13 -0
  154. package/src/observability/loggers.ts +1 -1
  155. package/src/routes/activity.ts +3 -0
  156. package/src/routes/brain.ts +107 -70
  157. package/src/routes/files.ts +10 -5
  158. package/src/routes/push.ts +11 -5
  159. package/src/routes/sessions.ts +45 -20
  160. package/src/share/staging.ts +11 -4
  161. package/src/ws/bridge.ts +14 -2
  162. package/src/ws/clients.ts +52 -7
  163. package/src/ws/connection.ts +98 -20
  164. package/src/ws/dispatch.ts +66 -6
  165. package/src/ws/history.ts +7 -5
  166. package/src/ws/host.ts +59 -1
  167. package/src/ws/run-session.ts +247 -74
  168. package/src/ws/shrink.ts +48 -14
  169. package/src/ws/turns.ts +136 -5
@@ -38,6 +38,8 @@ import {
38
38
  resolveStandalonePricingConfig,
39
39
  } from "../config/env.js";
40
40
  import { createModelPricing, type PricingRates } from "../pricing/model-pricing.js";
41
+ import type { PrincipalKind } from "../db/principals.js";
42
+ import { ACTIVITY_SQL } from "./sql.js";
41
43
 
42
44
  export const SPAN_OUTCOMES = [
43
45
  "success",
@@ -52,6 +54,12 @@ export type SpanOutcome = (typeof SPAN_OUTCOMES)[number];
52
54
  export type SpanKind = "turn" | "tool" | "subagent" | "cron";
53
55
  export type SpanOrigin = "session" | "cron";
54
56
 
57
+ function isPrincipalKind(value: unknown): value is PrincipalKind {
58
+ return (
59
+ value === "owner" || value === "agent" || value === "ambient" || value === "system"
60
+ );
61
+ }
62
+
55
63
  /** Token usage as spans carry it — OTel GenAI attribute semantics. */
56
64
  export interface SpanUsage {
57
65
  inputTokens?: number;
@@ -71,6 +79,7 @@ export interface SpanRow {
71
79
  origin: SpanOrigin;
72
80
  sessionId: string | null;
73
81
  jobName: string | null;
82
+ principalId: string | null;
74
83
  attrs: Record<string, unknown>;
75
84
  startedAt: number;
76
85
  waitUntil: number | null;
@@ -98,6 +107,9 @@ export interface RunRollupRow {
98
107
  name: string;
99
108
  sessionId: string | null;
100
109
  jobName: string | null;
110
+ principalId: string | null;
111
+ principalLabel: string | null;
112
+ principalKind: PrincipalKind | null;
101
113
  startedAt: number;
102
114
  endedAt: number | null;
103
115
  outcome: SpanOutcome | null;
@@ -130,6 +142,9 @@ export function rowToRunRollup(r: any): RunRollupRow {
130
142
  name: r.name,
131
143
  sessionId: r.session_id,
132
144
  jobName: r.job_name,
145
+ principalId: r.principal_id ?? null,
146
+ principalLabel: r.principal_label ?? null,
147
+ principalKind: isPrincipalKind(r.principal_kind) ? r.principal_kind : null,
133
148
  startedAt: r.started_at,
134
149
  endedAt: r.ended_at,
135
150
  outcome: r.outcome,
@@ -182,6 +197,7 @@ export interface StartSpanInput {
182
197
  origin: SpanOrigin;
183
198
  sessionId?: string;
184
199
  jobName?: string;
200
+ principalId?: string;
185
201
  attrs?: Record<string, unknown>;
186
202
  startedAt?: number;
187
203
  }
@@ -236,7 +252,12 @@ export interface ActivityStore {
236
252
  /** Non-terminal field updates (usage enrichment, wait boundary, attrs). */
237
253
  patchSpan(
238
254
  spanId: string,
239
- patch: { attrs?: Record<string, unknown>; usage?: SpanUsage; waitUntil?: number }
255
+ patch: {
256
+ principalId?: string;
257
+ attrs?: Record<string, unknown>;
258
+ usage?: SpanUsage;
259
+ waitUntil?: number;
260
+ }
240
261
  ): boolean;
241
262
  /**
242
263
  * Append one event; null (nothing written) for an unknown span. `cap`
@@ -327,15 +348,13 @@ export function createActivityStore(
327
348
 
328
349
  function nextSeq(runId: string): number {
329
350
  const row = db
330
- .query("SELECT COALESCE(MAX(seq), 0) AS hi FROM activity_changes WHERE run_id = ?")
351
+ .query(ACTIVITY_SQL.nextSeq)
331
352
  .get(runId) as { hi: number };
332
353
  return row.hi + 1;
333
354
  }
334
355
 
335
356
  function logChange(runId: string, seq: number, spanId: string, eventIndex?: number) {
336
- db.query(
337
- "INSERT INTO activity_changes (run_id, seq, span_id, event_index) VALUES (?, ?, ?, ?)"
338
- ).run(runId, seq, spanId, eventIndex ?? null);
357
+ db.query(ACTIVITY_SQL.insertChange).run(runId, seq, spanId, eventIndex ?? null);
339
358
  }
340
359
 
341
360
  function rowToSpan(r: any): SpanRow {
@@ -348,6 +367,7 @@ export function createActivityStore(
348
367
  origin: r.origin,
349
368
  sessionId: r.session_id,
350
369
  jobName: r.job_name,
370
+ principalId: r.principal_id ?? null,
351
371
  attrs: safeParse(r.attrs) ?? {},
352
372
  startedAt: r.started_at,
353
373
  waitUntil: r.wait_until,
@@ -380,20 +400,20 @@ export function createActivityStore(
380
400
  }
381
401
 
382
402
  function getSpanRaw(spanId: string): SpanRow | null {
383
- const r = db.query("SELECT * FROM activity_spans WHERE span_id = ?").get(spanId);
403
+ const r = db.query(ACTIVITY_SQL.spanById).get(spanId);
384
404
  return r ? rowToSpan(r) : null;
385
405
  }
386
406
 
387
407
  function runHighWaterSeqRaw(runId: string): number {
388
408
  const row = db
389
- .query("SELECT COALESCE(MAX(seq), 0) AS hi FROM activity_changes WHERE run_id = ?")
409
+ .query(ACTIVITY_SQL.nextSeq)
390
410
  .get(runId) as { hi: number };
391
411
  return row.hi;
392
412
  }
393
413
 
394
414
  function endSpanInTx(spanId: string, input: EndSpanInput): boolean {
395
415
  const existing = db
396
- .query("SELECT run_id, outcome, attrs FROM activity_spans WHERE span_id = ?")
416
+ .query(ACTIVITY_SQL.spanStateById)
397
417
  .get(spanId) as { run_id: string; outcome: string | null; attrs: string | null } | null;
398
418
  if (!existing || existing.outcome !== null) return false;
399
419
 
@@ -402,16 +422,7 @@ export function createActivityStore(
402
422
  ? JSON.stringify({ ...(safeParse(existing.attrs) ?? {}), ...input.attrs })
403
423
  : existing.attrs;
404
424
  const u = input.usage ?? {};
405
- db.query(
406
- `UPDATE activity_spans SET outcome = ?, outcome_reason = ?, ended_at = ?, attrs = ?,
407
- input_tokens = COALESCE(?, input_tokens),
408
- output_tokens = COALESCE(?, output_tokens),
409
- cache_read_tokens = COALESCE(?, cache_read_tokens),
410
- cache_creation_tokens = COALESCE(?, cache_creation_tokens),
411
- cost_usd = COALESCE(?, cost_usd),
412
- model = COALESCE(?, model)
413
- WHERE span_id = ?`
414
- ).run(
425
+ db.query(ACTIVITY_SQL.endSpan).run(
415
426
  input.outcome,
416
427
  input.reason ?? null,
417
428
  endedAt,
@@ -431,9 +442,7 @@ export function createActivityStore(
431
442
  /** Close every open span of the run (children first) and roll it up. */
432
443
  function closeRunInTx(runId: string, outcome: SpanOutcome, reason: string): number {
433
444
  const open = db
434
- .query(
435
- "SELECT span_id FROM activity_spans WHERE run_id = ? AND outcome IS NULL ORDER BY started_at DESC"
436
- )
445
+ .query(ACTIVITY_SQL.openSpansByRun)
437
446
  .all(runId) as Array<{ span_id: string }>;
438
447
  let closed = 0;
439
448
  for (const { span_id } of open) {
@@ -445,7 +454,7 @@ export function createActivityStore(
445
454
 
446
455
  function rollupRunInTx(runId: string) {
447
456
  const spans = db
448
- .query("SELECT * FROM activity_spans WHERE run_id = ? ORDER BY started_at")
457
+ .query(ACTIVITY_SQL.spansByRun)
449
458
  .all(runId)
450
459
  .map(rowToSpan);
451
460
  if (spans.length === 0) return;
@@ -504,39 +513,14 @@ export function createActivityStore(
504
513
  ? (priced.estimate ? 1 : 0)
505
514
  : null;
506
515
  const costUsd = root.usage.costUsd ?? priced?.costUsd ?? null;
516
+ const principal = root.principalId
517
+ ? (db
518
+ .query("SELECT label, kind FROM principals WHERE id = ?")
519
+ .get(root.principalId) as { label: string; kind: PrincipalKind } | null)
520
+ : null;
507
521
 
508
522
  db.query(
509
- `INSERT INTO activity_run_rollups
510
- (run_id, origin, name, session_id, job_name, started_at, ended_at, outcome,
511
- duration_ms, span_count, input_tokens, output_tokens, cache_read_tokens,
512
- cache_creation_tokens, cost_usd, effective_cost_usd, billing_mode,
513
- pricing_estimate, failure_reason, detail_pruned)
514
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
515
- ON CONFLICT(run_id) DO UPDATE SET
516
- ended_at = excluded.ended_at, outcome = excluded.outcome,
517
- duration_ms = excluded.duration_ms, span_count = excluded.span_count,
518
- input_tokens = excluded.input_tokens, output_tokens = excluded.output_tokens,
519
- cache_read_tokens = excluded.cache_read_tokens,
520
- cache_creation_tokens = excluded.cache_creation_tokens,
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`
523
+ ACTIVITY_SQL.upsertRollup
540
524
  // Every cost column is FROZEN at first non-NULL write (AE5): a re-rollup
541
525
  // after a pricing refresh must not silently reprice history — only a
542
526
  // still-NULL slot may be filled by a later computation. cost_usd gets
@@ -553,6 +537,9 @@ export function createActivityStore(
553
537
  root.name,
554
538
  root.sessionId,
555
539
  root.jobName,
540
+ root.principalId,
541
+ principal?.label ?? null,
542
+ principal?.kind ?? null,
556
543
  root.startedAt,
557
544
  root.endedAt,
558
545
  root.outcome,
@@ -576,12 +563,7 @@ export function createActivityStore(
576
563
  startSpan(input) {
577
564
  return inWrite(() => {
578
565
  const startedAt = input.startedAt ?? Date.now();
579
- db.query(
580
- `INSERT INTO activity_spans
581
- (span_id, run_id, parent_span_id, name, kind, origin, session_id, job_name,
582
- attrs, started_at, writer, last_heartbeat_at)
583
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
584
- ).run(
566
+ db.query(ACTIVITY_SQL.insertSpan).run(
585
567
  input.spanId,
586
568
  input.runId,
587
569
  input.parentSpanId ?? null,
@@ -590,6 +572,7 @@ export function createActivityStore(
590
572
  input.origin,
591
573
  input.sessionId ?? null,
592
574
  input.jobName ?? null,
575
+ input.principalId ?? null,
593
576
  input.attrs ? JSON.stringify(input.attrs) : null,
594
577
  startedAt,
595
578
  writer,
@@ -607,23 +590,15 @@ export function createActivityStore(
607
590
  patchSpan(spanId, patch) {
608
591
  return inWrite(() => {
609
592
  const existing = db
610
- .query("SELECT run_id, outcome, attrs FROM activity_spans WHERE span_id = ?")
593
+ .query(ACTIVITY_SQL.spanStateById)
611
594
  .get(spanId) as { run_id: string; outcome: string | null; attrs: string | null } | null;
612
595
  if (!existing || existing.outcome !== null) return false;
613
596
  const attrs = patch.attrs
614
597
  ? JSON.stringify({ ...(safeParse(existing.attrs) ?? {}), ...patch.attrs })
615
598
  : existing.attrs;
616
599
  const u = patch.usage ?? {};
617
- db.query(
618
- `UPDATE activity_spans SET attrs = ?, wait_until = COALESCE(?, wait_until),
619
- input_tokens = COALESCE(?, input_tokens),
620
- output_tokens = COALESCE(?, output_tokens),
621
- cache_read_tokens = COALESCE(?, cache_read_tokens),
622
- cache_creation_tokens = COALESCE(?, cache_creation_tokens),
623
- cost_usd = COALESCE(?, cost_usd),
624
- model = COALESCE(?, model)
625
- WHERE span_id = ?`
626
- ).run(
600
+ db.query(ACTIVITY_SQL.patchSpan).run(
601
+ patch.principalId ?? null,
627
602
  attrs,
628
603
  patch.waitUntil ?? null,
629
604
  u.inputTokens ?? null,
@@ -642,19 +617,21 @@ export function createActivityStore(
642
617
  appendEvent(spanId, eventType, payload, ts, cap) {
643
618
  return inWrite(() => {
644
619
  const span = db
645
- .query("SELECT run_id FROM activity_spans WHERE span_id = ?")
620
+ .query(ACTIVITY_SQL.spanRunById)
646
621
  .get(spanId) as { run_id: string } | null;
647
622
  if (!span) return null;
648
623
  const next = db
649
- .query(
650
- "SELECT COALESCE(MAX(event_index), -1) + 1 AS idx FROM activity_events WHERE span_id = ?"
651
- )
624
+ .query(ACTIVITY_SQL.nextEventIndex)
652
625
  .get(spanId) as { idx: number };
653
626
  const stored = capPayload(payload, cap);
654
627
  const at = ts ?? Date.now();
655
- db.query(
656
- "INSERT INTO activity_events (span_id, event_index, ts, event_type, payload) VALUES (?, ?, ?, ?, ?)"
657
- ).run(spanId, next.idx, at, eventType, JSON.stringify(stored));
628
+ db.query(ACTIVITY_SQL.insertEvent).run(
629
+ spanId,
630
+ next.idx,
631
+ at,
632
+ eventType,
633
+ JSON.stringify(stored)
634
+ );
658
635
  logChange(span.run_id, nextSeq(span.run_id), spanId, next.idx);
659
636
  // Built from the values just written — no read-back needed.
660
637
  return {
@@ -672,9 +649,7 @@ export function createActivityStore(
672
649
  inWrite(() => {
673
650
  // Deliberately NOT change-logged: a heartbeat is liveness metadata,
674
651
  // not activity the client needs a delta for.
675
- db.query(
676
- "UPDATE activity_spans SET last_heartbeat_at = ? WHERE span_id = ? AND outcome IS NULL"
677
- ).run(at ?? Date.now(), spanId);
652
+ db.query(ACTIVITY_SQL.heartbeat).run(at ?? Date.now(), spanId);
678
653
  });
679
654
  },
680
655
 
@@ -697,9 +672,7 @@ export function createActivityStore(
697
672
  // loop re-checks open-ness inside the transaction (endSpanInTx is
698
673
  // write-once), so a race just skips.
699
674
  const open = db
700
- .query(
701
- "SELECT DISTINCT run_id FROM activity_spans WHERE outcome IS NULL AND origin = 'session'"
702
- )
675
+ .query(ACTIVITY_SQL.openSessionRuns)
703
676
  .all() as Array<{ run_id: string }>;
704
677
  if (open.length === 0) return 0;
705
678
  getPricing(); // construct outside the write lock; rollup reuses it
@@ -718,11 +691,7 @@ export function createActivityStore(
718
691
  // legitimately long, quiet run with a live writer is never killed.
719
692
  // Candidate read outside the write transaction, same as sweepOwnOrphans.
720
693
  const staleRoots = db
721
- .query(
722
- `SELECT span_id, run_id FROM activity_spans
723
- WHERE outcome IS NULL AND parent_span_id IS NULL
724
- AND writer != ? AND COALESCE(last_heartbeat_at, started_at) < ?`
725
- )
694
+ .query(ACTIVITY_SQL.staleRoots)
726
695
  .all(writer, cutoff) as Array<{ span_id: string; run_id: string }>;
727
696
  if (staleRoots.length === 0) return 0;
728
697
  getPricing(); // construct outside the write lock; rollup reuses it
@@ -739,9 +708,7 @@ export function createActivityStore(
739
708
  const cutoff = (now ?? Date.now()) - thresholdMs;
740
709
  return (
741
710
  db
742
- .query(
743
- "SELECT * FROM activity_spans WHERE outcome IS NULL AND parent_span_id IS NULL AND started_at < ?"
744
- )
711
+ .query(ACTIVITY_SQL.stuckRoots)
745
712
  .all(cutoff) as any[]
746
713
  ).map(rowToSpan);
747
714
  },
@@ -751,23 +718,10 @@ export function createActivityStore(
751
718
  // change whose span/event row was pruned drops out via the join, same
752
719
  // as the old per-row miss.
753
720
  const spanRows = db
754
- .query(
755
- `SELECT c.change_id, c.run_id, c.seq, s.*
756
- FROM activity_changes c
757
- JOIN activity_spans s ON s.span_id = c.span_id
758
- WHERE c.change_id > ? AND c.event_index IS NULL
759
- ORDER BY c.change_id LIMIT ?`
760
- )
721
+ .query(ACTIVITY_SQL.spanChanges)
761
722
  .all(changeCursor, limit) as any[];
762
723
  const eventRows = db
763
- .query(
764
- `SELECT c.change_id, c.run_id, c.seq, e.*
765
- FROM activity_changes c
766
- JOIN activity_events e
767
- ON e.span_id = c.span_id AND e.event_index = c.event_index
768
- WHERE c.change_id > ? AND c.event_index IS NOT NULL
769
- ORDER BY c.change_id LIMIT ?`
770
- )
724
+ .query(ACTIVITY_SQL.eventChanges)
771
725
  .all(changeCursor, limit) as any[];
772
726
  const changes: ActivityChange[] = [
773
727
  ...spanRows.map(
@@ -795,22 +749,14 @@ export function createActivityStore(
795
749
 
796
750
  terminalRootChangesSince(changeCursor, limit = 500) {
797
751
  const rows = db
798
- .query(
799
- `SELECT MAX(c.change_id) AS change_id, s.*
800
- FROM activity_changes c
801
- JOIN activity_spans s ON s.span_id = c.span_id
802
- WHERE c.change_id > ? AND c.event_index IS NULL
803
- AND s.parent_span_id IS NULL AND s.outcome IS NOT NULL
804
- GROUP BY s.span_id
805
- ORDER BY change_id LIMIT ?`
806
- )
752
+ .query(ACTIVITY_SQL.terminalRootChanges)
807
753
  .all(changeCursor, limit) as any[];
808
754
  return rows.map((r) => ({ changeId: r.change_id, span: rowToSpan(r) }));
809
755
  },
810
756
 
811
757
  latestChangeCursor() {
812
758
  const row = db
813
- .query("SELECT COALESCE(MAX(change_id), 0) AS hi FROM activity_changes")
759
+ .query(ACTIVITY_SQL.latestChangeCursor)
814
760
  .get() as { hi: number };
815
761
  return row.hi;
816
762
  },
@@ -822,21 +768,17 @@ export function createActivityStore(
822
768
  return db.transaction(() => {
823
769
  const spans = (
824
770
  db
825
- .query("SELECT * FROM activity_spans WHERE run_id = ? ORDER BY started_at")
771
+ .query(ACTIVITY_SQL.spansByRun)
826
772
  .all(runId) as any[]
827
773
  ).map(rowToSpan);
828
774
  if (spans.length === 0) return null;
829
775
  const events = (
830
776
  db
831
- .query(
832
- `SELECT e.* FROM activity_events e
833
- JOIN activity_spans s ON s.span_id = e.span_id
834
- WHERE s.run_id = ? ORDER BY e.ts, e.event_index`
835
- )
777
+ .query(ACTIVITY_SQL.eventsByRun)
836
778
  .all(runId) as any[]
837
779
  ).map(rowToEvent);
838
780
  const cursor = db
839
- .query("SELECT COALESCE(MAX(change_id), 0) AS hi FROM activity_changes")
781
+ .query(ACTIVITY_SQL.latestChangeCursor)
840
782
  .get() as { hi: number };
841
783
  return {
842
784
  runId,
@@ -855,9 +797,7 @@ export function createActivityStore(
855
797
  openRootSpans() {
856
798
  return (
857
799
  db
858
- .query(
859
- "SELECT * FROM activity_spans WHERE outcome IS NULL AND parent_span_id IS NULL ORDER BY started_at DESC"
860
- )
800
+ .query(ACTIVITY_SQL.openRootSpans)
861
801
  .all() as any[]
862
802
  ).map(rowToSpan);
863
803
  },
@@ -887,46 +827,24 @@ export function createActivityStore(
887
827
  // run — that terminal write is what made the run prunable), guarded
888
828
  // against any run that still has an open span.
889
829
  const candidates = db
890
- .query(
891
- `SELECT run_id, ended_at AS ended FROM activity_run_rollups r
892
- WHERE detail_pruned = 0 AND ended_at IS NOT NULL
893
- AND ((ended_at < ? AND ended_at < ?) OR ended_at < ?)
894
- AND NOT EXISTS (
895
- SELECT 1 FROM activity_spans s
896
- WHERE s.run_id = r.run_id AND s.outcome IS NULL
897
- )
898
- LIMIT ?`
899
- )
830
+ .query(ACTIVITY_SQL.pruneCandidates)
900
831
  .all(floor, retentionCutoff, ceiling, batch) as Array<{ run_id: string; ended: number }>;
901
832
  let spansDeleted = 0;
902
833
  for (const { run_id, ended } of candidates) {
903
- db.query(
904
- "UPDATE activity_run_rollups SET detail_pruned = 1 WHERE run_id = ?"
905
- ).run(run_id);
834
+ db.query(ACTIVITY_SQL.markDetailPruned).run(run_id);
906
835
  if (ended >= floor) {
907
836
  // Pruned by the hard ceiling alone — the digest never covered
908
837
  // this run; make the coverage gap visible on the rollup.
909
- db.query(
910
- "UPDATE activity_run_rollups SET failure_reason = COALESCE(failure_reason, 'digest coverage gap') WHERE run_id = ?"
911
- ).run(run_id);
838
+ db.query(ACTIVITY_SQL.markDigestCoverageGap).run(run_id);
912
839
  }
913
- db.query(
914
- `DELETE FROM activity_events WHERE span_id IN
915
- (SELECT span_id FROM activity_spans WHERE run_id = ?)`
916
- ).run(run_id);
917
- const res = db.query("DELETE FROM activity_spans WHERE run_id = ?").run(run_id);
840
+ db.query(ACTIVITY_SQL.deleteEventsByRun).run(run_id);
841
+ const res = db.query(ACTIVITY_SQL.deleteSpansByRun).run(run_id);
918
842
  spansDeleted += res.changes;
919
- db.query("DELETE FROM activity_changes WHERE run_id = ?").run(run_id);
843
+ db.query(ACTIVITY_SQL.deleteChangesByRun).run(run_id);
920
844
  }
921
845
  // The change log only serves live polling; rows this deep in the past
922
846
  // are unreachable by any live cursor. Keep runs with open spans.
923
- db.query(
924
- `DELETE FROM activity_changes WHERE change_id < (
925
- SELECT COALESCE(MAX(change_id), 0) FROM activity_changes
926
- ) - 100000 AND run_id NOT IN (
927
- SELECT DISTINCT run_id FROM activity_spans WHERE outcome IS NULL
928
- )`
929
- ).run();
847
+ db.query(ACTIVITY_SQL.compactChanges).run();
930
848
  return { runsPruned: candidates.length, spansDeleted };
931
849
  });
932
850
  },
@@ -77,6 +77,7 @@ export function toWireSpan(span: SpanRow): ActivitySpan {
77
77
  origin: span.origin,
78
78
  sessionId: span.sessionId ?? undefined,
79
79
  jobName: span.jobName ?? undefined,
80
+ principalId: span.principalId ?? undefined,
80
81
  startedAt: span.startedAt,
81
82
  waitUntil: span.waitUntil ?? undefined,
82
83
  endedAt: span.endedAt ?? undefined,
@@ -122,6 +123,9 @@ export function toWireRollup(r: RunRollupRow): ActivityRunRollup {
122
123
  name: r.name,
123
124
  sessionId: r.sessionId,
124
125
  jobName: r.jobName,
126
+ principalId: r.principalId,
127
+ principalLabel: r.principalLabel,
128
+ principalKind: r.principalKind,
125
129
  startedAt: r.startedAt,
126
130
  endedAt: r.endedAt,
127
131
  outcome: r.outcome,
@@ -136,10 +140,12 @@ export function toWireRollup(r: RunRollupRow): ActivityRunRollup {
136
140
  }
137
141
 
138
142
  export interface ActivityStream {
139
- handleSubscribe(ws: WSContext, msg: ClientActivitySubscribe): void;
143
+ handleSubscribe(ws: WSContext, msg: ClientActivitySubscribe, principalId: string): void;
140
144
  handleUnsubscribe(ws: WSContext, msg: ClientActivityUnsubscribe): void;
141
145
  /** Forget a closed socket. */
142
146
  dropConnection(ws: WSContext): void;
147
+ /** Forget every subscription attached by one revoked principal. */
148
+ dropFor(principalId: string): void;
143
149
  /** Drain new committed changes to subscribers. Reentrancy-safe. */
144
150
  pump(): void;
145
151
  /** Whether any connection currently watches the given session's activity. */
@@ -150,7 +156,10 @@ export interface ActivityStream {
150
156
  }
151
157
 
152
158
  export function createActivityStream(store: ActivityStore, log?: Logger): ActivityStream {
153
- const subscriptions = new Map<WSContext, Subscription[]>();
159
+ const subscriptions = new Map<
160
+ unknown,
161
+ { ws: WSContext; principalId: string; subscriptions: Subscription[] }
162
+ >();
154
163
  let cursor = store.latestChangeCursor();
155
164
  let pumping = false;
156
165
  let pollHandle: ReturnType<typeof setInterval> | null = null;
@@ -222,9 +231,9 @@ export function createActivityStream(store: ActivityStore, log?: Logger): Activi
222
231
  const span = change.kind === "span" ? change.span : undefined;
223
232
  if (span) noteSpan(span);
224
233
  const frame = deltaFrame(change);
225
- for (const [ws, subs] of subscriptions) {
226
- if (subs.some((s) => matches(s, change.runId, span))) {
227
- sendTo(ws, frame);
234
+ for (const entry of subscriptions.values()) {
235
+ if (entry.subscriptions.some((s) => matches(s, change.runId, span))) {
236
+ sendTo(entry.ws, frame);
228
237
  }
229
238
  }
230
239
  }
@@ -310,7 +319,7 @@ export function createActivityStream(store: ActivityStore, log?: Logger): Activi
310
319
  }
311
320
 
312
321
  return {
313
- handleSubscribe(ws, msg) {
322
+ handleSubscribe(ws, msg, principalId) {
314
323
  // A scoped view without its scope id can never match anything and
315
324
  // would only leak an all-runs snapshot shape — ignore it up front.
316
325
  if ((msg.view === "session" && !msg.sessionId) || (msg.view === "run" && !msg.runId)) {
@@ -321,9 +330,11 @@ export function createActivityStream(store: ActivityStore, log?: Logger): Activi
321
330
  ...(msg.sessionId ? { sessionId: msg.sessionId } : {}),
322
331
  ...(msg.runId ? { runId: msg.runId } : {}),
323
332
  };
324
- const list = subscriptions.get(ws) ?? [];
333
+ const identity = ws.raw ?? ws;
334
+ const existing = subscriptions.get(identity);
335
+ const list = existing?.subscriptions ?? [];
325
336
  if (!list.some((s) => subKey(s) === subKey(sub))) list.push(sub);
326
- subscriptions.set(ws, list);
337
+ subscriptions.set(identity, { ws, principalId, subscriptions: list });
327
338
  ensurePolling();
328
339
  try {
329
340
  sendSnapshot(ws, sub);
@@ -337,29 +348,38 @@ export function createActivityStream(store: ActivityStore, log?: Logger): Activi
337
348
  },
338
349
 
339
350
  handleUnsubscribe(ws, msg) {
340
- const list = subscriptions.get(ws);
341
- if (!list) return;
351
+ const identity = ws.raw ?? ws;
352
+ const entry = subscriptions.get(identity);
353
+ if (!entry) return;
342
354
  const key = subKey({
343
355
  view: msg.view,
344
356
  ...(msg.sessionId ? { sessionId: msg.sessionId } : {}),
345
357
  ...(msg.runId ? { runId: msg.runId } : {}),
346
358
  });
347
- const remaining = list.filter((s) => subKey(s) !== key);
348
- if (remaining.length > 0) subscriptions.set(ws, remaining);
349
- else subscriptions.delete(ws);
359
+ const remaining = entry.subscriptions.filter((s) => subKey(s) !== key);
360
+ if (remaining.length > 0) {
361
+ subscriptions.set(identity, { ...entry, ws, subscriptions: remaining });
362
+ } else subscriptions.delete(identity);
350
363
  stopPollingIfIdle();
351
364
  },
352
365
 
353
366
  dropConnection(ws) {
354
- subscriptions.delete(ws);
367
+ subscriptions.delete(ws.raw ?? ws);
368
+ stopPollingIfIdle();
369
+ },
370
+
371
+ dropFor(principalId) {
372
+ for (const [identity, entry] of subscriptions) {
373
+ if (entry.principalId === principalId) subscriptions.delete(identity);
374
+ }
355
375
  stopPollingIfIdle();
356
376
  },
357
377
 
358
378
  pump,
359
379
 
360
380
  isWatched(scope) {
361
- for (const subs of subscriptions.values()) {
362
- for (const s of subs) {
381
+ for (const entry of subscriptions.values()) {
382
+ for (const s of entry.subscriptions) {
363
383
  if (s.view === "index") return true;
364
384
  if (scope.runId && s.view === "run" && s.runId === scope.runId) return true;
365
385
  if (scope.sessionId && s.view === "session" && s.sessionId === scope.sessionId)
@@ -371,7 +391,7 @@ export function createActivityStream(store: ActivityStore, log?: Logger): Activi
371
391
 
372
392
  subscriptionCount() {
373
393
  let n = 0;
374
- for (const subs of subscriptions.values()) n += subs.length;
394
+ for (const entry of subscriptions.values()) n += entry.subscriptions.length;
375
395
  return n;
376
396
  },
377
397
 
package/src/app-env.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type { Principal } from "./db/principals.js";
2
+
3
+ /** Request-scoped values populated by the authentication boundary. */
4
+ export type AppEnv = {
5
+ Variables: {
6
+ principal?: Principal;
7
+ };
8
+ };