@schlessera/brain-ui-server 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/dist/activity/digest.d.ts +29 -0
  2. package/dist/activity/digest.d.ts.map +1 -0
  3. package/dist/activity/digest.js +61 -0
  4. package/dist/activity/digest.js.map +1 -0
  5. package/dist/activity/notify.d.ts +62 -0
  6. package/dist/activity/notify.d.ts.map +1 -0
  7. package/dist/activity/notify.js +171 -0
  8. package/dist/activity/notify.js.map +1 -0
  9. package/dist/activity/push-sender.d.ts +63 -0
  10. package/dist/activity/push-sender.d.ts.map +1 -0
  11. package/dist/activity/push-sender.js +131 -0
  12. package/dist/activity/push-sender.js.map +1 -0
  13. package/dist/activity/query.d.ts +16 -0
  14. package/dist/activity/query.d.ts.map +1 -0
  15. package/dist/activity/query.js +128 -0
  16. package/dist/activity/query.js.map +1 -0
  17. package/dist/activity/recorder.d.ts +48 -0
  18. package/dist/activity/recorder.d.ts.map +1 -0
  19. package/dist/activity/recorder.js +205 -0
  20. package/dist/activity/recorder.js.map +1 -0
  21. package/dist/activity/runtime.d.ts +31 -0
  22. package/dist/activity/runtime.d.ts.map +1 -0
  23. package/dist/activity/runtime.js +109 -0
  24. package/dist/activity/runtime.js.map +1 -0
  25. package/dist/activity/span-sink.d.ts +22 -0
  26. package/dist/activity/span-sink.d.ts.map +1 -0
  27. package/dist/activity/span-sink.js +138 -0
  28. package/dist/activity/span-sink.js.map +1 -0
  29. package/dist/activity/store.d.ts +207 -0
  30. package/dist/activity/store.d.ts.map +1 -0
  31. package/dist/activity/store.js +440 -0
  32. package/dist/activity/store.js.map +1 -0
  33. package/dist/activity/stream.d.ts +45 -0
  34. package/dist/activity/stream.d.ts.map +1 -0
  35. package/dist/activity/stream.js +312 -0
  36. package/dist/activity/stream.js.map +1 -0
  37. package/dist/app.d.ts.map +1 -1
  38. package/dist/app.js +16 -0
  39. package/dist/app.js.map +1 -1
  40. package/dist/cron/scheduler.d.ts.map +1 -1
  41. package/dist/cron/scheduler.js +20 -5
  42. package/dist/cron/scheduler.js.map +1 -1
  43. package/dist/db/client.d.ts.map +1 -1
  44. package/dist/db/client.js +32 -6
  45. package/dist/db/client.js.map +1 -1
  46. package/dist/index.d.ts +4 -0
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +7 -0
  49. package/dist/index.js.map +1 -1
  50. package/dist/routes/activity.d.ts +20 -0
  51. package/dist/routes/activity.d.ts.map +1 -0
  52. package/dist/routes/activity.js +300 -0
  53. package/dist/routes/activity.js.map +1 -0
  54. package/dist/routes/push.d.ts +6 -0
  55. package/dist/routes/push.d.ts.map +1 -0
  56. package/dist/routes/push.js +73 -0
  57. package/dist/routes/push.js.map +1 -0
  58. package/dist/routes/sessions.d.ts.map +1 -1
  59. package/dist/routes/sessions.js +20 -4
  60. package/dist/routes/sessions.js.map +1 -1
  61. package/dist/ws/bridge.d.ts +2 -1
  62. package/dist/ws/bridge.d.ts.map +1 -1
  63. package/dist/ws/bridge.js +27 -2
  64. package/dist/ws/bridge.js.map +1 -1
  65. package/dist/ws/connection.d.ts.map +1 -1
  66. package/dist/ws/connection.js +9 -1
  67. package/dist/ws/connection.js.map +1 -1
  68. package/dist/ws/dispatch.d.ts.map +1 -1
  69. package/dist/ws/dispatch.js +11 -0
  70. package/dist/ws/dispatch.js.map +1 -1
  71. package/dist/ws/host.d.ts +16 -0
  72. package/dist/ws/host.d.ts.map +1 -1
  73. package/dist/ws/host.js +2 -0
  74. package/dist/ws/host.js.map +1 -1
  75. package/dist/ws/run-session.d.ts.map +1 -1
  76. package/dist/ws/run-session.js +21 -1
  77. package/dist/ws/run-session.js.map +1 -1
  78. package/migrations/007_activity.sql +114 -0
  79. package/migrations/008_push_subscriptions.sql +24 -0
  80. package/package.json +5 -3
  81. package/src/activity/digest.ts +92 -0
  82. package/src/activity/notify.ts +285 -0
  83. package/src/activity/push-sender.ts +215 -0
  84. package/src/activity/query.ts +160 -0
  85. package/src/activity/recorder.ts +261 -0
  86. package/src/activity/runtime.ts +141 -0
  87. package/src/activity/span-sink.ts +152 -0
  88. package/src/activity/store.ts +774 -0
  89. package/src/activity/stream.ts +366 -0
  90. package/src/app.ts +17 -0
  91. package/src/cron/scheduler.ts +28 -8
  92. package/src/db/client.ts +31 -6
  93. package/src/index.ts +22 -0
  94. package/src/routes/activity.ts +363 -0
  95. package/src/routes/push.ts +90 -0
  96. package/src/routes/sessions.ts +24 -4
  97. package/src/ws/bridge.ts +29 -2
  98. package/src/ws/connection.ts +9 -1
  99. package/src/ws/dispatch.ts +13 -0
  100. package/src/ws/host.ts +18 -0
  101. package/src/ws/run-session.ts +28 -1
@@ -0,0 +1,261 @@
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 type { Logger } from "@opentelemetry/api-logs";
28
+
29
+ import type { ActivityStore, SpanOutcome, SpanUsage } from "./store.js";
30
+
31
+ export interface TurnRecorderDeps {
32
+ store: ActivityStore;
33
+ /** Called after any committed write so the live stream can pump. */
34
+ onWrite?: () => void;
35
+ log?: Logger;
36
+ }
37
+
38
+ export interface TurnRecorder {
39
+ /** The run id (one run per turn). */
40
+ readonly runId: string;
41
+ observeFrame(msg: ServerMessage): void;
42
+ observeActivity(event: BackendActivityEvent): void;
43
+ /** The user's approval decision arrived for a gated tool call. */
44
+ onApprovalDecision(toolUseId: string, allowed: boolean): void;
45
+ /**
46
+ * Close the turn: merge the buffered result enrichment into the root's
47
+ * single terminal write, cascade-cancel anything left open, roll up.
48
+ */
49
+ finish(disposition: "success" | "error" | "cancelled" | "timeout"): void;
50
+ }
51
+
52
+ export function createTurnRecorder(
53
+ deps: TurnRecorderDeps,
54
+ turn: { turnId: string; sessionId: string | null }
55
+ ): TurnRecorder {
56
+ const { store, onWrite, log } = deps;
57
+ const runId = turn.turnId;
58
+ const rootSpanId = `${runId}:turn`;
59
+ let sessionId = turn.sessionId ?? undefined;
60
+ let rootStarted = false;
61
+ let finished = false;
62
+ /** Buffered terminal enrichment from the result frame. */
63
+ let resultOutcome: SpanOutcome | null = null;
64
+ let resultUsage: SpanUsage | undefined;
65
+ let resultAttrs: Record<string, unknown> | undefined;
66
+
67
+ const guard = (fn: () => void) => {
68
+ try {
69
+ fn();
70
+ } catch (err) {
71
+ log?.emit({
72
+ severityText: "WARN",
73
+ body: "activity recording failed; dropping",
74
+ attributes: { error: err instanceof Error ? err.message : String(err) },
75
+ });
76
+ }
77
+ };
78
+
79
+ function ensureRoot() {
80
+ if (rootStarted) return;
81
+ rootStarted = true;
82
+ store.startSpan({
83
+ spanId: rootSpanId,
84
+ runId,
85
+ name: "invoke_agent",
86
+ kind: "turn",
87
+ origin: "session",
88
+ sessionId,
89
+ attrs: { "gen_ai.operation.name": "invoke_agent" },
90
+ });
91
+ onWrite?.();
92
+ }
93
+
94
+ function usageFromTurnUsage(usage: TurnUsage): SpanUsage {
95
+ const models = usage.perModel ? Object.keys(usage.perModel) : [];
96
+ return {
97
+ inputTokens: usage.inputTokens,
98
+ outputTokens: usage.outputTokens,
99
+ cacheReadTokens: usage.cacheReadTokens,
100
+ cacheCreationTokens: usage.cacheCreationTokens,
101
+ model: models.length === 1 ? models[0] : undefined,
102
+ };
103
+ }
104
+
105
+ return {
106
+ runId,
107
+
108
+ observeFrame(msg) {
109
+ guard(() => {
110
+ if (finished) return;
111
+ switch (msg.type) {
112
+ case "session_info": {
113
+ sessionId = msg.sessionId;
114
+ ensureRoot();
115
+ // The root may have been opened before the session had a name.
116
+ store.patchSpan(rootSpanId, { attrs: { "session.id": msg.sessionId } });
117
+ onWrite?.();
118
+ break;
119
+ }
120
+ case "tool_use_start": {
121
+ ensureRoot();
122
+ const isSubagent = msg.toolName === "Agent";
123
+ store.startSpan({
124
+ spanId: msg.toolUseId,
125
+ runId,
126
+ parentSpanId: msg.parentToolUseId ?? rootSpanId,
127
+ name: `execute_tool ${msg.toolName}`,
128
+ kind: isSubagent ? "subagent" : "tool",
129
+ origin: "session",
130
+ sessionId,
131
+ attrs: {
132
+ "gen_ai.operation.name": isSubagent ? "invoke_agent" : "execute_tool",
133
+ "gen_ai.tool.name": msg.toolName,
134
+ },
135
+ });
136
+ onWrite?.();
137
+ break;
138
+ }
139
+ case "tool_approval_request": {
140
+ // The span exists (tool_use_start preceded it). Nothing to write:
141
+ // startedAt already marks the wait's beginning; the grant stamps
142
+ // the wait/execution boundary.
143
+ break;
144
+ }
145
+ case "tool_result": {
146
+ // endSpan is write-once: a span already closed (a denial landed
147
+ // the outcome first) rejects this as a no-op, and an unknown
148
+ // span id is equally a no-op — no bookkeeping set needed.
149
+ const outcome: SpanOutcome = msg.isError ? "error" : "success";
150
+ store.endSpan(msg.toolUseId, {
151
+ outcome,
152
+ reason: outcome === "error" ? clip(msg.output, 500) : undefined,
153
+ });
154
+ onWrite?.();
155
+ break;
156
+ }
157
+ case "result": {
158
+ // Buffered — the single terminal write happens in finish().
159
+ resultOutcome =
160
+ msg.outcome === "success"
161
+ ? "success"
162
+ : msg.outcome === "cancelled"
163
+ ? "cancelled"
164
+ : msg.outcomeDetail === "max_turns" || msg.outcomeDetail === "max_budget"
165
+ ? "timeout"
166
+ : "error";
167
+ if (msg.usage) resultUsage = usageFromTurnUsage(msg.usage);
168
+ if (typeof msg.costUsd === "number") {
169
+ resultUsage = { ...(resultUsage ?? {}), costUsd: msg.costUsd };
170
+ }
171
+ resultAttrs = {
172
+ ...(msg.outcomeDetail ? { "outcome.detail": msg.outcomeDetail } : {}),
173
+ ...(msg.usage?.perModel
174
+ ? { "gen_ai.usage.per_model": msg.usage.perModel }
175
+ : {}),
176
+ };
177
+ break;
178
+ }
179
+ default:
180
+ break;
181
+ }
182
+ });
183
+ },
184
+
185
+ observeActivity(event) {
186
+ guard(() => {
187
+ if (finished) return;
188
+ switch (event.kind) {
189
+ case "subagent_started": {
190
+ store.patchSpan(event.toolUseId, {
191
+ attrs: {
192
+ "subagent.type": event.subagentType,
193
+ "subagent.description": event.description,
194
+ },
195
+ });
196
+ onWrite?.();
197
+ break;
198
+ }
199
+ case "subagent_status": {
200
+ const attrs = {
201
+ ...(event.usage ? { "subagent.total_tokens": event.usage.totalTokens } : {}),
202
+ ...(event.summary ? { "subagent.summary": event.summary } : {}),
203
+ };
204
+ if (Object.keys(attrs).length > 0) {
205
+ store.patchSpan(event.toolUseId, { attrs });
206
+ onWrite?.();
207
+ }
208
+ break;
209
+ }
210
+ case "subagent_transcript": {
211
+ store.appendEvent(event.toolUseId, `transcript_${event.role}`, event.text);
212
+ onWrite?.();
213
+ break;
214
+ }
215
+ }
216
+ });
217
+ },
218
+
219
+ onApprovalDecision(toolUseId, allowed) {
220
+ guard(() => {
221
+ if (allowed) {
222
+ // Everything before this moment was approval wait, not execution.
223
+ store.patchSpan(toolUseId, { waitUntil: Date.now() });
224
+ } else {
225
+ // The denied outcome lands now — write-once makes the backend's
226
+ // later error tool_result a no-op on this span.
227
+ store.endSpan(toolUseId, { outcome: "denied", reason: "user declined" });
228
+ }
229
+ onWrite?.();
230
+ });
231
+ },
232
+
233
+ finish(disposition) {
234
+ guard(() => {
235
+ if (finished) return;
236
+ finished = true;
237
+ ensureRoot();
238
+ // Merged precedence: the buffered result enrichment refines the
239
+ // host's own disposition; the host wins only where the backend said
240
+ // nothing. Cancellation/timeout are host-owned facts and always win.
241
+ const outcome: SpanOutcome =
242
+ disposition === "cancelled" || disposition === "timeout"
243
+ ? disposition
244
+ : (resultOutcome ?? disposition);
245
+ store.endSpan(rootSpanId, {
246
+ outcome,
247
+ usage: resultUsage,
248
+ attrs: resultAttrs,
249
+ });
250
+ // Anything still open died with the turn.
251
+ store.cascadeClose(runId, "cancelled", `turn ${outcome}`);
252
+ store.rollupRun(runId);
253
+ onWrite?.();
254
+ });
255
+ },
256
+ };
257
+ }
258
+
259
+ function clip(text: string, max: number): string {
260
+ return text.length > max ? text.slice(0, max) : text;
261
+ }
@@ -0,0 +1,141 @@
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 } 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
+
23
+ // Activity lifecycle cadence. The stale threshold must comfortably exceed
24
+ // the cron wrapper's heartbeat interval (~30s) so a live writer is never
25
+ // swept; the tick doubles as the always-on low-frequency sweep the
26
+ // notification layer extends.
27
+ const ACTIVITY_TICK_MS = 20_000;
28
+ const ACTIVITY_STALE_AFTER_MS = 2 * 60 * 1000;
29
+ const ACTIVITY_PRUNE_INTERVAL_MS = 60 * 60 * 1000;
30
+ const ACTIVITY_HARD_CEILING_MS = 90 * 24 * 60 * 60 * 1000;
31
+ /** Acknowledged notification intents are kept this long, then deleted. */
32
+ const INTENT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
33
+
34
+ export interface ActivityRuntime {
35
+ store: ActivityStore;
36
+ stream: ActivityStream;
37
+ notifier: ActivityNotifier;
38
+ pushSender: PushSender;
39
+ /** The agent-facing read seam (bridge.queryActivity). */
40
+ query(query: ActivityQuery): ActivityQueryResult;
41
+ close(): void;
42
+ }
43
+
44
+ export function createActivityRuntime(
45
+ db: Database,
46
+ deps: { log: Logger }
47
+ ): ActivityRuntime {
48
+ const { log } = deps;
49
+ const store = createActivityStore(db);
50
+ const stream = createActivityStream(store, log);
51
+
52
+ // Boot sweep: close this server's orphans from a previous life (interrupted).
53
+ try {
54
+ const orphans = store.sweepOwnOrphans();
55
+ if (orphans > 0) {
56
+ log.emit({
57
+ severityText: "WARN",
58
+ body: "closed orphaned activity spans from a previous process",
59
+ attributes: { count: orphans },
60
+ });
61
+ }
62
+ } catch (err) {
63
+ log.emit({
64
+ severityText: "WARN",
65
+ body: "boot activity sweep failed",
66
+ attributes: { error: err instanceof Error ? err.message : String(err) },
67
+ });
68
+ }
69
+
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
+ let delivering = false;
79
+ let lastPrune = 0;
80
+ const tick = setInterval(() => {
81
+ try {
82
+ const stale = store.sweepStale(ACTIVITY_STALE_AFTER_MS);
83
+ if (stale > 0) {
84
+ log.emit({
85
+ severityText: "WARN",
86
+ body: "closed stale activity spans (writer went silent)",
87
+ attributes: { count: stale },
88
+ });
89
+ stream.pump();
90
+ }
91
+ notifier.tick();
92
+ // Async delivery, reentrancy-guarded: a slow push service must not
93
+ // stack passes; the next tick simply retries what stayed pending.
94
+ if (!delivering) {
95
+ delivering = true;
96
+ void pushSender
97
+ .deliverPending(notifier)
98
+ .catch((err) =>
99
+ log.emit({
100
+ severityText: "WARN",
101
+ body: "push delivery pass failed",
102
+ attributes: { error: err instanceof Error ? err.message : String(err) },
103
+ })
104
+ )
105
+ .finally(() => {
106
+ delivering = false;
107
+ });
108
+ }
109
+ if (Date.now() - lastPrune > ACTIVITY_PRUNE_INTERVAL_MS) {
110
+ lastPrune = Date.now();
111
+ 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.
114
+ digestFloorAt: digestRetentionFloor(db),
115
+ hardCeilingMs: ACTIVITY_HARD_CEILING_MS,
116
+ });
117
+ notifier.pruneAcknowledged(INTENT_RETENTION_MS);
118
+ }
119
+ } catch (err) {
120
+ log.emit({
121
+ severityText: "WARN",
122
+ body: "activity tick failed",
123
+ attributes: { error: err instanceof Error ? err.message : String(err) },
124
+ });
125
+ }
126
+ }, ACTIVITY_TICK_MS);
127
+ // A Bun interval would otherwise keep a closed test app's process alive.
128
+ if (typeof tick === "object" && "unref" in tick) tick.unref();
129
+
130
+ return {
131
+ store,
132
+ stream,
133
+ notifier,
134
+ pushSender,
135
+ query: (query) => runActivityQuery(db, store, query, notifier),
136
+ close() {
137
+ clearInterval(tick);
138
+ stream.close();
139
+ },
140
+ };
141
+ }
@@ -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
+ }