@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,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live activity streaming: view-scoped subscriptions, snapshot-then-delta.
|
|
3
|
+
*
|
|
4
|
+
* All emission is change-log driven — one code path for the server's own
|
|
5
|
+
* writes and a foreign writer's (the cron wrapper). A write on this process
|
|
6
|
+
* calls `pump()` directly (near-zero latency); foreign writes are picked up
|
|
7
|
+
* by a fast poll interval that runs ONLY while at least one subscription is
|
|
8
|
+
* live. The pump reads `changesSince(cursor)` and fans each committed change
|
|
9
|
+
* out as an `activity_delta` to matching subscribers, so ordering is the
|
|
10
|
+
* change log's and a delta can never precede its commit (persist-then-emit).
|
|
11
|
+
*
|
|
12
|
+
* The subscribe handler answers with a snapshot whose per-run high-water seq
|
|
13
|
+
* and pump baseline come from ONE store read transaction — there is no gap
|
|
14
|
+
* window between snapshot and first delta, and no duplication the client's
|
|
15
|
+
* seq-discard rule cannot handle.
|
|
16
|
+
*
|
|
17
|
+
* This is deliberately the only place broadcast is per-connection filtered;
|
|
18
|
+
* every other frame keeps the host's broadcast-to-all semantics.
|
|
19
|
+
*/
|
|
20
|
+
import {
|
|
21
|
+
SPAN_TOOL_NAME_PREFIX,
|
|
22
|
+
type ActivityRunRollup,
|
|
23
|
+
type ActivitySpan,
|
|
24
|
+
type ActivitySpanEvent,
|
|
25
|
+
type ClientActivitySubscribe,
|
|
26
|
+
type ClientActivityUnsubscribe,
|
|
27
|
+
type ServerActivityDelta,
|
|
28
|
+
type ServerActivitySnapshot,
|
|
29
|
+
} from "@schlessera/brain-ui-sdk/protocol";
|
|
30
|
+
import type { Logger } from "@opentelemetry/api-logs";
|
|
31
|
+
|
|
32
|
+
import type { WSContext } from "../ws/clients.js";
|
|
33
|
+
import { sendTo } from "../ws/clients.js";
|
|
34
|
+
import type {
|
|
35
|
+
ActivityChange,
|
|
36
|
+
ActivityStore,
|
|
37
|
+
RunRollupRow,
|
|
38
|
+
SpanEventRow,
|
|
39
|
+
SpanRow,
|
|
40
|
+
} from "./store.js";
|
|
41
|
+
|
|
42
|
+
/** Events per snapshot frame — keeps each frame far below the WS size cap. */
|
|
43
|
+
const SNAPSHOT_EVENT_CHUNK = 100;
|
|
44
|
+
/** Spans per snapshot frame, same rationale. */
|
|
45
|
+
const SNAPSHOT_SPAN_CHUNK = 100;
|
|
46
|
+
/** Fast-poll cadence while subscriptions exist (foreign-writer liveness). */
|
|
47
|
+
const POLL_INTERVAL_MS = 1500;
|
|
48
|
+
|
|
49
|
+
const TOOL_NAME_PREFIX = SPAN_TOOL_NAME_PREFIX;
|
|
50
|
+
|
|
51
|
+
interface Subscription {
|
|
52
|
+
view: "index" | "session" | "run";
|
|
53
|
+
sessionId?: string;
|
|
54
|
+
runId?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Store row → wire span. Lifts the typed fields the protocol promotes
|
|
58
|
+
* (toolName, subagent enrichment) out of the naming/attr conventions. */
|
|
59
|
+
export function toWireSpan(span: SpanRow): ActivitySpan {
|
|
60
|
+
const isToolLike = span.kind === "tool" || span.kind === "subagent";
|
|
61
|
+
const toolName =
|
|
62
|
+
span.kind === "subagent" && span.name.startsWith(`${TOOL_NAME_PREFIX}Agent`)
|
|
63
|
+
? "Agent"
|
|
64
|
+
: isToolLike && span.name.startsWith(TOOL_NAME_PREFIX)
|
|
65
|
+
? span.name.slice(TOOL_NAME_PREFIX.length)
|
|
66
|
+
: isToolLike
|
|
67
|
+
? span.name
|
|
68
|
+
: undefined;
|
|
69
|
+
const subagent = span.kind === "subagent" ? subagentOf(span.attrs) : undefined;
|
|
70
|
+
return {
|
|
71
|
+
spanId: span.spanId,
|
|
72
|
+
runId: span.runId,
|
|
73
|
+
parentSpanId: span.parentSpanId ?? undefined,
|
|
74
|
+
name: span.name,
|
|
75
|
+
...(toolName !== undefined ? { toolName } : {}),
|
|
76
|
+
kind: span.kind,
|
|
77
|
+
origin: span.origin,
|
|
78
|
+
sessionId: span.sessionId ?? undefined,
|
|
79
|
+
jobName: span.jobName ?? undefined,
|
|
80
|
+
startedAt: span.startedAt,
|
|
81
|
+
waitUntil: span.waitUntil ?? undefined,
|
|
82
|
+
endedAt: span.endedAt ?? undefined,
|
|
83
|
+
outcome: span.outcome ?? undefined,
|
|
84
|
+
outcomeReason: span.outcomeReason ?? undefined,
|
|
85
|
+
usage: hasUsage(span) ? { ...span.usage } : undefined,
|
|
86
|
+
...(subagent ? { subagent } : {}),
|
|
87
|
+
attrs: Object.keys(span.attrs).length > 0 ? span.attrs : undefined,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function subagentOf(attrs: Record<string, unknown>): ActivitySpan["subagent"] {
|
|
92
|
+
const type = attrs["subagent.type"];
|
|
93
|
+
const description = attrs["subagent.description"];
|
|
94
|
+
const summary = attrs["subagent.summary"];
|
|
95
|
+
const totalTokens = attrs["subagent.total_tokens"];
|
|
96
|
+
const subagent = {
|
|
97
|
+
...(typeof type === "string" ? { type } : {}),
|
|
98
|
+
...(typeof description === "string" ? { description } : {}),
|
|
99
|
+
...(typeof summary === "string" ? { summary } : {}),
|
|
100
|
+
...(typeof totalTokens === "number" ? { totalTokens } : {}),
|
|
101
|
+
};
|
|
102
|
+
return Object.keys(subagent).length > 0 ? subagent : undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function toWireEvent(event: SpanEventRow): ActivitySpanEvent {
|
|
106
|
+
return {
|
|
107
|
+
spanId: event.spanId,
|
|
108
|
+
eventIndex: event.eventIndex,
|
|
109
|
+
ts: event.ts,
|
|
110
|
+
eventType: event.eventType,
|
|
111
|
+
payload: event.payload,
|
|
112
|
+
...(event.truncated ? { truncated: true } : {}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Store rollup → wire rollup: nullable columns become omitted-when-null per
|
|
117
|
+
* the optional wire contract (effectiveCostUsd stays explicit — null means
|
|
118
|
+
* unknown, and omitting it would let a client mistake unknown for absent). */
|
|
119
|
+
export function toWireRollup(r: RunRollupRow): ActivityRunRollup {
|
|
120
|
+
return {
|
|
121
|
+
origin: r.origin,
|
|
122
|
+
name: r.name,
|
|
123
|
+
sessionId: r.sessionId,
|
|
124
|
+
jobName: r.jobName,
|
|
125
|
+
startedAt: r.startedAt,
|
|
126
|
+
endedAt: r.endedAt,
|
|
127
|
+
outcome: r.outcome,
|
|
128
|
+
durationMs: r.durationMs,
|
|
129
|
+
spanCount: r.spanCount,
|
|
130
|
+
costUsd: r.costUsd,
|
|
131
|
+
effectiveCostUsd: r.effectiveCostUsd,
|
|
132
|
+
billingMode: r.billingMode ?? undefined,
|
|
133
|
+
pricingEstimate: r.pricingEstimate ?? undefined,
|
|
134
|
+
failureReason: r.failureReason,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface ActivityStream {
|
|
139
|
+
handleSubscribe(ws: WSContext, msg: ClientActivitySubscribe): void;
|
|
140
|
+
handleUnsubscribe(ws: WSContext, msg: ClientActivityUnsubscribe): void;
|
|
141
|
+
/** Forget a closed socket. */
|
|
142
|
+
dropConnection(ws: WSContext): void;
|
|
143
|
+
/** Drain new committed changes to subscribers. Reentrancy-safe. */
|
|
144
|
+
pump(): void;
|
|
145
|
+
/** Whether any connection currently watches the given session's activity. */
|
|
146
|
+
isWatched(scope: { sessionId?: string; runId?: string }): boolean;
|
|
147
|
+
/** Number of live subscriptions (all connections). */
|
|
148
|
+
subscriptionCount(): number;
|
|
149
|
+
close(): void;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function createActivityStream(store: ActivityStore, log?: Logger): ActivityStream {
|
|
153
|
+
const subscriptions = new Map<WSContext, Subscription[]>();
|
|
154
|
+
let cursor = store.latestChangeCursor();
|
|
155
|
+
let pumping = false;
|
|
156
|
+
let pollHandle: ReturnType<typeof setInterval> | null = null;
|
|
157
|
+
/** runId → sessionId, learned from any span seen; a run's session never
|
|
158
|
+
* changes, so entries are immutable. Replaces per-event store lookups
|
|
159
|
+
* when matching event deltas against session-view subscriptions. */
|
|
160
|
+
const runSessions = new Map<string, string>();
|
|
161
|
+
|
|
162
|
+
function noteSpan(span: SpanRow): void {
|
|
163
|
+
if (span.sessionId && !runSessions.has(span.runId)) {
|
|
164
|
+
runSessions.set(span.runId, span.sessionId);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function subKey(s: Subscription): string {
|
|
169
|
+
return `${s.view}:${s.sessionId ?? ""}:${s.runId ?? ""}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function matches(sub: Subscription, runId: string, span?: SpanRow): boolean {
|
|
173
|
+
if (sub.view === "index") return true;
|
|
174
|
+
if (sub.view === "run") return sub.runId === runId;
|
|
175
|
+
// Session view: match by the span's session when we have it; event-only
|
|
176
|
+
// deltas resolve through the memoized run→session map.
|
|
177
|
+
if (span?.sessionId) return span.sessionId === sub.sessionId;
|
|
178
|
+
return runSessions.get(runId) === sub.sessionId;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function ensurePolling() {
|
|
182
|
+
if (pollHandle || subscriptions.size === 0) return;
|
|
183
|
+
pollHandle = setInterval(() => pump(), POLL_INTERVAL_MS);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function stopPollingIfIdle() {
|
|
187
|
+
if (pollHandle && subscriptions.size === 0) {
|
|
188
|
+
clearInterval(pollHandle);
|
|
189
|
+
pollHandle = null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function deltaFrame(change: ActivityChange): ServerActivityDelta {
|
|
194
|
+
return change.kind === "span"
|
|
195
|
+
? { type: "activity_delta", runId: change.runId, seq: change.seq, span: toWireSpan(change.span) }
|
|
196
|
+
: {
|
|
197
|
+
type: "activity_delta",
|
|
198
|
+
runId: change.runId,
|
|
199
|
+
seq: change.seq,
|
|
200
|
+
event: toWireEvent(change.event),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function pump(): void {
|
|
205
|
+
if (pumping) return;
|
|
206
|
+
pumping = true;
|
|
207
|
+
try {
|
|
208
|
+
// Nobody listening: skip the changes entirely, but keep the cursor at
|
|
209
|
+
// the log's head — otherwise the first subscriber's pump would replay
|
|
210
|
+
// history its snapshot already carries (harmless under the client's
|
|
211
|
+
// seq-discard rule, but pure waste). Side effects that must see every
|
|
212
|
+
// change (the notifier) keep their OWN cursor.
|
|
213
|
+
if (subscriptions.size === 0) {
|
|
214
|
+
cursor = store.latestChangeCursor();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
for (;;) {
|
|
218
|
+
const changes = store.changesSince(cursor, 200);
|
|
219
|
+
if (changes.length === 0) break;
|
|
220
|
+
for (const change of changes) {
|
|
221
|
+
cursor = change.changeId;
|
|
222
|
+
const span = change.kind === "span" ? change.span : undefined;
|
|
223
|
+
if (span) noteSpan(span);
|
|
224
|
+
const frame = deltaFrame(change);
|
|
225
|
+
for (const [ws, subs] of subscriptions) {
|
|
226
|
+
if (subs.some((s) => matches(s, change.runId, span))) {
|
|
227
|
+
sendTo(ws, frame);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (changes.length < 200) break;
|
|
232
|
+
}
|
|
233
|
+
} catch (err) {
|
|
234
|
+
log?.emit({
|
|
235
|
+
severityText: "WARN",
|
|
236
|
+
body: "activity pump failed",
|
|
237
|
+
attributes: { error: err instanceof Error ? err.message : String(err) },
|
|
238
|
+
});
|
|
239
|
+
} finally {
|
|
240
|
+
pumping = false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function sendSnapshot(ws: WSContext, sub: Subscription): void {
|
|
245
|
+
let spans: SpanRow[] = [];
|
|
246
|
+
let events: SpanEventRow[] = [];
|
|
247
|
+
const highWater: Record<string, number> = {};
|
|
248
|
+
|
|
249
|
+
if (sub.view === "run" && sub.runId) {
|
|
250
|
+
const snap = store.snapshotRun(sub.runId);
|
|
251
|
+
if (snap) {
|
|
252
|
+
spans = snap.spans;
|
|
253
|
+
events = snap.events;
|
|
254
|
+
highWater[snap.runId] = snap.highWaterSeq;
|
|
255
|
+
// No pump-cursor rewind: the pump cursor can only LAG the snapshot
|
|
256
|
+
// read (it never runs ahead of the DB), so unpumped changes are
|
|
257
|
+
// already inside this snapshot's high-water and the client's
|
|
258
|
+
// seq-discard rule absorbs them when the pump catches up.
|
|
259
|
+
}
|
|
260
|
+
} else if (sub.view === "session" && sub.sessionId) {
|
|
261
|
+
for (const root of store.openRootSpans()) {
|
|
262
|
+
if (root.sessionId !== sub.sessionId) continue;
|
|
263
|
+
const snap = store.snapshotRun(root.runId);
|
|
264
|
+
if (!snap) continue;
|
|
265
|
+
spans = spans.concat(snap.spans);
|
|
266
|
+
events = events.concat(snap.events);
|
|
267
|
+
highWater[snap.runId] = snap.highWaterSeq;
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
// Index view: open roots only — history is the REST activity API's job.
|
|
271
|
+
for (const root of store.openRootSpans()) {
|
|
272
|
+
spans.push(root);
|
|
273
|
+
highWater[root.runId] = store.runHighWaterSeq(root.runId);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
for (const span of spans) noteSpan(span);
|
|
277
|
+
|
|
278
|
+
const base: Omit<ServerActivitySnapshot, "spans" | "events"> = {
|
|
279
|
+
type: "activity_snapshot",
|
|
280
|
+
view: sub.view,
|
|
281
|
+
...(sub.sessionId ? { sessionId: sub.sessionId } : {}),
|
|
282
|
+
...(sub.runId ? { runId: sub.runId } : {}),
|
|
283
|
+
highWaterSeq: highWater,
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// Chunk: first span/event batch in the base frame, then append frames
|
|
287
|
+
// for the rest (the client's applySnapshot merges append frames).
|
|
288
|
+
const wireSpans = spans.map(toWireSpan);
|
|
289
|
+
sendTo(ws, {
|
|
290
|
+
...base,
|
|
291
|
+
spans: wireSpans.slice(0, SNAPSHOT_SPAN_CHUNK),
|
|
292
|
+
events: events.slice(0, SNAPSHOT_EVENT_CHUNK).map(toWireEvent),
|
|
293
|
+
});
|
|
294
|
+
for (let i = SNAPSHOT_SPAN_CHUNK; i < wireSpans.length; i += SNAPSHOT_SPAN_CHUNK) {
|
|
295
|
+
sendTo(ws, {
|
|
296
|
+
...base,
|
|
297
|
+
spans: wireSpans.slice(i, i + SNAPSHOT_SPAN_CHUNK),
|
|
298
|
+
events: [],
|
|
299
|
+
append: true,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
for (let i = SNAPSHOT_EVENT_CHUNK; i < events.length; i += SNAPSHOT_EVENT_CHUNK) {
|
|
303
|
+
sendTo(ws, {
|
|
304
|
+
...base,
|
|
305
|
+
spans: [],
|
|
306
|
+
events: events.slice(i, i + SNAPSHOT_EVENT_CHUNK).map(toWireEvent),
|
|
307
|
+
append: true,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
handleSubscribe(ws, msg) {
|
|
314
|
+
// A scoped view without its scope id can never match anything and
|
|
315
|
+
// would only leak an all-runs snapshot shape — ignore it up front.
|
|
316
|
+
if ((msg.view === "session" && !msg.sessionId) || (msg.view === "run" && !msg.runId)) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const sub: Subscription = {
|
|
320
|
+
view: msg.view,
|
|
321
|
+
...(msg.sessionId ? { sessionId: msg.sessionId } : {}),
|
|
322
|
+
...(msg.runId ? { runId: msg.runId } : {}),
|
|
323
|
+
};
|
|
324
|
+
const list = subscriptions.get(ws) ?? [];
|
|
325
|
+
if (!list.some((s) => subKey(s) === subKey(sub))) list.push(sub);
|
|
326
|
+
subscriptions.set(ws, list);
|
|
327
|
+
ensurePolling();
|
|
328
|
+
try {
|
|
329
|
+
sendSnapshot(ws, sub);
|
|
330
|
+
} catch (err) {
|
|
331
|
+
log?.emit({
|
|
332
|
+
severityText: "WARN",
|
|
333
|
+
body: "activity snapshot failed",
|
|
334
|
+
attributes: { error: err instanceof Error ? err.message : String(err) },
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
handleUnsubscribe(ws, msg) {
|
|
340
|
+
const list = subscriptions.get(ws);
|
|
341
|
+
if (!list) return;
|
|
342
|
+
const key = subKey({
|
|
343
|
+
view: msg.view,
|
|
344
|
+
...(msg.sessionId ? { sessionId: msg.sessionId } : {}),
|
|
345
|
+
...(msg.runId ? { runId: msg.runId } : {}),
|
|
346
|
+
});
|
|
347
|
+
const remaining = list.filter((s) => subKey(s) !== key);
|
|
348
|
+
if (remaining.length > 0) subscriptions.set(ws, remaining);
|
|
349
|
+
else subscriptions.delete(ws);
|
|
350
|
+
stopPollingIfIdle();
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
dropConnection(ws) {
|
|
354
|
+
subscriptions.delete(ws);
|
|
355
|
+
stopPollingIfIdle();
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
pump,
|
|
359
|
+
|
|
360
|
+
isWatched(scope) {
|
|
361
|
+
for (const subs of subscriptions.values()) {
|
|
362
|
+
for (const s of subs) {
|
|
363
|
+
if (s.view === "index") return true;
|
|
364
|
+
if (scope.runId && s.view === "run" && s.runId === scope.runId) return true;
|
|
365
|
+
if (scope.sessionId && s.view === "session" && s.sessionId === scope.sessionId)
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return false;
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
subscriptionCount() {
|
|
373
|
+
let n = 0;
|
|
374
|
+
for (const subs of subscriptions.values()) n += subs.length;
|
|
375
|
+
return n;
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
close() {
|
|
379
|
+
if (pollHandle) clearInterval(pollHandle);
|
|
380
|
+
pollHandle = null;
|
|
381
|
+
subscriptions.clear();
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function hasUsage(span: SpanRow): boolean {
|
|
387
|
+
const u = span.usage;
|
|
388
|
+
return (
|
|
389
|
+
u.inputTokens !== undefined ||
|
|
390
|
+
u.outputTokens !== undefined ||
|
|
391
|
+
u.cacheReadTokens !== undefined ||
|
|
392
|
+
u.cacheCreationTokens !== undefined ||
|
|
393
|
+
u.costUsd !== undefined ||
|
|
394
|
+
u.model !== undefined
|
|
395
|
+
);
|
|
396
|
+
}
|
package/src/agent/backend.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Logger } from "@opentelemetry/api-logs";
|
|
2
2
|
import { createRequire } from "module";
|
|
3
3
|
import type { AgentConfig } from "../config/env.js";
|
|
4
|
-
import type { ProviderInfo } from "@schlessera/brain-ui-sdk";
|
|
4
|
+
import type { BillingMode, ProviderInfo } from "@schlessera/brain-ui-sdk";
|
|
5
5
|
import type {
|
|
6
6
|
AgentBackend,
|
|
7
7
|
BackendCapabilities,
|
|
@@ -175,6 +175,12 @@ export interface BackendRegistryOptions {
|
|
|
175
175
|
* sessions pinned to it. Defaults to "nothing hidden".
|
|
176
176
|
*/
|
|
177
177
|
getHiddenModelIds?: () => string[];
|
|
178
|
+
/**
|
|
179
|
+
* Per-profile billing-mode overrides (from the app's settings table).
|
|
180
|
+
* Consulted LAST: an override wins over both the declared-credential rule
|
|
181
|
+
* and the ambient predicate. Defaults to "no overrides".
|
|
182
|
+
*/
|
|
183
|
+
getBillingOverrides?: () => Record<string, BillingMode>;
|
|
178
184
|
/**
|
|
179
185
|
* Where the registry and its backends report. Adapted to the backends'
|
|
180
186
|
* minimal callback ({@link BackendLogFn}) before crossing the package
|
|
@@ -197,9 +203,11 @@ export interface BackendRegistry {
|
|
|
197
203
|
/** Resolve a persisted backend id, falling back for legacy/unknown sessions. */
|
|
198
204
|
getBackendForSession(backendId: string | null | undefined): Promise<AgentBackend>;
|
|
199
205
|
/**
|
|
200
|
-
* Every available profile, tagged with its owning backend id
|
|
201
|
-
*
|
|
202
|
-
*
|
|
206
|
+
* Every available profile, tagged with its owning backend id and (when the
|
|
207
|
+
* registry can classify it) its resolved `billingMode` — settings override
|
|
208
|
+
* applied last. Hidden profiles are omitted by default (this feeds the
|
|
209
|
+
* picker); the settings screen passes `includeHidden` to render the full
|
|
210
|
+
* catalog.
|
|
203
211
|
*/
|
|
204
212
|
listAllProviders(options?: { includeHidden?: boolean }): Promise<ProviderInfo[]>;
|
|
205
213
|
/** Per-backend capability metadata exposed by the providers route. */
|
|
@@ -372,6 +380,32 @@ export function createBackendRegistry(
|
|
|
372
380
|
let cachedRegistry: Promise<RegistrySnapshot> | null = null;
|
|
373
381
|
let modelSource: ClaudeModelSource | null = null;
|
|
374
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Declared profiles carrying their OWN credential env vars
|
|
385
|
+
* (authTokenEnv/apiKeyEnv) — api-billed regardless of the ambient
|
|
386
|
+
* credential. Populated when the Claude roster resolves, which happens
|
|
387
|
+
* before any profile can be listed or run.
|
|
388
|
+
*/
|
|
389
|
+
const declaredApiProfileIds = new Set<string>();
|
|
390
|
+
/** The Claude backend's id, once built — profiles on any OTHER backend (pi) have no subscription path. */
|
|
391
|
+
let claudeBackendId: string | null = null;
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Base billing classification for one roster entry, BEFORE the settings
|
|
395
|
+
* override (applied last by the shared accessor surface):
|
|
396
|
+
* - a non-Claude backend (pi) → "api";
|
|
397
|
+
* - a declared profile with explicit credentials → "api";
|
|
398
|
+
* - everything ambient (built-in default, discovered models, declared
|
|
399
|
+
* entries without their own credentials) → the env-resolved ambient
|
|
400
|
+
* mode (subscription iff the OAuth token is present and no
|
|
401
|
+
* ANTHROPIC_API_KEY — the Agent SDK's own precedence).
|
|
402
|
+
*/
|
|
403
|
+
function classifyBilling(profile: ProviderInfo & { backendId: string }): BillingMode {
|
|
404
|
+
if (claudeBackendId === null || profile.backendId !== claudeBackendId) return "api";
|
|
405
|
+
if (declaredApiProfileIds.has(profile.id)) return "api";
|
|
406
|
+
return agent.ambientBilling;
|
|
407
|
+
}
|
|
408
|
+
|
|
375
409
|
/**
|
|
376
410
|
* Declared profiles (built-in default + BRAIN_UI_CLAUDE_PROFILES) plus every
|
|
377
411
|
* discovered model whose id isn't already declared. Declared wins: the env
|
|
@@ -469,6 +503,9 @@ export function createBackendRegistry(
|
|
|
469
503
|
);
|
|
470
504
|
}
|
|
471
505
|
seen.add(input.id);
|
|
506
|
+
// The same non-empty test defineProfiles applies to requiredEnvKeys: a
|
|
507
|
+
// profile bringing its own credential env var is api-billed.
|
|
508
|
+
if (input.authTokenEnv || input.apiKeyEnv) declaredApiProfileIds.add(input.id);
|
|
472
509
|
}
|
|
473
510
|
|
|
474
511
|
const declared = (inputs as ClaudeProfileInput[]).map((input) => ({
|
|
@@ -503,7 +540,7 @@ export function createBackendRegistry(
|
|
|
503
540
|
ttlMs: agent.modelTtlMs,
|
|
504
541
|
});
|
|
505
542
|
|
|
506
|
-
|
|
543
|
+
const backend = claude.createClaudeBackend({
|
|
507
544
|
brainPath,
|
|
508
545
|
claudeCodePath: agent.claudeCodePath,
|
|
509
546
|
...(backendLog ? { log: backendLog } : {}),
|
|
@@ -517,6 +554,8 @@ export function createBackendRegistry(
|
|
|
517
554
|
profiles: () =>
|
|
518
555
|
mergeDiscovered(claude, declared, modelSource?.list() ?? []),
|
|
519
556
|
});
|
|
557
|
+
claudeBackendId = backend.id;
|
|
558
|
+
return backend;
|
|
520
559
|
}
|
|
521
560
|
|
|
522
561
|
async function buildPiBackend(): Promise<AgentBackend> {
|
|
@@ -566,7 +605,13 @@ export function createBackendRegistry(
|
|
|
566
605
|
await getRegistry();
|
|
567
606
|
return modelSource;
|
|
568
607
|
},
|
|
569
|
-
options.log
|
|
608
|
+
options.log,
|
|
609
|
+
{
|
|
610
|
+
classify: classifyBilling,
|
|
611
|
+
...(options.getBillingOverrides
|
|
612
|
+
? { getOverrides: options.getBillingOverrides }
|
|
613
|
+
: {}),
|
|
614
|
+
}
|
|
570
615
|
);
|
|
571
616
|
}
|
|
572
617
|
|
|
@@ -579,14 +624,23 @@ export function createBackendRegistry(
|
|
|
579
624
|
export function createStaticBackendRegistry(
|
|
580
625
|
backends: AgentBackend[],
|
|
581
626
|
defaultBackendId = backends[0]?.id ?? "",
|
|
582
|
-
options: {
|
|
627
|
+
options: {
|
|
628
|
+
getHiddenModelIds?: () => string[];
|
|
629
|
+
getBillingOverrides?: () => Record<string, BillingMode>;
|
|
630
|
+
log?: Logger;
|
|
631
|
+
} = {}
|
|
583
632
|
): BackendRegistry {
|
|
584
633
|
const snapshot = buildSnapshot(backends, defaultBackendId);
|
|
585
634
|
return makeRegistry(
|
|
586
635
|
async () => snapshot,
|
|
587
636
|
options.getHiddenModelIds ?? (() => []),
|
|
588
637
|
async () => null,
|
|
589
|
-
options.log
|
|
638
|
+
options.log,
|
|
639
|
+
// No classifier: an embedder's backends carry no credential topology this
|
|
640
|
+
// registry could reason about, so only an explicit override sets a mode.
|
|
641
|
+
options.getBillingOverrides
|
|
642
|
+
? { getOverrides: options.getBillingOverrides }
|
|
643
|
+
: {}
|
|
590
644
|
);
|
|
591
645
|
}
|
|
592
646
|
|
|
@@ -595,7 +649,13 @@ function makeRegistry(
|
|
|
595
649
|
getRegistry: () => Promise<RegistrySnapshot>,
|
|
596
650
|
getHidden: () => string[],
|
|
597
651
|
getModelSource: () => Promise<ModelDiscoverySource | null>,
|
|
598
|
-
log?: Logger
|
|
652
|
+
log?: Logger,
|
|
653
|
+
billing: {
|
|
654
|
+
/** Base classification per roster entry; absent = the registry cannot classify. */
|
|
655
|
+
classify?: (profile: ProviderInfo & { backendId: string }) => BillingMode;
|
|
656
|
+
/** Settings overrides, consulted LAST — an override wins over `classify`. */
|
|
657
|
+
getOverrides?: () => Record<string, BillingMode>;
|
|
658
|
+
} = {}
|
|
599
659
|
): BackendRegistry {
|
|
600
660
|
let profileSnapshot: ProfileSnapshot | null = null;
|
|
601
661
|
|
|
@@ -641,6 +701,20 @@ function makeRegistry(
|
|
|
641
701
|
}
|
|
642
702
|
}
|
|
643
703
|
|
|
704
|
+
/** Same degradation discipline: a failed override read means derived modes apply. */
|
|
705
|
+
function billingOverrides(): Record<string, BillingMode> {
|
|
706
|
+
try {
|
|
707
|
+
return billing.getOverrides?.() ?? {};
|
|
708
|
+
} catch (err) {
|
|
709
|
+
log?.emit({
|
|
710
|
+
severityText: "WARN",
|
|
711
|
+
body: "could not read billing overrides; using derived billing modes",
|
|
712
|
+
attributes: { error: err instanceof Error ? err.message : String(err) },
|
|
713
|
+
});
|
|
714
|
+
return {};
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
644
718
|
return {
|
|
645
719
|
async getBackends() {
|
|
646
720
|
return (await getRegistry()).backends;
|
|
@@ -681,11 +755,18 @@ function makeRegistry(
|
|
|
681
755
|
const registry = await getRegistry();
|
|
682
756
|
const snapshot = await getProfileSnapshot();
|
|
683
757
|
const hidden = options.includeHidden ? new Set<string>() : hiddenIds();
|
|
758
|
+
// Read fresh on every listing (not memoized with the snapshot), so a
|
|
759
|
+
// saved override is live for the very next run without invalidation.
|
|
760
|
+
const overrides = billingOverrides();
|
|
684
761
|
|
|
685
762
|
return registry.backends.flatMap((backend) =>
|
|
686
763
|
(snapshot.byBackend.get(backend.id) ?? [])
|
|
687
764
|
.filter((profile) => !hidden.has(profile.id))
|
|
688
|
-
.map((profile) =>
|
|
765
|
+
.map((profile) => {
|
|
766
|
+
const entry = { ...profile, backendId: backend.id };
|
|
767
|
+
const billingMode = overrides[profile.id] ?? billing.classify?.(entry);
|
|
768
|
+
return billingMode ? { ...entry, billingMode } : entry;
|
|
769
|
+
})
|
|
689
770
|
);
|
|
690
771
|
},
|
|
691
772
|
|
package/src/app.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { resolveServerConfig, type ServerConfig } from "./config/env.js";
|
|
|
8
8
|
import { createHealthRoutes, createStatusRoutes } from "./routes/health.js";
|
|
9
9
|
import { createBrainRoutes } from "./routes/brain.js";
|
|
10
10
|
import { createSessionRoutes } from "./routes/sessions.js";
|
|
11
|
+
import { createActivityRoutes } from "./routes/activity.js";
|
|
11
12
|
import { createVoiceRoutes } from "./routes/voice.js";
|
|
12
13
|
import { createFilesRoutes } from "./routes/files.js";
|
|
13
14
|
import { createShareRoutes, shareTargetFallbackRoutes } from "./routes/share.js";
|
|
@@ -32,7 +33,10 @@ import {
|
|
|
32
33
|
type PasskeyContext,
|
|
33
34
|
} from "./middleware/passkeys.js";
|
|
34
35
|
import { createUiDb } from "./db/client.js";
|
|
35
|
-
import { getHiddenModelIds } from "./db/settings.js";
|
|
36
|
+
import { getBillingOverrides, getHiddenModelIds } from "./db/settings.js";
|
|
37
|
+
import { createActivityRuntime } from "./activity/runtime.js";
|
|
38
|
+
import { createModelPricing } from "./pricing/model-pricing.js";
|
|
39
|
+
import { createPushRoutes } from "./routes/push.js";
|
|
36
40
|
import {
|
|
37
41
|
assertBackendResolvable,
|
|
38
42
|
createBackendRegistry,
|
|
@@ -171,12 +175,31 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
171
175
|
const db = createUiDb(config.dbPath, { log: dbLog });
|
|
172
176
|
const brain = createBrainClient({ brainPath: config.brainPath });
|
|
173
177
|
const cron = createCronScheduler({ db, brain, log: observability.logger("cron") });
|
|
178
|
+
|
|
179
|
+
// Model pricing for rollup-time effective cost: constructed here because
|
|
180
|
+
// the config owns enabled/TTL/brainPath, shared through the activity
|
|
181
|
+
// runtime. The first refresh warms in the background — ensureFresh never
|
|
182
|
+
// rejects and no rollup ever waits on the network (resolve() is sync).
|
|
183
|
+
const pricing = createModelPricing({
|
|
184
|
+
brainPath: config.brainPath,
|
|
185
|
+
enabled: config.pricing.enabled,
|
|
186
|
+
ttlMs: config.pricing.ttlMs,
|
|
187
|
+
});
|
|
188
|
+
void pricing.ensureFresh();
|
|
189
|
+
|
|
190
|
+
// Activity record: span store + live stream + notifications + lifecycle
|
|
191
|
+
// sweeps, owned by the runtime (see activity/runtime.ts).
|
|
192
|
+
const activity = createActivityRuntime(db, {
|
|
193
|
+
log: observability.logger("activity"),
|
|
194
|
+
pricing,
|
|
195
|
+
});
|
|
174
196
|
const registry =
|
|
175
197
|
options.registry ??
|
|
176
198
|
createBackendRegistry({
|
|
177
199
|
brainPath: config.brainPath,
|
|
178
200
|
agent: config.agent,
|
|
179
201
|
getHiddenModelIds: () => getHiddenModelIds(db, dbLog),
|
|
202
|
+
getBillingOverrides: () => getBillingOverrides(db, dbLog),
|
|
180
203
|
log: observability.logger("agent"),
|
|
181
204
|
});
|
|
182
205
|
const host = new WsHost({
|
|
@@ -190,6 +213,11 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
190
213
|
: {}),
|
|
191
214
|
maxConcurrentSessions: () => config.maxConcurrentSessions,
|
|
192
215
|
wsRate: config.wsRate,
|
|
216
|
+
activity: {
|
|
217
|
+
store: activity.store,
|
|
218
|
+
stream: activity.stream,
|
|
219
|
+
query: activity.query,
|
|
220
|
+
},
|
|
193
221
|
});
|
|
194
222
|
const wsUpgrade = createWsUpgrade(host);
|
|
195
223
|
// One instrument for every way a login can fail — passkey ceremonies and
|
|
@@ -293,6 +321,10 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
293
321
|
createBrainRoutes({ brain, brainPath: config.brainPath, keyterms })
|
|
294
322
|
);
|
|
295
323
|
app.route("/api", createSessionRoutes({ registry, db }));
|
|
324
|
+
// Behind the guard by mount position, like /api/status: the activity
|
|
325
|
+
// record leaks strictly more (session activity, errors, spend).
|
|
326
|
+
app.route("/api", createActivityRoutes({ db, store: activity.store, notifier: activity.notifier }));
|
|
327
|
+
app.route("/api", createPushRoutes({ sender: activity.pushSender }));
|
|
296
328
|
app.route("/api", createVoiceRoutes({ voice: config.voice, keyterms }));
|
|
297
329
|
app.route(
|
|
298
330
|
"/api",
|
|
@@ -310,7 +342,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
310
342
|
app.route("/api", createProviderRoutes({ registry }));
|
|
311
343
|
app.route(
|
|
312
344
|
"/api",
|
|
313
|
-
createModelRoutes({ registry, db, log: observability.logger("models") })
|
|
345
|
+
createModelRoutes({ registry, db, pricing, log: observability.logger("models") })
|
|
314
346
|
);
|
|
315
347
|
app.route(
|
|
316
348
|
"/api",
|
|
@@ -365,6 +397,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
365
397
|
isTurnActive: () => host.coordinator.isTurnActive(),
|
|
366
398
|
cancelActiveTurns: () => host.coordinator.cancelAll("Server shutting down"),
|
|
367
399
|
close: () => {
|
|
400
|
+
activity.close();
|
|
368
401
|
db.close();
|
|
369
402
|
},
|
|
370
403
|
};
|