@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.
- package/README.md +5 -1
- package/data/model-prices.json +63 -0
- package/dist/activity/digest.d.ts +29 -0
- package/dist/activity/digest.d.ts.map +1 -0
- package/dist/activity/digest.js +89 -0
- package/dist/activity/digest.js.map +1 -0
- package/dist/activity/notify.d.ts +62 -0
- package/dist/activity/notify.d.ts.map +1 -0
- package/dist/activity/notify.js +193 -0
- package/dist/activity/notify.js.map +1 -0
- package/dist/activity/push-sender.d.ts +63 -0
- package/dist/activity/push-sender.d.ts.map +1 -0
- package/dist/activity/push-sender.js +131 -0
- package/dist/activity/push-sender.js.map +1 -0
- package/dist/activity/query.d.ts +16 -0
- package/dist/activity/query.d.ts.map +1 -0
- package/dist/activity/query.js +155 -0
- package/dist/activity/query.js.map +1 -0
- package/dist/activity/recorder.d.ts +53 -0
- package/dist/activity/recorder.d.ts.map +1 -0
- package/dist/activity/recorder.js +238 -0
- package/dist/activity/recorder.js.map +1 -0
- package/dist/activity/runtime.d.ts +37 -0
- package/dist/activity/runtime.d.ts.map +1 -0
- package/dist/activity/runtime.js +127 -0
- package/dist/activity/runtime.js.map +1 -0
- package/dist/activity/span-sink.d.ts +22 -0
- package/dist/activity/span-sink.d.ts.map +1 -0
- package/dist/activity/span-sink.js +138 -0
- package/dist/activity/span-sink.js.map +1 -0
- package/dist/activity/store.d.ts +256 -0
- package/dist/activity/store.d.ts.map +1 -0
- package/dist/activity/store.js +657 -0
- package/dist/activity/store.js.map +1 -0
- package/dist/activity/stream.d.ts +49 -0
- package/dist/activity/stream.d.ts.map +1 -0
- package/dist/activity/stream.js +353 -0
- package/dist/activity/stream.js.map +1 -0
- package/dist/agent/backend.d.ts +13 -4
- package/dist/agent/backend.d.ts.map +1 -1
- package/dist/agent/backend.js +68 -5
- package/dist/agent/backend.js.map +1 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +33 -2
- package/dist/app.js.map +1 -1
- package/dist/config/env.d.ts +36 -0
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +66 -2
- package/dist/config/env.js.map +1 -1
- package/dist/cron/scheduler.d.ts.map +1 -1
- package/dist/cron/scheduler.js +20 -5
- package/dist/cron/scheduler.js.map +1 -1
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +32 -6
- package/dist/db/client.js.map +1 -1
- package/dist/db/settings.d.ts +15 -0
- package/dist/db/settings.d.ts.map +1 -1
- package/dist/db/settings.js +43 -0
- package/dist/db/settings.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/pricing/model-pricing.d.ts +61 -0
- package/dist/pricing/model-pricing.d.ts.map +1 -0
- package/dist/pricing/model-pricing.js +379 -0
- package/dist/pricing/model-pricing.js.map +1 -0
- package/dist/routes/activity.d.ts +20 -0
- package/dist/routes/activity.d.ts.map +1 -0
- package/dist/routes/activity.js +308 -0
- package/dist/routes/activity.js.map +1 -0
- package/dist/routes/models.d.ts +6 -0
- package/dist/routes/models.d.ts.map +1 -1
- package/dist/routes/models.js +38 -3
- package/dist/routes/models.js.map +1 -1
- package/dist/routes/push.d.ts +6 -0
- package/dist/routes/push.d.ts.map +1 -0
- package/dist/routes/push.js +73 -0
- package/dist/routes/push.js.map +1 -0
- package/dist/routes/sessions.d.ts.map +1 -1
- package/dist/routes/sessions.js +20 -4
- package/dist/routes/sessions.js.map +1 -1
- package/dist/ws/bridge.d.ts +2 -1
- package/dist/ws/bridge.d.ts.map +1 -1
- package/dist/ws/bridge.js +27 -2
- package/dist/ws/bridge.js.map +1 -1
- package/dist/ws/connection.d.ts.map +1 -1
- package/dist/ws/connection.js +9 -1
- package/dist/ws/connection.js.map +1 -1
- package/dist/ws/dispatch.d.ts.map +1 -1
- package/dist/ws/dispatch.js +11 -0
- package/dist/ws/dispatch.js.map +1 -1
- package/dist/ws/host.d.ts +16 -0
- package/dist/ws/host.d.ts.map +1 -1
- package/dist/ws/host.js +2 -0
- package/dist/ws/host.js.map +1 -1
- package/dist/ws/run-session.d.ts.map +1 -1
- package/dist/ws/run-session.js +55 -1
- package/dist/ws/run-session.js.map +1 -1
- package/migrations/007_activity.sql +114 -0
- package/migrations/008_push_subscriptions.sql +24 -0
- package/migrations/009_activity_followups.sql +22 -0
- package/migrations/010_effective_cost.sql +21 -0
- package/package.json +6 -3
- package/src/activity/digest.ts +120 -0
- package/src/activity/notify.ts +307 -0
- package/src/activity/push-sender.ts +215 -0
- package/src/activity/query.ts +187 -0
- package/src/activity/recorder.ts +313 -0
- package/src/activity/runtime.ts +169 -0
- package/src/activity/span-sink.ts +152 -0
- package/src/activity/store.ts +1069 -0
- package/src/activity/stream.ts +396 -0
- package/src/agent/backend.ts +91 -10
- package/src/app.ts +35 -2
- package/src/config/env.ts +90 -2
- package/src/cron/scheduler.ts +28 -8
- package/src/db/client.ts +31 -6
- package/src/db/settings.ts +56 -0
- package/src/index.ts +23 -0
- package/src/pricing/model-pricing.ts +497 -0
- package/src/routes/activity.ts +371 -0
- package/src/routes/models.ts +57 -6
- package/src/routes/push.ts +90 -0
- package/src/routes/sessions.ts +24 -4
- package/src/ws/bridge.ts +29 -2
- package/src/ws/connection.ts +9 -1
- package/src/ws/dispatch.ts +13 -0
- package/src/ws/host.ts +18 -0
- package/src/ws/run-session.ts +71 -2
|
@@ -0,0 +1,1069 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The activity store: the canonical record of agent activity (migration 007).
|
|
3
|
+
*
|
|
4
|
+
* Every unit of agent work — a turn, a tool call, a subagent run, a cron run —
|
|
5
|
+
* is a span row forming a tree per run. Spans are written AT START (a
|
|
6
|
+
* non-terminal row exists while the work runs — that is what makes
|
|
7
|
+
* glance-checks, stuck-run detection, and "what is running?" queries
|
|
8
|
+
* possible) and updated to a write-once terminal outcome.
|
|
9
|
+
*
|
|
10
|
+
* Ordering has two primitives, both minted here inside the write transaction:
|
|
11
|
+
*
|
|
12
|
+
* - `seq` is per-run monotonic and totally orders every delta-visible write
|
|
13
|
+
* within a run. A snapshot carries the run's high-water seq; a client
|
|
14
|
+
* discards deltas at or below it. Deltas are derived from committed rows —
|
|
15
|
+
* persist-then-emit — so the stream can never show what the store does not
|
|
16
|
+
* hold.
|
|
17
|
+
* - `change_id` (AUTOINCREMENT rowid of activity_changes) is the GLOBAL
|
|
18
|
+
* cursor. It exists because per-run seq cannot discover a run the poller
|
|
19
|
+
* has never seen: a foreign writer's brand-new root span is visible only
|
|
20
|
+
* as "a change with a higher change_id than my cursor".
|
|
21
|
+
*
|
|
22
|
+
* Every write runs under an IMMEDIATE transaction: two processes write this
|
|
23
|
+
* database (server + cron wrapper), and a deferred transaction losing the
|
|
24
|
+
* upgrade race throws SQLITE_BUSY instead of waiting. `createUiDb` sets
|
|
25
|
+
* `busy_timeout` so immediate transactions queue rather than throw.
|
|
26
|
+
*
|
|
27
|
+
* Nothing in here may ever fail the work being observed: callers that
|
|
28
|
+
* instrument live turns wrap calls in try/catch and drop on error
|
|
29
|
+
* (observability must not break the observed) — but the STORE itself throws
|
|
30
|
+
* on programmer error, because a silent half-written record is worse than a
|
|
31
|
+
* loud one.
|
|
32
|
+
*/
|
|
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";
|
|
41
|
+
|
|
42
|
+
export const SPAN_OUTCOMES = [
|
|
43
|
+
"success",
|
|
44
|
+
"error",
|
|
45
|
+
"timeout",
|
|
46
|
+
"cancelled",
|
|
47
|
+
"denied",
|
|
48
|
+
"interrupted",
|
|
49
|
+
] as const;
|
|
50
|
+
export type SpanOutcome = (typeof SPAN_OUTCOMES)[number];
|
|
51
|
+
|
|
52
|
+
export type SpanKind = "turn" | "tool" | "subagent" | "cron";
|
|
53
|
+
export type SpanOrigin = "session" | "cron";
|
|
54
|
+
|
|
55
|
+
/** Token usage as spans carry it — OTel GenAI attribute semantics. */
|
|
56
|
+
export interface SpanUsage {
|
|
57
|
+
inputTokens?: number;
|
|
58
|
+
outputTokens?: number;
|
|
59
|
+
cacheReadTokens?: number;
|
|
60
|
+
cacheCreationTokens?: number;
|
|
61
|
+
costUsd?: number;
|
|
62
|
+
model?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SpanRow {
|
|
66
|
+
spanId: string;
|
|
67
|
+
runId: string;
|
|
68
|
+
parentSpanId: string | null;
|
|
69
|
+
name: string;
|
|
70
|
+
kind: SpanKind;
|
|
71
|
+
origin: SpanOrigin;
|
|
72
|
+
sessionId: string | null;
|
|
73
|
+
jobName: string | null;
|
|
74
|
+
attrs: Record<string, unknown>;
|
|
75
|
+
startedAt: number;
|
|
76
|
+
waitUntil: number | null;
|
|
77
|
+
endedAt: number | null;
|
|
78
|
+
outcome: SpanOutcome | null;
|
|
79
|
+
outcomeReason: string | null;
|
|
80
|
+
usage: SpanUsage;
|
|
81
|
+
writer: string;
|
|
82
|
+
lastHeartbeatAt: number | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface SpanEventRow {
|
|
86
|
+
spanId: string;
|
|
87
|
+
eventIndex: number;
|
|
88
|
+
ts: number;
|
|
89
|
+
eventType: string;
|
|
90
|
+
payload: unknown;
|
|
91
|
+
truncated: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One `activity_run_rollups` row, snake→camel. */
|
|
95
|
+
export interface RunRollupRow {
|
|
96
|
+
runId: string;
|
|
97
|
+
origin: SpanOrigin;
|
|
98
|
+
name: string;
|
|
99
|
+
sessionId: string | null;
|
|
100
|
+
jobName: string | null;
|
|
101
|
+
startedAt: number;
|
|
102
|
+
endedAt: number | null;
|
|
103
|
+
outcome: SpanOutcome | null;
|
|
104
|
+
durationMs: number | null;
|
|
105
|
+
spanCount: number;
|
|
106
|
+
inputTokens: number | null;
|
|
107
|
+
outputTokens: number | null;
|
|
108
|
+
cacheReadTokens: number | null;
|
|
109
|
+
cacheCreationTokens: number | null;
|
|
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;
|
|
121
|
+
failureReason: string | null;
|
|
122
|
+
detailPruned: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The one snake→camel mapper for rollup rows — every reader shares it. */
|
|
126
|
+
export function rowToRunRollup(r: any): RunRollupRow {
|
|
127
|
+
return {
|
|
128
|
+
runId: r.run_id,
|
|
129
|
+
origin: r.origin,
|
|
130
|
+
name: r.name,
|
|
131
|
+
sessionId: r.session_id,
|
|
132
|
+
jobName: r.job_name,
|
|
133
|
+
startedAt: r.started_at,
|
|
134
|
+
endedAt: r.ended_at,
|
|
135
|
+
outcome: r.outcome,
|
|
136
|
+
durationMs: r.duration_ms,
|
|
137
|
+
spanCount: r.span_count,
|
|
138
|
+
inputTokens: r.input_tokens,
|
|
139
|
+
outputTokens: r.output_tokens,
|
|
140
|
+
cacheReadTokens: r.cache_read_tokens,
|
|
141
|
+
cacheCreationTokens: r.cache_creation_tokens,
|
|
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,
|
|
148
|
+
failureReason: r.failure_reason,
|
|
149
|
+
detailPruned: r.detail_pruned === 1,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
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
|
+
|
|
171
|
+
/** One committed write, as the delta stream sees it. */
|
|
172
|
+
export type ActivityChange =
|
|
173
|
+
| { changeId: number; runId: string; seq: number; kind: "span"; span: SpanRow }
|
|
174
|
+
| { changeId: number; runId: string; seq: number; kind: "event"; event: SpanEventRow };
|
|
175
|
+
|
|
176
|
+
export interface StartSpanInput {
|
|
177
|
+
spanId: string;
|
|
178
|
+
runId: string;
|
|
179
|
+
parentSpanId?: string;
|
|
180
|
+
name: string;
|
|
181
|
+
kind: SpanKind;
|
|
182
|
+
origin: SpanOrigin;
|
|
183
|
+
sessionId?: string;
|
|
184
|
+
jobName?: string;
|
|
185
|
+
attrs?: Record<string, unknown>;
|
|
186
|
+
startedAt?: number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface EndSpanInput {
|
|
190
|
+
outcome: SpanOutcome;
|
|
191
|
+
reason?: string;
|
|
192
|
+
endedAt?: number;
|
|
193
|
+
usage?: SpanUsage;
|
|
194
|
+
attrs?: Record<string, unknown>;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface RunSnapshot {
|
|
198
|
+
runId: string;
|
|
199
|
+
spans: SpanRow[];
|
|
200
|
+
events: SpanEventRow[];
|
|
201
|
+
/** Per-run high-water seq at the moment of the read, in the SAME
|
|
202
|
+
* transaction as the reads — the poller/subscriber baseline. */
|
|
203
|
+
highWaterSeq: number;
|
|
204
|
+
/** Global cursor at the same moment, for pinning a poller baseline. */
|
|
205
|
+
changeCursor: number;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface PruneOptions {
|
|
209
|
+
/** Spans of runs that ended before this are prunable (digest floor).
|
|
210
|
+
* Absolute epoch ms, not an offset. */
|
|
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;
|
|
216
|
+
/** Runs older than this are pruned REGARDLESS of the digest floor. */
|
|
217
|
+
hardCeilingMs: number;
|
|
218
|
+
now?: number;
|
|
219
|
+
/** Max runs pruned per call — pruning is batched so writers are not starved. */
|
|
220
|
+
batch?: number;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A single event payload is capped so no delta can approach the WS frame cap. */
|
|
224
|
+
export const MAX_EVENT_PAYLOAD_BYTES = 16_384;
|
|
225
|
+
|
|
226
|
+
export interface ActivityStore {
|
|
227
|
+
readonly writer: string;
|
|
228
|
+
startSpan(input: StartSpanInput): SpanRow;
|
|
229
|
+
/**
|
|
230
|
+
* Terminal write. Write-once: returns false (and writes nothing) if the
|
|
231
|
+
* span is already terminal or unknown. Merged-outcome precedence lives in
|
|
232
|
+
* the CALLER (the host merges reporter enrichment before its single
|
|
233
|
+
* terminal write); the store just enforces once-ness.
|
|
234
|
+
*/
|
|
235
|
+
endSpan(spanId: string, input: EndSpanInput): boolean;
|
|
236
|
+
/** Non-terminal field updates (usage enrichment, wait boundary, attrs). */
|
|
237
|
+
patchSpan(
|
|
238
|
+
spanId: string,
|
|
239
|
+
patch: { attrs?: Record<string, unknown>; usage?: SpanUsage; waitUntil?: number }
|
|
240
|
+
): boolean;
|
|
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;
|
|
254
|
+
/** External writers touch their root span so staleness is heartbeat-age based. */
|
|
255
|
+
heartbeat(spanId: string, at?: number): void;
|
|
256
|
+
/** Close every open span of a run as `outcome` (children first), reason on all. */
|
|
257
|
+
cascadeClose(runId: string, outcome: SpanOutcome, reason: string): number;
|
|
258
|
+
/** Boot sweep: close THIS writer's leftover open spans as interrupted. */
|
|
259
|
+
sweepOwnOrphans(): number;
|
|
260
|
+
/** Close open spans whose root heartbeat (or own writer liveness) went stale. */
|
|
261
|
+
sweepStale(staleAfterMs: number, now?: number): number;
|
|
262
|
+
/** Open root spans running longer than the threshold — the watchdog signal. */
|
|
263
|
+
findStuck(thresholdMs: number, now?: number): SpanRow[];
|
|
264
|
+
changesSince(changeCursor: number, limit?: number): ActivityChange[];
|
|
265
|
+
/**
|
|
266
|
+
* Changes past the cursor whose span is a TERMINAL ROOT — the notifier's
|
|
267
|
+
* detection query, filtered and joined SQL-side. One row per span (the
|
|
268
|
+
* highest change_id it appears under).
|
|
269
|
+
*/
|
|
270
|
+
terminalRootChangesSince(
|
|
271
|
+
changeCursor: number,
|
|
272
|
+
limit?: number
|
|
273
|
+
): Array<{ changeId: number; span: SpanRow }>;
|
|
274
|
+
latestChangeCursor(): number;
|
|
275
|
+
snapshotRun(runId: string): RunSnapshot | null;
|
|
276
|
+
/** The run's high-water seq — what a full snapshot carries, without the reads. */
|
|
277
|
+
runHighWaterSeq(runId: string): number;
|
|
278
|
+
getSpan(spanId: string): SpanRow | null;
|
|
279
|
+
openRootSpans(): SpanRow[];
|
|
280
|
+
/** Upsert the run's rollup row from current span state (call on terminal). */
|
|
281
|
+
rollupRun(runId: string): void;
|
|
282
|
+
prune(options: PruneOptions): { runsPruned: number; spansDeleted: number };
|
|
283
|
+
}
|
|
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
|
+
|
|
293
|
+
export interface CreateActivityStoreOptions {
|
|
294
|
+
/** Process identity stamped on every span this store writes. */
|
|
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;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function createActivityStore(
|
|
309
|
+
db: Database,
|
|
310
|
+
options: CreateActivityStoreOptions = {}
|
|
311
|
+
): ActivityStore {
|
|
312
|
+
const writer = options.writer ?? `pid:${process.pid}:${Date.now()}`;
|
|
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
|
+
|
|
324
|
+
// bun:sqlite transactions: `.immediate` takes the write lock up front, so a
|
|
325
|
+
// concurrent writer waits (busy_timeout) instead of failing mid-upgrade.
|
|
326
|
+
const inWrite = <T>(fn: () => T): T => db.transaction(fn).immediate();
|
|
327
|
+
|
|
328
|
+
function nextSeq(runId: string): number {
|
|
329
|
+
const row = db
|
|
330
|
+
.query("SELECT COALESCE(MAX(seq), 0) AS hi FROM activity_changes WHERE run_id = ?")
|
|
331
|
+
.get(runId) as { hi: number };
|
|
332
|
+
return row.hi + 1;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
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);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function rowToSpan(r: any): SpanRow {
|
|
342
|
+
return {
|
|
343
|
+
spanId: r.span_id,
|
|
344
|
+
runId: r.run_id,
|
|
345
|
+
parentSpanId: r.parent_span_id,
|
|
346
|
+
name: r.name,
|
|
347
|
+
kind: r.kind,
|
|
348
|
+
origin: r.origin,
|
|
349
|
+
sessionId: r.session_id,
|
|
350
|
+
jobName: r.job_name,
|
|
351
|
+
attrs: safeParse(r.attrs) ?? {},
|
|
352
|
+
startedAt: r.started_at,
|
|
353
|
+
waitUntil: r.wait_until,
|
|
354
|
+
endedAt: r.ended_at,
|
|
355
|
+
outcome: r.outcome,
|
|
356
|
+
outcomeReason: r.outcome_reason,
|
|
357
|
+
usage: {
|
|
358
|
+
inputTokens: r.input_tokens ?? undefined,
|
|
359
|
+
outputTokens: r.output_tokens ?? undefined,
|
|
360
|
+
cacheReadTokens: r.cache_read_tokens ?? undefined,
|
|
361
|
+
cacheCreationTokens: r.cache_creation_tokens ?? undefined,
|
|
362
|
+
costUsd: r.cost_usd ?? undefined,
|
|
363
|
+
model: r.model ?? undefined,
|
|
364
|
+
},
|
|
365
|
+
writer: r.writer,
|
|
366
|
+
lastHeartbeatAt: r.last_heartbeat_at,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function rowToEvent(r: any): SpanEventRow {
|
|
371
|
+
const parsed = safeParse(r.payload) as { v?: unknown; truncated?: boolean } | undefined;
|
|
372
|
+
return {
|
|
373
|
+
spanId: r.span_id,
|
|
374
|
+
eventIndex: r.event_index,
|
|
375
|
+
ts: r.ts,
|
|
376
|
+
eventType: r.event_type,
|
|
377
|
+
payload: parsed?.v,
|
|
378
|
+
truncated: parsed?.truncated === true,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function getSpanRaw(spanId: string): SpanRow | null {
|
|
383
|
+
const r = db.query("SELECT * FROM activity_spans WHERE span_id = ?").get(spanId);
|
|
384
|
+
return r ? rowToSpan(r) : null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function runHighWaterSeqRaw(runId: string): number {
|
|
388
|
+
const row = db
|
|
389
|
+
.query("SELECT COALESCE(MAX(seq), 0) AS hi FROM activity_changes WHERE run_id = ?")
|
|
390
|
+
.get(runId) as { hi: number };
|
|
391
|
+
return row.hi;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function endSpanInTx(spanId: string, input: EndSpanInput): boolean {
|
|
395
|
+
const existing = db
|
|
396
|
+
.query("SELECT run_id, outcome, attrs FROM activity_spans WHERE span_id = ?")
|
|
397
|
+
.get(spanId) as { run_id: string; outcome: string | null; attrs: string | null } | null;
|
|
398
|
+
if (!existing || existing.outcome !== null) return false;
|
|
399
|
+
|
|
400
|
+
const endedAt = input.endedAt ?? Date.now();
|
|
401
|
+
const attrs = input.attrs
|
|
402
|
+
? JSON.stringify({ ...(safeParse(existing.attrs) ?? {}), ...input.attrs })
|
|
403
|
+
: existing.attrs;
|
|
404
|
+
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(
|
|
415
|
+
input.outcome,
|
|
416
|
+
input.reason ?? null,
|
|
417
|
+
endedAt,
|
|
418
|
+
attrs,
|
|
419
|
+
u.inputTokens ?? null,
|
|
420
|
+
u.outputTokens ?? null,
|
|
421
|
+
u.cacheReadTokens ?? null,
|
|
422
|
+
u.cacheCreationTokens ?? null,
|
|
423
|
+
u.costUsd ?? null,
|
|
424
|
+
u.model ?? null,
|
|
425
|
+
spanId
|
|
426
|
+
);
|
|
427
|
+
logChange(existing.run_id, nextSeq(existing.run_id), spanId);
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Close every open span of the run (children first) and roll it up. */
|
|
432
|
+
function closeRunInTx(runId: string, outcome: SpanOutcome, reason: string): number {
|
|
433
|
+
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
|
+
)
|
|
437
|
+
.all(runId) as Array<{ span_id: string }>;
|
|
438
|
+
let closed = 0;
|
|
439
|
+
for (const { span_id } of open) {
|
|
440
|
+
if (endSpanInTx(span_id, { outcome, reason })) closed++;
|
|
441
|
+
}
|
|
442
|
+
if (closed > 0) rollupRunInTx(runId);
|
|
443
|
+
return closed;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function rollupRunInTx(runId: string) {
|
|
447
|
+
const spans = db
|
|
448
|
+
.query("SELECT * FROM activity_spans WHERE run_id = ? ORDER BY started_at")
|
|
449
|
+
.all(runId)
|
|
450
|
+
.map(rowToSpan);
|
|
451
|
+
if (spans.length === 0) return;
|
|
452
|
+
const root = spans.find((s) => s.parentSpanId === null) ?? spans[0]!;
|
|
453
|
+
// Aggregation scope: ROOT spans only. Result-level accounting (the
|
|
454
|
+
// SDK's modelUsage) already includes subagent consumption — summing the
|
|
455
|
+
// tree would double-count every fan-out. Verified empirically (plan U3
|
|
456
|
+
// smoke test).
|
|
457
|
+
const failure =
|
|
458
|
+
spans.find(
|
|
459
|
+
(s) => (s.outcome === "error" || s.outcome === "timeout") && s.outcomeReason != null
|
|
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
|
+
|
|
508
|
+
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`
|
|
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).
|
|
550
|
+
).run(
|
|
551
|
+
runId,
|
|
552
|
+
root.origin,
|
|
553
|
+
root.name,
|
|
554
|
+
root.sessionId,
|
|
555
|
+
root.jobName,
|
|
556
|
+
root.startedAt,
|
|
557
|
+
root.endedAt,
|
|
558
|
+
root.outcome,
|
|
559
|
+
root.endedAt !== null ? root.endedAt - root.startedAt : null,
|
|
560
|
+
spans.length,
|
|
561
|
+
root.usage.inputTokens ?? null,
|
|
562
|
+
root.usage.outputTokens ?? null,
|
|
563
|
+
root.usage.cacheReadTokens ?? null,
|
|
564
|
+
root.usage.cacheCreationTokens ?? null,
|
|
565
|
+
costUsd,
|
|
566
|
+
effectiveCostUsd,
|
|
567
|
+
billingMode,
|
|
568
|
+
pricingEstimate,
|
|
569
|
+
failure
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return {
|
|
574
|
+
writer,
|
|
575
|
+
|
|
576
|
+
startSpan(input) {
|
|
577
|
+
return inWrite(() => {
|
|
578
|
+
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(
|
|
585
|
+
input.spanId,
|
|
586
|
+
input.runId,
|
|
587
|
+
input.parentSpanId ?? null,
|
|
588
|
+
input.name,
|
|
589
|
+
input.kind,
|
|
590
|
+
input.origin,
|
|
591
|
+
input.sessionId ?? null,
|
|
592
|
+
input.jobName ?? null,
|
|
593
|
+
input.attrs ? JSON.stringify(input.attrs) : null,
|
|
594
|
+
startedAt,
|
|
595
|
+
writer,
|
|
596
|
+
input.parentSpanId ? null : startedAt
|
|
597
|
+
);
|
|
598
|
+
logChange(input.runId, nextSeq(input.runId), input.spanId);
|
|
599
|
+
return getSpanRaw(input.spanId)!;
|
|
600
|
+
});
|
|
601
|
+
},
|
|
602
|
+
|
|
603
|
+
endSpan(spanId, input) {
|
|
604
|
+
return inWrite(() => endSpanInTx(spanId, input));
|
|
605
|
+
},
|
|
606
|
+
|
|
607
|
+
patchSpan(spanId, patch) {
|
|
608
|
+
return inWrite(() => {
|
|
609
|
+
const existing = db
|
|
610
|
+
.query("SELECT run_id, outcome, attrs FROM activity_spans WHERE span_id = ?")
|
|
611
|
+
.get(spanId) as { run_id: string; outcome: string | null; attrs: string | null } | null;
|
|
612
|
+
if (!existing || existing.outcome !== null) return false;
|
|
613
|
+
const attrs = patch.attrs
|
|
614
|
+
? JSON.stringify({ ...(safeParse(existing.attrs) ?? {}), ...patch.attrs })
|
|
615
|
+
: existing.attrs;
|
|
616
|
+
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(
|
|
627
|
+
attrs,
|
|
628
|
+
patch.waitUntil ?? null,
|
|
629
|
+
u.inputTokens ?? null,
|
|
630
|
+
u.outputTokens ?? null,
|
|
631
|
+
u.cacheReadTokens ?? null,
|
|
632
|
+
u.cacheCreationTokens ?? null,
|
|
633
|
+
u.costUsd ?? null,
|
|
634
|
+
u.model ?? null,
|
|
635
|
+
spanId
|
|
636
|
+
);
|
|
637
|
+
logChange(existing.run_id, nextSeq(existing.run_id), spanId);
|
|
638
|
+
return true;
|
|
639
|
+
});
|
|
640
|
+
},
|
|
641
|
+
|
|
642
|
+
appendEvent(spanId, eventType, payload, ts, cap) {
|
|
643
|
+
return inWrite(() => {
|
|
644
|
+
const span = db
|
|
645
|
+
.query("SELECT run_id FROM activity_spans WHERE span_id = ?")
|
|
646
|
+
.get(spanId) as { run_id: string } | null;
|
|
647
|
+
if (!span) return null;
|
|
648
|
+
const next = db
|
|
649
|
+
.query(
|
|
650
|
+
"SELECT COALESCE(MAX(event_index), -1) + 1 AS idx FROM activity_events WHERE span_id = ?"
|
|
651
|
+
)
|
|
652
|
+
.get(spanId) as { idx: number };
|
|
653
|
+
const stored = capPayload(payload, cap);
|
|
654
|
+
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));
|
|
658
|
+
logChange(span.run_id, nextSeq(span.run_id), spanId, next.idx);
|
|
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
|
+
};
|
|
668
|
+
});
|
|
669
|
+
},
|
|
670
|
+
|
|
671
|
+
heartbeat(spanId, at) {
|
|
672
|
+
inWrite(() => {
|
|
673
|
+
// Deliberately NOT change-logged: a heartbeat is liveness metadata,
|
|
674
|
+
// 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);
|
|
678
|
+
});
|
|
679
|
+
},
|
|
680
|
+
|
|
681
|
+
cascadeClose(runId, outcome, reason) {
|
|
682
|
+
getPricing(); // construct outside the write lock; rollup reuses it
|
|
683
|
+
return inWrite(() => closeRunInTx(runId, outcome, reason));
|
|
684
|
+
},
|
|
685
|
+
|
|
686
|
+
sweepOwnOrphans() {
|
|
687
|
+
// "Own" means this PROCESS IDENTITY's rows from a previous life. The
|
|
688
|
+
// writer stamp includes the start time, so rows written by the
|
|
689
|
+
// current instance never match a fresh store's sweep... but a boot
|
|
690
|
+
// sweep runs before any spans are written, so sweeping by pid-prefix
|
|
691
|
+
// alone would be wrong across pid reuse. Sweep every session-origin
|
|
692
|
+
// open span instead: only THIS server writes session spans, and at
|
|
693
|
+
// boot none of ours can legitimately be open.
|
|
694
|
+
//
|
|
695
|
+
// Candidates are read OUTSIDE the write transaction — the common case
|
|
696
|
+
// finds nothing and must not take the exclusive lock for it. The close
|
|
697
|
+
// loop re-checks open-ness inside the transaction (endSpanInTx is
|
|
698
|
+
// write-once), so a race just skips.
|
|
699
|
+
const open = db
|
|
700
|
+
.query(
|
|
701
|
+
"SELECT DISTINCT run_id FROM activity_spans WHERE outcome IS NULL AND origin = 'session'"
|
|
702
|
+
)
|
|
703
|
+
.all() as Array<{ run_id: string }>;
|
|
704
|
+
if (open.length === 0) return 0;
|
|
705
|
+
getPricing(); // construct outside the write lock; rollup reuses it
|
|
706
|
+
return inWrite(() => {
|
|
707
|
+
let closed = 0;
|
|
708
|
+
for (const { run_id } of open) {
|
|
709
|
+
closed += closeRunInTx(run_id, "interrupted", "server restarted");
|
|
710
|
+
}
|
|
711
|
+
return closed;
|
|
712
|
+
});
|
|
713
|
+
},
|
|
714
|
+
|
|
715
|
+
sweepStale(staleAfterMs, now) {
|
|
716
|
+
const cutoff = (now ?? Date.now()) - staleAfterMs;
|
|
717
|
+
// Staleness is judged on ROOT heartbeat age — never span age — so a
|
|
718
|
+
// legitimately long, quiet run with a live writer is never killed.
|
|
719
|
+
// Candidate read outside the write transaction, same as sweepOwnOrphans.
|
|
720
|
+
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
|
+
)
|
|
726
|
+
.all(writer, cutoff) as Array<{ span_id: string; run_id: string }>;
|
|
727
|
+
if (staleRoots.length === 0) return 0;
|
|
728
|
+
getPricing(); // construct outside the write lock; rollup reuses it
|
|
729
|
+
return inWrite(() => {
|
|
730
|
+
let closed = 0;
|
|
731
|
+
for (const { run_id } of staleRoots) {
|
|
732
|
+
closed += closeRunInTx(run_id, "interrupted", "writer went silent");
|
|
733
|
+
}
|
|
734
|
+
return closed;
|
|
735
|
+
});
|
|
736
|
+
},
|
|
737
|
+
|
|
738
|
+
findStuck(thresholdMs, now) {
|
|
739
|
+
const cutoff = (now ?? Date.now()) - thresholdMs;
|
|
740
|
+
return (
|
|
741
|
+
db
|
|
742
|
+
.query(
|
|
743
|
+
"SELECT * FROM activity_spans WHERE outcome IS NULL AND parent_span_id IS NULL AND started_at < ?"
|
|
744
|
+
)
|
|
745
|
+
.all(cutoff) as any[]
|
|
746
|
+
).map(rowToSpan);
|
|
747
|
+
},
|
|
748
|
+
|
|
749
|
+
changesSince(changeCursor, limit = 500) {
|
|
750
|
+
// Set-based: one join per change kind instead of a lookup per row. A
|
|
751
|
+
// change whose span/event row was pruned drops out via the join, same
|
|
752
|
+
// as the old per-row miss.
|
|
753
|
+
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
|
+
)
|
|
761
|
+
.all(changeCursor, limit) as any[];
|
|
762
|
+
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
|
+
)
|
|
771
|
+
.all(changeCursor, limit) as any[];
|
|
772
|
+
const changes: ActivityChange[] = [
|
|
773
|
+
...spanRows.map(
|
|
774
|
+
(r): ActivityChange => ({
|
|
775
|
+
changeId: r.change_id,
|
|
776
|
+
runId: r.run_id,
|
|
777
|
+
seq: r.seq,
|
|
778
|
+
kind: "span",
|
|
779
|
+
span: rowToSpan(r),
|
|
780
|
+
})
|
|
781
|
+
),
|
|
782
|
+
...eventRows.map(
|
|
783
|
+
(r): ActivityChange => ({
|
|
784
|
+
changeId: r.change_id,
|
|
785
|
+
runId: r.run_id,
|
|
786
|
+
seq: r.seq,
|
|
787
|
+
kind: "event",
|
|
788
|
+
event: rowToEvent(r),
|
|
789
|
+
})
|
|
790
|
+
),
|
|
791
|
+
];
|
|
792
|
+
changes.sort((a, b) => a.changeId - b.changeId);
|
|
793
|
+
return changes.length > limit ? changes.slice(0, limit) : changes;
|
|
794
|
+
},
|
|
795
|
+
|
|
796
|
+
terminalRootChangesSince(changeCursor, limit = 500) {
|
|
797
|
+
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
|
+
)
|
|
807
|
+
.all(changeCursor, limit) as any[];
|
|
808
|
+
return rows.map((r) => ({ changeId: r.change_id, span: rowToSpan(r) }));
|
|
809
|
+
},
|
|
810
|
+
|
|
811
|
+
latestChangeCursor() {
|
|
812
|
+
const row = db
|
|
813
|
+
.query("SELECT COALESCE(MAX(change_id), 0) AS hi FROM activity_changes")
|
|
814
|
+
.get() as { hi: number };
|
|
815
|
+
return row.hi;
|
|
816
|
+
},
|
|
817
|
+
|
|
818
|
+
snapshotRun(runId) {
|
|
819
|
+
// A read transaction so spans, events, high-water seq, and the global
|
|
820
|
+
// cursor are one consistent picture — the poller baseline is pinned to
|
|
821
|
+
// exactly this moment (no gap between snapshot and first delta).
|
|
822
|
+
return db.transaction(() => {
|
|
823
|
+
const spans = (
|
|
824
|
+
db
|
|
825
|
+
.query("SELECT * FROM activity_spans WHERE run_id = ? ORDER BY started_at")
|
|
826
|
+
.all(runId) as any[]
|
|
827
|
+
).map(rowToSpan);
|
|
828
|
+
if (spans.length === 0) return null;
|
|
829
|
+
const events = (
|
|
830
|
+
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
|
+
)
|
|
836
|
+
.all(runId) as any[]
|
|
837
|
+
).map(rowToEvent);
|
|
838
|
+
const cursor = db
|
|
839
|
+
.query("SELECT COALESCE(MAX(change_id), 0) AS hi FROM activity_changes")
|
|
840
|
+
.get() as { hi: number };
|
|
841
|
+
return {
|
|
842
|
+
runId,
|
|
843
|
+
spans,
|
|
844
|
+
events,
|
|
845
|
+
highWaterSeq: runHighWaterSeqRaw(runId),
|
|
846
|
+
changeCursor: cursor.hi,
|
|
847
|
+
};
|
|
848
|
+
})();
|
|
849
|
+
},
|
|
850
|
+
|
|
851
|
+
runHighWaterSeq: runHighWaterSeqRaw,
|
|
852
|
+
|
|
853
|
+
getSpan: getSpanRaw,
|
|
854
|
+
|
|
855
|
+
openRootSpans() {
|
|
856
|
+
return (
|
|
857
|
+
db
|
|
858
|
+
.query(
|
|
859
|
+
"SELECT * FROM activity_spans WHERE outcome IS NULL AND parent_span_id IS NULL ORDER BY started_at DESC"
|
|
860
|
+
)
|
|
861
|
+
.all() as any[]
|
|
862
|
+
).map(rowToSpan);
|
|
863
|
+
},
|
|
864
|
+
|
|
865
|
+
rollupRun(runId) {
|
|
866
|
+
getPricing(); // construct outside the write lock; rollup reuses it
|
|
867
|
+
inWrite(() => rollupRunInTx(runId));
|
|
868
|
+
},
|
|
869
|
+
|
|
870
|
+
prune(options) {
|
|
871
|
+
const now = options.now ?? Date.now();
|
|
872
|
+
const batch = options.batch ?? 50;
|
|
873
|
+
// The digest floor gates pruning, but a dead digest job must not freeze
|
|
874
|
+
// it forever: the hard ceiling prunes regardless (marking the rollup so
|
|
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.
|
|
882
|
+
const floor = Math.max(options.digestFloorAt, 0);
|
|
883
|
+
const retentionCutoff = now - options.detailRetentionMs;
|
|
884
|
+
const ceiling = now - options.hardCeilingMs;
|
|
885
|
+
return inWrite(() => {
|
|
886
|
+
// Candidates come from the rollups (they exist for every finished
|
|
887
|
+
// run — that terminal write is what made the run prunable), guarded
|
|
888
|
+
// against any run that still has an open span.
|
|
889
|
+
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
|
+
)
|
|
900
|
+
.all(floor, retentionCutoff, ceiling, batch) as Array<{ run_id: string; ended: number }>;
|
|
901
|
+
let spansDeleted = 0;
|
|
902
|
+
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);
|
|
906
|
+
if (ended >= floor) {
|
|
907
|
+
// Pruned by the hard ceiling alone — the digest never covered
|
|
908
|
+
// 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);
|
|
912
|
+
}
|
|
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);
|
|
918
|
+
spansDeleted += res.changes;
|
|
919
|
+
db.query("DELETE FROM activity_changes WHERE run_id = ?").run(run_id);
|
|
920
|
+
}
|
|
921
|
+
// The change log only serves live polling; rows this deep in the past
|
|
922
|
+
// 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();
|
|
930
|
+
return { runsPruned: candidates.length, spansDeleted };
|
|
931
|
+
});
|
|
932
|
+
},
|
|
933
|
+
};
|
|
934
|
+
}
|
|
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
|
+
|
|
1050
|
+
function safeParse(text: string | null): Record<string, unknown> | undefined {
|
|
1051
|
+
if (!text) return undefined;
|
|
1052
|
+
try {
|
|
1053
|
+
return JSON.parse(text);
|
|
1054
|
+
} catch {
|
|
1055
|
+
return undefined;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
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);
|
|
1063
|
+
const json = JSON.stringify(payload ?? null);
|
|
1064
|
+
if (json.length <= limit) return { v: payload ?? null };
|
|
1065
|
+
if (typeof payload === "string") {
|
|
1066
|
+
return { v: payload.slice(0, limit), truncated: true };
|
|
1067
|
+
}
|
|
1068
|
+
return { v: json.slice(0, limit), truncated: true };
|
|
1069
|
+
}
|