@schlessera/brain-ui-server 0.18.0 → 0.20.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 (130) hide show
  1. package/README.md +5 -1
  2. package/data/model-prices.json +63 -0
  3. package/dist/activity/digest.d.ts +29 -0
  4. package/dist/activity/digest.d.ts.map +1 -0
  5. package/dist/activity/digest.js +89 -0
  6. package/dist/activity/digest.js.map +1 -0
  7. package/dist/activity/notify.d.ts +62 -0
  8. package/dist/activity/notify.d.ts.map +1 -0
  9. package/dist/activity/notify.js +193 -0
  10. package/dist/activity/notify.js.map +1 -0
  11. package/dist/activity/push-sender.d.ts +63 -0
  12. package/dist/activity/push-sender.d.ts.map +1 -0
  13. package/dist/activity/push-sender.js +131 -0
  14. package/dist/activity/push-sender.js.map +1 -0
  15. package/dist/activity/query.d.ts +16 -0
  16. package/dist/activity/query.d.ts.map +1 -0
  17. package/dist/activity/query.js +155 -0
  18. package/dist/activity/query.js.map +1 -0
  19. package/dist/activity/recorder.d.ts +53 -0
  20. package/dist/activity/recorder.d.ts.map +1 -0
  21. package/dist/activity/recorder.js +238 -0
  22. package/dist/activity/recorder.js.map +1 -0
  23. package/dist/activity/runtime.d.ts +37 -0
  24. package/dist/activity/runtime.d.ts.map +1 -0
  25. package/dist/activity/runtime.js +127 -0
  26. package/dist/activity/runtime.js.map +1 -0
  27. package/dist/activity/span-sink.d.ts +22 -0
  28. package/dist/activity/span-sink.d.ts.map +1 -0
  29. package/dist/activity/span-sink.js +138 -0
  30. package/dist/activity/span-sink.js.map +1 -0
  31. package/dist/activity/store.d.ts +256 -0
  32. package/dist/activity/store.d.ts.map +1 -0
  33. package/dist/activity/store.js +657 -0
  34. package/dist/activity/store.js.map +1 -0
  35. package/dist/activity/stream.d.ts +49 -0
  36. package/dist/activity/stream.d.ts.map +1 -0
  37. package/dist/activity/stream.js +353 -0
  38. package/dist/activity/stream.js.map +1 -0
  39. package/dist/agent/backend.d.ts +13 -4
  40. package/dist/agent/backend.d.ts.map +1 -1
  41. package/dist/agent/backend.js +68 -5
  42. package/dist/agent/backend.js.map +1 -1
  43. package/dist/app.d.ts.map +1 -1
  44. package/dist/app.js +33 -2
  45. package/dist/app.js.map +1 -1
  46. package/dist/config/env.d.ts +36 -0
  47. package/dist/config/env.d.ts.map +1 -1
  48. package/dist/config/env.js +66 -2
  49. package/dist/config/env.js.map +1 -1
  50. package/dist/cron/scheduler.d.ts.map +1 -1
  51. package/dist/cron/scheduler.js +20 -5
  52. package/dist/cron/scheduler.js.map +1 -1
  53. package/dist/db/client.d.ts.map +1 -1
  54. package/dist/db/client.js +32 -6
  55. package/dist/db/client.js.map +1 -1
  56. package/dist/db/settings.d.ts +15 -0
  57. package/dist/db/settings.d.ts.map +1 -1
  58. package/dist/db/settings.js +43 -0
  59. package/dist/db/settings.js.map +1 -1
  60. package/dist/index.d.ts +4 -0
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +7 -0
  63. package/dist/index.js.map +1 -1
  64. package/dist/pricing/model-pricing.d.ts +61 -0
  65. package/dist/pricing/model-pricing.d.ts.map +1 -0
  66. package/dist/pricing/model-pricing.js +379 -0
  67. package/dist/pricing/model-pricing.js.map +1 -0
  68. package/dist/routes/activity.d.ts +20 -0
  69. package/dist/routes/activity.d.ts.map +1 -0
  70. package/dist/routes/activity.js +308 -0
  71. package/dist/routes/activity.js.map +1 -0
  72. package/dist/routes/models.d.ts +6 -0
  73. package/dist/routes/models.d.ts.map +1 -1
  74. package/dist/routes/models.js +38 -3
  75. package/dist/routes/models.js.map +1 -1
  76. package/dist/routes/push.d.ts +6 -0
  77. package/dist/routes/push.d.ts.map +1 -0
  78. package/dist/routes/push.js +73 -0
  79. package/dist/routes/push.js.map +1 -0
  80. package/dist/routes/sessions.d.ts.map +1 -1
  81. package/dist/routes/sessions.js +20 -4
  82. package/dist/routes/sessions.js.map +1 -1
  83. package/dist/ws/bridge.d.ts +2 -1
  84. package/dist/ws/bridge.d.ts.map +1 -1
  85. package/dist/ws/bridge.js +27 -2
  86. package/dist/ws/bridge.js.map +1 -1
  87. package/dist/ws/connection.d.ts.map +1 -1
  88. package/dist/ws/connection.js +9 -1
  89. package/dist/ws/connection.js.map +1 -1
  90. package/dist/ws/dispatch.d.ts.map +1 -1
  91. package/dist/ws/dispatch.js +11 -0
  92. package/dist/ws/dispatch.js.map +1 -1
  93. package/dist/ws/host.d.ts +16 -0
  94. package/dist/ws/host.d.ts.map +1 -1
  95. package/dist/ws/host.js +2 -0
  96. package/dist/ws/host.js.map +1 -1
  97. package/dist/ws/run-session.d.ts.map +1 -1
  98. package/dist/ws/run-session.js +55 -1
  99. package/dist/ws/run-session.js.map +1 -1
  100. package/migrations/007_activity.sql +114 -0
  101. package/migrations/008_push_subscriptions.sql +24 -0
  102. package/migrations/009_activity_followups.sql +22 -0
  103. package/migrations/010_effective_cost.sql +21 -0
  104. package/package.json +6 -3
  105. package/src/activity/digest.ts +120 -0
  106. package/src/activity/notify.ts +307 -0
  107. package/src/activity/push-sender.ts +215 -0
  108. package/src/activity/query.ts +187 -0
  109. package/src/activity/recorder.ts +313 -0
  110. package/src/activity/runtime.ts +169 -0
  111. package/src/activity/span-sink.ts +152 -0
  112. package/src/activity/store.ts +1069 -0
  113. package/src/activity/stream.ts +396 -0
  114. package/src/agent/backend.ts +91 -10
  115. package/src/app.ts +35 -2
  116. package/src/config/env.ts +90 -2
  117. package/src/cron/scheduler.ts +28 -8
  118. package/src/db/client.ts +31 -6
  119. package/src/db/settings.ts +56 -0
  120. package/src/index.ts +23 -0
  121. package/src/pricing/model-pricing.ts +497 -0
  122. package/src/routes/activity.ts +371 -0
  123. package/src/routes/models.ts +57 -6
  124. package/src/routes/push.ts +90 -0
  125. package/src/routes/sessions.ts +24 -4
  126. package/src/ws/bridge.ts +29 -2
  127. package/src/ws/connection.ts +9 -1
  128. package/src/ws/dispatch.ts +13 -0
  129. package/src/ws/host.ts +18 -0
  130. package/src/ws/run-session.ts +71 -2
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Per-turn span derivation: the host-side half of activity recording.
3
+ *
4
+ * The host already sees everything a baseline trace needs — the frames it
5
+ * relays (tool starts/results, the terminal result), its own approval flow
6
+ * (wait time, denials), and the turn lifecycle it owns (timeout, cancel).
7
+ * A `TurnRecorder` turns that stream into spans, so EVERY backend gets a
8
+ * full activity tree with zero backend work; the bridge's activity side
9
+ * channel enriches with what only the backend sees (subagent lifecycle,
10
+ * usage, transcripts).
11
+ *
12
+ * Terminal-write precedence (the write-once race): enrichment arrives on the
13
+ * same ordered call path as frames, and the recorder buffers the result
14
+ * frame's outcome/usage rather than closing the root span on sight. The ONE
15
+ * terminal write for the root happens in `finish()` — after the backend's
16
+ * startTurn resolved, when everything that could refine the outcome has
17
+ * drained. Frame-derived outcome is the fallback, never a competing writer.
18
+ *
19
+ * Nothing here may fail the observed turn: every public method catches and
20
+ * drops its own errors.
21
+ */
22
+ import type {
23
+ BackendActivityEvent,
24
+ ServerMessage,
25
+ TurnUsage,
26
+ } from "@schlessera/brain-ui-sdk/server";
27
+ import {
28
+ SPAN_OP_EXECUTE_TOOL,
29
+ SPAN_OP_INVOKE_AGENT,
30
+ SPAN_TOOL_NAME_PREFIX,
31
+ type BillingMode,
32
+ } from "@schlessera/brain-ui-sdk/protocol";
33
+ import type { Logger } from "@opentelemetry/api-logs";
34
+
35
+ import type { ActivityStore, SpanOutcome, SpanUsage } from "./store.js";
36
+
37
+ export interface TurnRecorderDeps {
38
+ store: ActivityStore;
39
+ /** Called after any committed write so the live stream can pump. */
40
+ onWrite?: () => void;
41
+ log?: Logger;
42
+ }
43
+
44
+ export interface TurnRecorder {
45
+ /** The run id (one run per turn). */
46
+ readonly runId: string;
47
+ observeFrame(msg: ServerMessage): void;
48
+ observeActivity(event: BackendActivityEvent): void;
49
+ /** The user's approval decision arrived for a gated tool call. */
50
+ onApprovalDecision(toolUseId: string, allowed: boolean): void;
51
+ /**
52
+ * Close the turn: merge the buffered result enrichment into the root's
53
+ * single terminal write, cascade-cancel anything left open, roll up.
54
+ */
55
+ finish(disposition: "success" | "error" | "cancelled" | "timeout"): void;
56
+ }
57
+
58
+ export function createTurnRecorder(
59
+ deps: TurnRecorderDeps,
60
+ turn: {
61
+ turnId: string;
62
+ sessionId: string | null;
63
+ /** The RESOLVED inference profile this turn runs on (post pin-drop fallback). */
64
+ profileId?: string;
65
+ /** Billing classification of that profile, resolved at run start (U3). */
66
+ billingMode?: BillingMode;
67
+ }
68
+ ): TurnRecorder {
69
+ const { store, onWrite, log } = deps;
70
+ const runId = turn.turnId;
71
+ const rootSpanId = `${runId}:turn`;
72
+ let sessionId = turn.sessionId ?? undefined;
73
+ let rootStarted = false;
74
+ let finished = false;
75
+ /** Buffered terminal enrichment from the result frame. */
76
+ let resultOutcome: SpanOutcome | null = null;
77
+ let resultUsage: SpanUsage | undefined;
78
+ let resultAttrs: Record<string, unknown> | undefined;
79
+
80
+ const guard = (fn: () => void) => {
81
+ try {
82
+ fn();
83
+ } catch (err) {
84
+ log?.emit({
85
+ severityText: "WARN",
86
+ body: "activity recording failed; dropping",
87
+ attributes: { error: err instanceof Error ? err.message : String(err) },
88
+ });
89
+ }
90
+ };
91
+
92
+ function ensureRoot() {
93
+ if (rootStarted) return;
94
+ rootStarted = true;
95
+ store.startSpan({
96
+ spanId: rootSpanId,
97
+ runId,
98
+ name: SPAN_OP_INVOKE_AGENT,
99
+ kind: "turn",
100
+ origin: "session",
101
+ sessionId,
102
+ // Profile + billing ride the ROOT span so the rollup can price the run
103
+ // without any registry or env lookup of its own (a root missing the
104
+ // billing attr falls back to env classification at rollup time).
105
+ attrs: {
106
+ "gen_ai.operation.name": SPAN_OP_INVOKE_AGENT,
107
+ ...(turn.profileId ? { "brain.profile_id": turn.profileId } : {}),
108
+ ...(turn.billingMode ? { "brain.billing_mode": turn.billingMode } : {}),
109
+ },
110
+ });
111
+ onWrite?.();
112
+ }
113
+
114
+ function usageFromTurnUsage(usage: TurnUsage): SpanUsage {
115
+ const models = usage.perModel ? Object.keys(usage.perModel) : [];
116
+ return {
117
+ inputTokens: usage.inputTokens,
118
+ outputTokens: usage.outputTokens,
119
+ cacheReadTokens: usage.cacheReadTokens,
120
+ cacheCreationTokens: usage.cacheCreationTokens,
121
+ model: models.length === 1 ? models[0] : undefined,
122
+ };
123
+ }
124
+
125
+ return {
126
+ runId,
127
+
128
+ observeFrame(msg) {
129
+ guard(() => {
130
+ if (finished) return;
131
+ switch (msg.type) {
132
+ case "session_info": {
133
+ sessionId = msg.sessionId;
134
+ ensureRoot();
135
+ // The root may have been opened before the session had a name.
136
+ store.patchSpan(rootSpanId, { attrs: { "session.id": msg.sessionId } });
137
+ onWrite?.();
138
+ break;
139
+ }
140
+ case "tool_use_start": {
141
+ ensureRoot();
142
+ const isSubagent = msg.toolName === "Agent";
143
+ store.startSpan({
144
+ spanId: msg.toolUseId,
145
+ runId,
146
+ parentSpanId: msg.parentToolUseId ?? rootSpanId,
147
+ name: `${SPAN_TOOL_NAME_PREFIX}${msg.toolName}`,
148
+ kind: isSubagent ? "subagent" : "tool",
149
+ origin: "session",
150
+ sessionId,
151
+ attrs: {
152
+ "gen_ai.operation.name": isSubagent ? SPAN_OP_INVOKE_AGENT : SPAN_OP_EXECUTE_TOOL,
153
+ "gen_ai.tool.name": msg.toolName,
154
+ },
155
+ });
156
+ onWrite?.();
157
+ break;
158
+ }
159
+ case "tool_approval_request": {
160
+ // The span exists (tool_use_start preceded it). Nothing to write:
161
+ // startedAt already marks the wait's beginning; the grant stamps
162
+ // the wait/execution boundary.
163
+ break;
164
+ }
165
+ case "tool_use_complete": {
166
+ // The complete input object in one frame — tool_input_delta
167
+ // frames stay ignored, no accumulation needed. Recorded as a
168
+ // span event so the Activity drill-in can expand the call (R14).
169
+ store.appendEvent(
170
+ msg.toolUseId,
171
+ "tool_input",
172
+ safeStringify(msg.input),
173
+ undefined,
174
+ PAYLOAD_EVENT_CAP
175
+ );
176
+ onWrite?.();
177
+ break;
178
+ }
179
+ case "tool_result": {
180
+ // endSpan is write-once: a span already closed (a denial landed
181
+ // the outcome first) rejects this as a no-op, and an unknown
182
+ // span id is equally a no-op — no bookkeeping set needed.
183
+ const outcome: SpanOutcome = msg.isError ? "error" : "success";
184
+ store.endSpan(msg.toolUseId, {
185
+ outcome,
186
+ reason: outcome === "error" ? clip(msg.output, 500) : undefined,
187
+ });
188
+ // Error output rides along too — a failed call's output is what
189
+ // the drill-in needs most. appendEvent no-ops on unknown spans.
190
+ store.appendEvent(msg.toolUseId, "tool_output", msg.output, undefined, PAYLOAD_EVENT_CAP);
191
+ onWrite?.();
192
+ break;
193
+ }
194
+ case "result": {
195
+ // Buffered — the single terminal write happens in finish().
196
+ resultOutcome =
197
+ msg.outcome === "success"
198
+ ? "success"
199
+ : msg.outcome === "cancelled"
200
+ ? "cancelled"
201
+ : msg.outcomeDetail === "max_turns" || msg.outcomeDetail === "max_budget"
202
+ ? "timeout"
203
+ : "error";
204
+ if (msg.usage) resultUsage = usageFromTurnUsage(msg.usage);
205
+ if (typeof msg.costUsd === "number") {
206
+ resultUsage = { ...(resultUsage ?? {}), costUsd: msg.costUsd };
207
+ }
208
+ resultAttrs = {
209
+ ...(msg.outcomeDetail ? { "outcome.detail": msg.outcomeDetail } : {}),
210
+ ...(msg.usage?.perModel
211
+ ? { "gen_ai.usage.per_model": msg.usage.perModel }
212
+ : {}),
213
+ };
214
+ break;
215
+ }
216
+ default:
217
+ break;
218
+ }
219
+ });
220
+ },
221
+
222
+ observeActivity(event) {
223
+ guard(() => {
224
+ if (finished) return;
225
+ switch (event.kind) {
226
+ case "subagent_started": {
227
+ store.patchSpan(event.toolUseId, {
228
+ attrs: {
229
+ "subagent.type": event.subagentType,
230
+ "subagent.description": event.description,
231
+ },
232
+ });
233
+ onWrite?.();
234
+ break;
235
+ }
236
+ case "subagent_status": {
237
+ const attrs = {
238
+ ...(event.usage ? { "subagent.total_tokens": event.usage.totalTokens } : {}),
239
+ ...(event.summary ? { "subagent.summary": event.summary } : {}),
240
+ };
241
+ if (Object.keys(attrs).length > 0) {
242
+ store.patchSpan(event.toolUseId, { attrs });
243
+ onWrite?.();
244
+ }
245
+ break;
246
+ }
247
+ case "subagent_transcript": {
248
+ store.appendEvent(event.toolUseId, `transcript_${event.role}`, event.text);
249
+ onWrite?.();
250
+ break;
251
+ }
252
+ }
253
+ });
254
+ },
255
+
256
+ onApprovalDecision(toolUseId, allowed) {
257
+ guard(() => {
258
+ if (allowed) {
259
+ // Everything before this moment was approval wait, not execution.
260
+ store.patchSpan(toolUseId, { waitUntil: Date.now() });
261
+ } else {
262
+ // The denied outcome lands now — write-once makes the backend's
263
+ // later error tool_result a no-op on this span.
264
+ store.endSpan(toolUseId, { outcome: "denied", reason: "user declined" });
265
+ }
266
+ onWrite?.();
267
+ });
268
+ },
269
+
270
+ finish(disposition) {
271
+ guard(() => {
272
+ if (finished) return;
273
+ finished = true;
274
+ ensureRoot();
275
+ // Merged precedence: the buffered result enrichment refines the
276
+ // host's own disposition; the host wins only where the backend said
277
+ // nothing. Cancellation/timeout are host-owned facts and always win.
278
+ const outcome: SpanOutcome =
279
+ disposition === "cancelled" || disposition === "timeout"
280
+ ? disposition
281
+ : (resultOutcome ?? disposition);
282
+ store.endSpan(rootSpanId, {
283
+ outcome,
284
+ usage: resultUsage,
285
+ attrs: resultAttrs,
286
+ });
287
+ // Anything still open died with the turn.
288
+ store.cascadeClose(runId, "cancelled", `turn ${outcome}`);
289
+ store.rollupRun(runId);
290
+ onWrite?.();
291
+ });
292
+ },
293
+ };
294
+ }
295
+
296
+ function clip(text: string, max: number): string {
297
+ return text.length > max ? text.slice(0, max) : text;
298
+ }
299
+
300
+ /** Cap for recorded tool input/output payload events — roomier than the
301
+ * 500-char outcome-reason clip, still far under the store's 16 KB event cap.
302
+ * Passed to appendEvent, whose clipping sets the wire `truncated` flag. */
303
+ const PAYLOAD_EVENT_CAP = 4096;
304
+
305
+ /** Tool inputs are arbitrary values: serialization must never take the turn
306
+ * down with it (BigInt members throw; a toJSON can return undefined). */
307
+ function safeStringify(value: unknown): string {
308
+ try {
309
+ return JSON.stringify(value) ?? String(value);
310
+ } catch {
311
+ return String(value);
312
+ }
313
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The activity runtime: everything the activity record needs to live inside
3
+ * one app instance — store, live stream, notifier, push sender, the boot
4
+ * orphan sweep, and the always-on lifecycle tick (stale sweep, notification
5
+ * detection, push delivery, hourly prune). `createApp` constructs one and
6
+ * wires its pieces into the host, routes, and bridge; `close()` releases the
7
+ * interval and subscriptions.
8
+ *
9
+ * Nothing here may block boot or fail the observed work: the sweep and every
10
+ * tick pass log and continue on failure.
11
+ */
12
+ import type { Database } from "bun:sqlite";
13
+ import type { Logger } from "@opentelemetry/api-logs";
14
+ import type { ActivityQuery, ActivityQueryResult } from "@schlessera/brain-ui-sdk/server";
15
+
16
+ import { createActivityStore, type ActivityStore, type RollupPricing } from "./store.js";
17
+ import { createActivityStream, type ActivityStream } from "./stream.js";
18
+ import { createActivityNotifier, type ActivityNotifier } from "./notify.js";
19
+ import { createPushSender, type PushSender } from "./push-sender.js";
20
+ import { digestRetentionFloor } from "./digest.js";
21
+ import { runActivityQuery } from "./query.js";
22
+ import { getDetailRetentionDays } from "../db/settings.js";
23
+
24
+ // Activity lifecycle cadence. The stale threshold must comfortably exceed
25
+ // the cron wrapper's heartbeat interval (~30s) so a live writer is never
26
+ // swept; the tick doubles as the always-on low-frequency sweep the
27
+ // notification layer extends.
28
+ const ACTIVITY_TICK_MS = 20_000;
29
+ const ACTIVITY_STALE_AFTER_MS = 2 * 60 * 1000;
30
+ const ACTIVITY_PRUNE_INTERVAL_MS = 60 * 60 * 1000;
31
+ const ACTIVITY_HARD_CEILING_MS = 90 * 24 * 60 * 60 * 1000;
32
+ /** Acknowledged notification intents are kept this long, then deleted. */
33
+ const INTENT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
34
+
35
+ export interface ActivityRuntime {
36
+ store: ActivityStore;
37
+ stream: ActivityStream;
38
+ notifier: ActivityNotifier;
39
+ pushSender: PushSender;
40
+ /** The agent-facing read seam (bridge.queryActivity). */
41
+ query(query: ActivityQuery): ActivityQueryResult;
42
+ close(): void;
43
+ }
44
+
45
+ export function createActivityRuntime(
46
+ db: Database,
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
+ }
56
+ ): ActivityRuntime {
57
+ const { log } = deps;
58
+ const store = createActivityStore(db, deps.pricing ? { pricing: deps.pricing } : {});
59
+ const stream = createActivityStream(store, log);
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
+
74
+ // Boot sweep: close this server's orphans from a previous life (interrupted).
75
+ try {
76
+ const orphans = store.sweepOwnOrphans();
77
+ if (orphans > 0) {
78
+ log.emit({
79
+ severityText: "WARN",
80
+ body: "closed orphaned activity spans from a previous process",
81
+ attributes: { count: orphans },
82
+ });
83
+ }
84
+ } catch (err) {
85
+ log.emit({
86
+ severityText: "WARN",
87
+ body: "boot activity sweep failed",
88
+ attributes: { error: err instanceof Error ? err.message : String(err) },
89
+ });
90
+ }
91
+
92
+ let delivering = false;
93
+ let lastPrune = 0;
94
+ const tick = setInterval(() => {
95
+ try {
96
+ const stale = store.sweepStale(ACTIVITY_STALE_AFTER_MS);
97
+ if (stale > 0) {
98
+ log.emit({
99
+ severityText: "WARN",
100
+ body: "closed stale activity spans (writer went silent)",
101
+ attributes: { count: stale },
102
+ });
103
+ stream.pump();
104
+ }
105
+ notifier.tick();
106
+ // Async delivery, reentrancy-guarded: a slow push service must not
107
+ // stack passes; the next tick simply retries what stayed pending.
108
+ if (!delivering) {
109
+ delivering = true;
110
+ void pushSender
111
+ .deliverPending(notifier)
112
+ .catch((err) =>
113
+ log.emit({
114
+ severityText: "WARN",
115
+ body: "push delivery pass failed",
116
+ attributes: { error: err instanceof Error ? err.message : String(err) },
117
+ })
118
+ )
119
+ .finally(() => {
120
+ delivering = false;
121
+ });
122
+ }
123
+ if (Date.now() - lastPrune > ACTIVITY_PRUNE_INTERVAL_MS) {
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
+ }
136
+ store.prune({
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.
141
+ digestFloorAt: digestRetentionFloor(db),
142
+ detailRetentionMs: getDetailRetentionDays(db, log) * 24 * 60 * 60 * 1000,
143
+ hardCeilingMs: ACTIVITY_HARD_CEILING_MS,
144
+ });
145
+ notifier.pruneAcknowledged(INTENT_RETENTION_MS);
146
+ }
147
+ } catch (err) {
148
+ log.emit({
149
+ severityText: "WARN",
150
+ body: "activity tick failed",
151
+ attributes: { error: err instanceof Error ? err.message : String(err) },
152
+ });
153
+ }
154
+ }, ACTIVITY_TICK_MS);
155
+ // A Bun interval would otherwise keep a closed test app's process alive.
156
+ if (typeof tick === "object" && "unref" in tick) tick.unref();
157
+
158
+ return {
159
+ store,
160
+ stream,
161
+ notifier,
162
+ pushSender,
163
+ query: (query) => runActivityQuery(db, store, query, notifier),
164
+ close() {
165
+ clearInterval(tick);
166
+ stream.close();
167
+ },
168
+ };
169
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The span-sink JSONL contract: how an agent-running cron step reports its
3
+ * activity to the wrapper that records the run.
4
+ *
5
+ * The wrapper (brain-ui's cron-run.ts) exports a file path in
6
+ * `BRAIN_ACTIVITY_SPAN_SINK`; the child process appends one JSON object per
7
+ * line and never reads the file back. The wrapper ingests incrementally
8
+ * (liveness) and once at exit (completeness), attaching everything under the
9
+ * run's root span. No core call site emits this yet — the contract ships
10
+ * first, fixture-verified, and producers arrive when core grows an agent
11
+ * loop (see the plan's Deferred to Follow-Up Work).
12
+ *
13
+ * Line shapes (unknown fields ignored, malformed lines skipped and counted):
14
+ *
15
+ * {"type":"span_start","spanId":"s1","name":"execute_tool Read","ts":123,
16
+ * "parentSpanId":"s0"?, "kind":"tool"?}
17
+ * {"type":"span_end","spanId":"s1","outcome":"success","ts":124,
18
+ * "reason":"..."?, "usage":{"inputTokens":1,...}?}
19
+ * {"type":"event","spanId":"s1","eventType":"text","payload":...,"ts":125}
20
+ */
21
+ import { readFileSync } from "fs";
22
+
23
+ import { SPAN_OUTCOMES, type ActivityStore, type SpanOutcome, type SpanUsage } from "./store.js";
24
+
25
+ export interface SpanSinkContext {
26
+ /** The run every ingested span belongs to. */
27
+ runId: string;
28
+ /** Parent for lines that name none — the cron root span. */
29
+ rootSpanId: string;
30
+ jobName?: string;
31
+ }
32
+
33
+ export interface SpanSinkResult {
34
+ /** Byte offset consumed so far — pass back on the next incremental call. */
35
+ offset: number;
36
+ ingested: number;
37
+ skipped: number;
38
+ }
39
+
40
+ /**
41
+ * Ingest new complete lines from a span-sink file. Incremental: pass the
42
+ * previous call's `offset`; a partial trailing line is left for next time.
43
+ * A missing file is an empty result, not an error — the child may simply
44
+ * never have emitted.
45
+ */
46
+ export function ingestSpanSink(
47
+ store: ActivityStore,
48
+ path: string,
49
+ context: SpanSinkContext,
50
+ offset = 0
51
+ ): SpanSinkResult {
52
+ let text: string;
53
+ try {
54
+ text = readFileSync(path, "utf-8");
55
+ } catch {
56
+ return { offset, ingested: 0, skipped: 0 };
57
+ }
58
+ if (text.length <= offset) return { offset, ingested: 0, skipped: 0 };
59
+
60
+ const chunk = text.slice(offset);
61
+ const lastNewline = chunk.lastIndexOf("\n");
62
+ if (lastNewline === -1) return { offset, ingested: 0, skipped: 0 };
63
+ const complete = chunk.slice(0, lastNewline);
64
+ const consumed = offset + lastNewline + 1;
65
+
66
+ let ingested = 0;
67
+ let skipped = 0;
68
+ for (const line of complete.split("\n")) {
69
+ if (!line.trim()) continue;
70
+ if (applyLine(store, line, context)) ingested++;
71
+ else skipped++;
72
+ }
73
+ return { offset: consumed, ingested, skipped };
74
+ }
75
+
76
+ function applyLine(store: ActivityStore, line: string, context: SpanSinkContext): boolean {
77
+ let parsed: Record<string, unknown>;
78
+ try {
79
+ parsed = JSON.parse(line);
80
+ } catch {
81
+ return false;
82
+ }
83
+ if (!parsed || typeof parsed !== "object") return false;
84
+ const spanId = str(parsed.spanId);
85
+ if (!spanId) return false;
86
+ // Sink span ids are namespaced by run so two runs' children can never
87
+ // collide in the shared store.
88
+ const scopedId = `${context.runId}:${spanId}`;
89
+
90
+ try {
91
+ switch (parsed.type) {
92
+ case "span_start": {
93
+ const name = str(parsed.name);
94
+ if (!name) return false;
95
+ const parent = str(parsed.parentSpanId);
96
+ store.startSpan({
97
+ spanId: scopedId,
98
+ runId: context.runId,
99
+ parentSpanId: parent ? `${context.runId}:${parent}` : context.rootSpanId,
100
+ name,
101
+ kind: parsed.kind === "subagent" ? "subagent" : "tool",
102
+ origin: "cron",
103
+ jobName: context.jobName,
104
+ startedAt: num(parsed.ts),
105
+ });
106
+ return true;
107
+ }
108
+ case "span_end": {
109
+ const outcome = str(parsed.outcome);
110
+ if (!outcome || !(SPAN_OUTCOMES as readonly string[]).includes(outcome)) return false;
111
+ return store.endSpan(scopedId, {
112
+ outcome: outcome as SpanOutcome,
113
+ reason: str(parsed.reason),
114
+ endedAt: num(parsed.ts),
115
+ usage: usageOf(parsed.usage),
116
+ });
117
+ }
118
+ case "event": {
119
+ const eventType = str(parsed.eventType);
120
+ if (!eventType) return false;
121
+ return store.appendEvent(scopedId, eventType, parsed.payload, num(parsed.ts)) !== null;
122
+ }
123
+ default:
124
+ return false;
125
+ }
126
+ } catch {
127
+ // A duplicate span_start (replayed line) or similar must not abort the
128
+ // whole ingest.
129
+ return false;
130
+ }
131
+ }
132
+
133
+ function str(v: unknown): string | undefined {
134
+ return typeof v === "string" && v.length > 0 ? v : undefined;
135
+ }
136
+
137
+ function num(v: unknown): number | undefined {
138
+ return typeof v === "number" && Number.isFinite(v) ? v : undefined;
139
+ }
140
+
141
+ function usageOf(v: unknown): SpanUsage | undefined {
142
+ if (!v || typeof v !== "object") return undefined;
143
+ const u = v as Record<string, unknown>;
144
+ return {
145
+ inputTokens: num(u.inputTokens),
146
+ outputTokens: num(u.outputTokens),
147
+ cacheReadTokens: num(u.cacheReadTokens),
148
+ cacheCreationTokens: num(u.cacheCreationTokens),
149
+ costUsd: num(u.costUsd),
150
+ model: str(u.model),
151
+ };
152
+ }