@schlessera/brain-ui-server 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/activity/digest.d.ts +29 -0
- package/dist/activity/digest.d.ts.map +1 -0
- package/dist/activity/digest.js +61 -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 +171 -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 +128 -0
- package/dist/activity/query.js.map +1 -0
- package/dist/activity/recorder.d.ts +48 -0
- package/dist/activity/recorder.d.ts.map +1 -0
- package/dist/activity/recorder.js +205 -0
- package/dist/activity/recorder.js.map +1 -0
- package/dist/activity/runtime.d.ts +31 -0
- package/dist/activity/runtime.d.ts.map +1 -0
- package/dist/activity/runtime.js +109 -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 +207 -0
- package/dist/activity/store.d.ts.map +1 -0
- package/dist/activity/store.js +440 -0
- package/dist/activity/store.js.map +1 -0
- package/dist/activity/stream.d.ts +45 -0
- package/dist/activity/stream.d.ts.map +1 -0
- package/dist/activity/stream.js +312 -0
- package/dist/activity/stream.js.map +1 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +20 -1
- package/dist/app.js.map +1 -1
- package/dist/config/env.d.ts +5 -0
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +12 -0
- 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/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/routes/activity.d.ts +20 -0
- package/dist/routes/activity.d.ts.map +1 -0
- package/dist/routes/activity.js +300 -0
- package/dist/routes/activity.js.map +1 -0
- 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 +45 -3
- package/dist/ws/bridge.js.map +1 -1
- package/dist/ws/connection.d.ts.map +1 -1
- package/dist/ws/connection.js +41 -5
- 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 +25 -1
- package/dist/ws/run-session.js.map +1 -1
- package/dist/ws/turns.d.ts +22 -2
- package/dist/ws/turns.d.ts.map +1 -1
- package/dist/ws/turns.js +13 -0
- package/dist/ws/turns.js.map +1 -1
- package/migrations/007_activity.sql +114 -0
- package/migrations/008_push_subscriptions.sql +24 -0
- package/package.json +5 -3
- package/src/activity/digest.ts +92 -0
- package/src/activity/notify.ts +285 -0
- package/src/activity/push-sender.ts +215 -0
- package/src/activity/query.ts +160 -0
- package/src/activity/recorder.ts +261 -0
- package/src/activity/runtime.ts +141 -0
- package/src/activity/span-sink.ts +152 -0
- package/src/activity/store.ts +774 -0
- package/src/activity/stream.ts +366 -0
- package/src/app.ts +21 -1
- package/src/config/env.ts +19 -0
- package/src/cron/scheduler.ts +28 -8
- package/src/db/client.ts +31 -6
- package/src/index.ts +22 -0
- package/src/routes/activity.ts +363 -0
- package/src/routes/push.ts +90 -0
- package/src/routes/sessions.ts +24 -4
- package/src/ws/bridge.ts +47 -3
- package/src/ws/connection.ts +57 -5
- package/src/ws/dispatch.ts +13 -0
- package/src/ws/host.ts +18 -0
- package/src/ws/run-session.ts +32 -1
- package/src/ws/turns.ts +29 -1
|
@@ -0,0 +1,366 @@
|
|
|
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 type {
|
|
21
|
+
ActivitySpan,
|
|
22
|
+
ActivitySpanEvent,
|
|
23
|
+
ClientActivitySubscribe,
|
|
24
|
+
ClientActivityUnsubscribe,
|
|
25
|
+
ServerActivityDelta,
|
|
26
|
+
ServerActivitySnapshot,
|
|
27
|
+
} from "@schlessera/brain-ui-sdk/protocol";
|
|
28
|
+
import type { Logger } from "@opentelemetry/api-logs";
|
|
29
|
+
|
|
30
|
+
import type { WSContext } from "../ws/clients.js";
|
|
31
|
+
import { sendTo } from "../ws/clients.js";
|
|
32
|
+
import type { ActivityChange, ActivityStore, SpanEventRow, SpanRow } from "./store.js";
|
|
33
|
+
|
|
34
|
+
/** Events per snapshot frame — keeps each frame far below the WS size cap. */
|
|
35
|
+
const SNAPSHOT_EVENT_CHUNK = 100;
|
|
36
|
+
/** Spans per snapshot frame, same rationale. */
|
|
37
|
+
const SNAPSHOT_SPAN_CHUNK = 100;
|
|
38
|
+
/** Fast-poll cadence while subscriptions exist (foreign-writer liveness). */
|
|
39
|
+
const POLL_INTERVAL_MS = 1500;
|
|
40
|
+
|
|
41
|
+
const TOOL_NAME_PREFIX = "execute_tool ";
|
|
42
|
+
|
|
43
|
+
interface Subscription {
|
|
44
|
+
view: "index" | "session" | "run";
|
|
45
|
+
sessionId?: string;
|
|
46
|
+
runId?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Store row → wire span. Lifts the typed fields the protocol promotes
|
|
50
|
+
* (toolName, subagent enrichment) out of the naming/attr conventions. */
|
|
51
|
+
export function toWireSpan(span: SpanRow): ActivitySpan {
|
|
52
|
+
const isToolLike = span.kind === "tool" || span.kind === "subagent";
|
|
53
|
+
const toolName =
|
|
54
|
+
span.kind === "subagent" && span.name.startsWith(`${TOOL_NAME_PREFIX}Agent`)
|
|
55
|
+
? "Agent"
|
|
56
|
+
: isToolLike && span.name.startsWith(TOOL_NAME_PREFIX)
|
|
57
|
+
? span.name.slice(TOOL_NAME_PREFIX.length)
|
|
58
|
+
: isToolLike
|
|
59
|
+
? span.name
|
|
60
|
+
: undefined;
|
|
61
|
+
const subagent = span.kind === "subagent" ? subagentOf(span.attrs) : undefined;
|
|
62
|
+
return {
|
|
63
|
+
spanId: span.spanId,
|
|
64
|
+
runId: span.runId,
|
|
65
|
+
parentSpanId: span.parentSpanId ?? undefined,
|
|
66
|
+
name: span.name,
|
|
67
|
+
...(toolName !== undefined ? { toolName } : {}),
|
|
68
|
+
kind: span.kind,
|
|
69
|
+
origin: span.origin,
|
|
70
|
+
sessionId: span.sessionId ?? undefined,
|
|
71
|
+
jobName: span.jobName ?? undefined,
|
|
72
|
+
startedAt: span.startedAt,
|
|
73
|
+
waitUntil: span.waitUntil ?? undefined,
|
|
74
|
+
endedAt: span.endedAt ?? undefined,
|
|
75
|
+
outcome: span.outcome ?? undefined,
|
|
76
|
+
outcomeReason: span.outcomeReason ?? undefined,
|
|
77
|
+
usage: hasUsage(span) ? { ...span.usage } : undefined,
|
|
78
|
+
...(subagent ? { subagent } : {}),
|
|
79
|
+
attrs: Object.keys(span.attrs).length > 0 ? span.attrs : undefined,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function subagentOf(attrs: Record<string, unknown>): ActivitySpan["subagent"] {
|
|
84
|
+
const type = attrs["subagent.type"];
|
|
85
|
+
const description = attrs["subagent.description"];
|
|
86
|
+
const summary = attrs["subagent.summary"];
|
|
87
|
+
const totalTokens = attrs["subagent.total_tokens"];
|
|
88
|
+
const subagent = {
|
|
89
|
+
...(typeof type === "string" ? { type } : {}),
|
|
90
|
+
...(typeof description === "string" ? { description } : {}),
|
|
91
|
+
...(typeof summary === "string" ? { summary } : {}),
|
|
92
|
+
...(typeof totalTokens === "number" ? { totalTokens } : {}),
|
|
93
|
+
};
|
|
94
|
+
return Object.keys(subagent).length > 0 ? subagent : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function toWireEvent(event: SpanEventRow): ActivitySpanEvent {
|
|
98
|
+
return {
|
|
99
|
+
spanId: event.spanId,
|
|
100
|
+
eventIndex: event.eventIndex,
|
|
101
|
+
ts: event.ts,
|
|
102
|
+
eventType: event.eventType,
|
|
103
|
+
payload: event.payload,
|
|
104
|
+
...(event.truncated ? { truncated: true } : {}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface ActivityStream {
|
|
109
|
+
handleSubscribe(ws: WSContext, msg: ClientActivitySubscribe): void;
|
|
110
|
+
handleUnsubscribe(ws: WSContext, msg: ClientActivityUnsubscribe): void;
|
|
111
|
+
/** Forget a closed socket. */
|
|
112
|
+
dropConnection(ws: WSContext): void;
|
|
113
|
+
/** Drain new committed changes to subscribers. Reentrancy-safe. */
|
|
114
|
+
pump(): void;
|
|
115
|
+
/** Whether any connection currently watches the given session's activity. */
|
|
116
|
+
isWatched(scope: { sessionId?: string; runId?: string }): boolean;
|
|
117
|
+
/** Number of live subscriptions (all connections). */
|
|
118
|
+
subscriptionCount(): number;
|
|
119
|
+
close(): void;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createActivityStream(store: ActivityStore, log?: Logger): ActivityStream {
|
|
123
|
+
const subscriptions = new Map<WSContext, Subscription[]>();
|
|
124
|
+
let cursor = store.latestChangeCursor();
|
|
125
|
+
let pumping = false;
|
|
126
|
+
let pollHandle: ReturnType<typeof setInterval> | null = null;
|
|
127
|
+
/** runId → sessionId, learned from any span seen; a run's session never
|
|
128
|
+
* changes, so entries are immutable. Replaces per-event store lookups
|
|
129
|
+
* when matching event deltas against session-view subscriptions. */
|
|
130
|
+
const runSessions = new Map<string, string>();
|
|
131
|
+
|
|
132
|
+
function noteSpan(span: SpanRow): void {
|
|
133
|
+
if (span.sessionId && !runSessions.has(span.runId)) {
|
|
134
|
+
runSessions.set(span.runId, span.sessionId);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function subKey(s: Subscription): string {
|
|
139
|
+
return `${s.view}:${s.sessionId ?? ""}:${s.runId ?? ""}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function matches(sub: Subscription, runId: string, span?: SpanRow): boolean {
|
|
143
|
+
if (sub.view === "index") return true;
|
|
144
|
+
if (sub.view === "run") return sub.runId === runId;
|
|
145
|
+
// Session view: match by the span's session when we have it; event-only
|
|
146
|
+
// deltas resolve through the memoized run→session map.
|
|
147
|
+
if (span?.sessionId) return span.sessionId === sub.sessionId;
|
|
148
|
+
return runSessions.get(runId) === sub.sessionId;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function ensurePolling() {
|
|
152
|
+
if (pollHandle || subscriptions.size === 0) return;
|
|
153
|
+
pollHandle = setInterval(() => pump(), POLL_INTERVAL_MS);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function stopPollingIfIdle() {
|
|
157
|
+
if (pollHandle && subscriptions.size === 0) {
|
|
158
|
+
clearInterval(pollHandle);
|
|
159
|
+
pollHandle = null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function deltaFrame(change: ActivityChange): ServerActivityDelta {
|
|
164
|
+
return change.kind === "span"
|
|
165
|
+
? { type: "activity_delta", runId: change.runId, seq: change.seq, span: toWireSpan(change.span) }
|
|
166
|
+
: {
|
|
167
|
+
type: "activity_delta",
|
|
168
|
+
runId: change.runId,
|
|
169
|
+
seq: change.seq,
|
|
170
|
+
event: toWireEvent(change.event),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function pump(): void {
|
|
175
|
+
if (pumping) return;
|
|
176
|
+
pumping = true;
|
|
177
|
+
try {
|
|
178
|
+
// Nobody listening: skip the changes entirely, but keep the cursor at
|
|
179
|
+
// the log's head — otherwise the first subscriber's pump would replay
|
|
180
|
+
// history its snapshot already carries (harmless under the client's
|
|
181
|
+
// seq-discard rule, but pure waste). Side effects that must see every
|
|
182
|
+
// change (the notifier) keep their OWN cursor.
|
|
183
|
+
if (subscriptions.size === 0) {
|
|
184
|
+
cursor = store.latestChangeCursor();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
for (;;) {
|
|
188
|
+
const changes = store.changesSince(cursor, 200);
|
|
189
|
+
if (changes.length === 0) break;
|
|
190
|
+
for (const change of changes) {
|
|
191
|
+
cursor = change.changeId;
|
|
192
|
+
const span = change.kind === "span" ? change.span : undefined;
|
|
193
|
+
if (span) noteSpan(span);
|
|
194
|
+
const frame = deltaFrame(change);
|
|
195
|
+
for (const [ws, subs] of subscriptions) {
|
|
196
|
+
if (subs.some((s) => matches(s, change.runId, span))) {
|
|
197
|
+
sendTo(ws, frame);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (changes.length < 200) break;
|
|
202
|
+
}
|
|
203
|
+
} catch (err) {
|
|
204
|
+
log?.emit({
|
|
205
|
+
severityText: "WARN",
|
|
206
|
+
body: "activity pump failed",
|
|
207
|
+
attributes: { error: err instanceof Error ? err.message : String(err) },
|
|
208
|
+
});
|
|
209
|
+
} finally {
|
|
210
|
+
pumping = false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function sendSnapshot(ws: WSContext, sub: Subscription): void {
|
|
215
|
+
let spans: SpanRow[] = [];
|
|
216
|
+
let events: SpanEventRow[] = [];
|
|
217
|
+
const highWater: Record<string, number> = {};
|
|
218
|
+
|
|
219
|
+
if (sub.view === "run" && sub.runId) {
|
|
220
|
+
const snap = store.snapshotRun(sub.runId);
|
|
221
|
+
if (snap) {
|
|
222
|
+
spans = snap.spans;
|
|
223
|
+
events = snap.events;
|
|
224
|
+
highWater[snap.runId] = snap.highWaterSeq;
|
|
225
|
+
// No pump-cursor rewind: the pump cursor can only LAG the snapshot
|
|
226
|
+
// read (it never runs ahead of the DB), so unpumped changes are
|
|
227
|
+
// already inside this snapshot's high-water and the client's
|
|
228
|
+
// seq-discard rule absorbs them when the pump catches up.
|
|
229
|
+
}
|
|
230
|
+
} else if (sub.view === "session" && sub.sessionId) {
|
|
231
|
+
for (const root of store.openRootSpans()) {
|
|
232
|
+
if (root.sessionId !== sub.sessionId) continue;
|
|
233
|
+
const snap = store.snapshotRun(root.runId);
|
|
234
|
+
if (!snap) continue;
|
|
235
|
+
spans = spans.concat(snap.spans);
|
|
236
|
+
events = events.concat(snap.events);
|
|
237
|
+
highWater[snap.runId] = snap.highWaterSeq;
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
// Index view: open roots only — history is the REST activity API's job.
|
|
241
|
+
for (const root of store.openRootSpans()) {
|
|
242
|
+
spans.push(root);
|
|
243
|
+
highWater[root.runId] = store.runHighWaterSeq(root.runId);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
for (const span of spans) noteSpan(span);
|
|
247
|
+
|
|
248
|
+
const base: Omit<ServerActivitySnapshot, "spans" | "events"> = {
|
|
249
|
+
type: "activity_snapshot",
|
|
250
|
+
view: sub.view,
|
|
251
|
+
...(sub.sessionId ? { sessionId: sub.sessionId } : {}),
|
|
252
|
+
...(sub.runId ? { runId: sub.runId } : {}),
|
|
253
|
+
highWaterSeq: highWater,
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// Chunk: first span/event batch in the base frame, then append frames
|
|
257
|
+
// for the rest (the client's applySnapshot merges append frames).
|
|
258
|
+
const wireSpans = spans.map(toWireSpan);
|
|
259
|
+
sendTo(ws, {
|
|
260
|
+
...base,
|
|
261
|
+
spans: wireSpans.slice(0, SNAPSHOT_SPAN_CHUNK),
|
|
262
|
+
events: events.slice(0, SNAPSHOT_EVENT_CHUNK).map(toWireEvent),
|
|
263
|
+
});
|
|
264
|
+
for (let i = SNAPSHOT_SPAN_CHUNK; i < wireSpans.length; i += SNAPSHOT_SPAN_CHUNK) {
|
|
265
|
+
sendTo(ws, {
|
|
266
|
+
...base,
|
|
267
|
+
spans: wireSpans.slice(i, i + SNAPSHOT_SPAN_CHUNK),
|
|
268
|
+
events: [],
|
|
269
|
+
append: true,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
for (let i = SNAPSHOT_EVENT_CHUNK; i < events.length; i += SNAPSHOT_EVENT_CHUNK) {
|
|
273
|
+
sendTo(ws, {
|
|
274
|
+
...base,
|
|
275
|
+
spans: [],
|
|
276
|
+
events: events.slice(i, i + SNAPSHOT_EVENT_CHUNK).map(toWireEvent),
|
|
277
|
+
append: true,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
handleSubscribe(ws, msg) {
|
|
284
|
+
// A scoped view without its scope id can never match anything and
|
|
285
|
+
// would only leak an all-runs snapshot shape — ignore it up front.
|
|
286
|
+
if ((msg.view === "session" && !msg.sessionId) || (msg.view === "run" && !msg.runId)) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const sub: Subscription = {
|
|
290
|
+
view: msg.view,
|
|
291
|
+
...(msg.sessionId ? { sessionId: msg.sessionId } : {}),
|
|
292
|
+
...(msg.runId ? { runId: msg.runId } : {}),
|
|
293
|
+
};
|
|
294
|
+
const list = subscriptions.get(ws) ?? [];
|
|
295
|
+
if (!list.some((s) => subKey(s) === subKey(sub))) list.push(sub);
|
|
296
|
+
subscriptions.set(ws, list);
|
|
297
|
+
ensurePolling();
|
|
298
|
+
try {
|
|
299
|
+
sendSnapshot(ws, sub);
|
|
300
|
+
} catch (err) {
|
|
301
|
+
log?.emit({
|
|
302
|
+
severityText: "WARN",
|
|
303
|
+
body: "activity snapshot failed",
|
|
304
|
+
attributes: { error: err instanceof Error ? err.message : String(err) },
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
handleUnsubscribe(ws, msg) {
|
|
310
|
+
const list = subscriptions.get(ws);
|
|
311
|
+
if (!list) return;
|
|
312
|
+
const key = subKey({
|
|
313
|
+
view: msg.view,
|
|
314
|
+
...(msg.sessionId ? { sessionId: msg.sessionId } : {}),
|
|
315
|
+
...(msg.runId ? { runId: msg.runId } : {}),
|
|
316
|
+
});
|
|
317
|
+
const remaining = list.filter((s) => subKey(s) !== key);
|
|
318
|
+
if (remaining.length > 0) subscriptions.set(ws, remaining);
|
|
319
|
+
else subscriptions.delete(ws);
|
|
320
|
+
stopPollingIfIdle();
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
dropConnection(ws) {
|
|
324
|
+
subscriptions.delete(ws);
|
|
325
|
+
stopPollingIfIdle();
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
pump,
|
|
329
|
+
|
|
330
|
+
isWatched(scope) {
|
|
331
|
+
for (const subs of subscriptions.values()) {
|
|
332
|
+
for (const s of subs) {
|
|
333
|
+
if (s.view === "index") return true;
|
|
334
|
+
if (scope.runId && s.view === "run" && s.runId === scope.runId) return true;
|
|
335
|
+
if (scope.sessionId && s.view === "session" && s.sessionId === scope.sessionId)
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return false;
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
subscriptionCount() {
|
|
343
|
+
let n = 0;
|
|
344
|
+
for (const subs of subscriptions.values()) n += subs.length;
|
|
345
|
+
return n;
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
close() {
|
|
349
|
+
if (pollHandle) clearInterval(pollHandle);
|
|
350
|
+
pollHandle = null;
|
|
351
|
+
subscriptions.clear();
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function hasUsage(span: SpanRow): boolean {
|
|
357
|
+
const u = span.usage;
|
|
358
|
+
return (
|
|
359
|
+
u.inputTokens !== undefined ||
|
|
360
|
+
u.outputTokens !== undefined ||
|
|
361
|
+
u.cacheReadTokens !== undefined ||
|
|
362
|
+
u.cacheCreationTokens !== undefined ||
|
|
363
|
+
u.costUsd !== undefined ||
|
|
364
|
+
u.model !== undefined
|
|
365
|
+
);
|
|
366
|
+
}
|
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";
|
|
@@ -33,6 +34,8 @@ import {
|
|
|
33
34
|
} from "./middleware/passkeys.js";
|
|
34
35
|
import { createUiDb } from "./db/client.js";
|
|
35
36
|
import { getHiddenModelIds } from "./db/settings.js";
|
|
37
|
+
import { createActivityRuntime } from "./activity/runtime.js";
|
|
38
|
+
import { createPushRoutes } from "./routes/push.js";
|
|
36
39
|
import {
|
|
37
40
|
assertBackendResolvable,
|
|
38
41
|
createBackendRegistry,
|
|
@@ -171,6 +174,10 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
171
174
|
const db = createUiDb(config.dbPath, { log: dbLog });
|
|
172
175
|
const brain = createBrainClient({ brainPath: config.brainPath });
|
|
173
176
|
const cron = createCronScheduler({ db, brain, log: observability.logger("cron") });
|
|
177
|
+
|
|
178
|
+
// Activity record: span store + live stream + notifications + lifecycle
|
|
179
|
+
// sweeps, owned by the runtime (see activity/runtime.ts).
|
|
180
|
+
const activity = createActivityRuntime(db, { log: observability.logger("activity") });
|
|
174
181
|
const registry =
|
|
175
182
|
options.registry ??
|
|
176
183
|
createBackendRegistry({
|
|
@@ -184,9 +191,17 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
184
191
|
observability,
|
|
185
192
|
catalog: createSessionCatalog(() => db, dbLog),
|
|
186
193
|
...(options.appName ? { appName: options.appName } : {}),
|
|
187
|
-
|
|
194
|
+
// Explicit option wins; then the env-resolved config; then the host default.
|
|
195
|
+
...(options.turnTimeoutMs ?? config.turnTimeoutMs
|
|
196
|
+
? { turnTimeoutMs: (options.turnTimeoutMs ?? config.turnTimeoutMs)! }
|
|
197
|
+
: {}),
|
|
188
198
|
maxConcurrentSessions: () => config.maxConcurrentSessions,
|
|
189
199
|
wsRate: config.wsRate,
|
|
200
|
+
activity: {
|
|
201
|
+
store: activity.store,
|
|
202
|
+
stream: activity.stream,
|
|
203
|
+
query: activity.query,
|
|
204
|
+
},
|
|
190
205
|
});
|
|
191
206
|
const wsUpgrade = createWsUpgrade(host);
|
|
192
207
|
// One instrument for every way a login can fail — passkey ceremonies and
|
|
@@ -290,6 +305,10 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
290
305
|
createBrainRoutes({ brain, brainPath: config.brainPath, keyterms })
|
|
291
306
|
);
|
|
292
307
|
app.route("/api", createSessionRoutes({ registry, db }));
|
|
308
|
+
// Behind the guard by mount position, like /api/status: the activity
|
|
309
|
+
// record leaks strictly more (session activity, errors, spend).
|
|
310
|
+
app.route("/api", createActivityRoutes({ db, store: activity.store, notifier: activity.notifier }));
|
|
311
|
+
app.route("/api", createPushRoutes({ sender: activity.pushSender }));
|
|
293
312
|
app.route("/api", createVoiceRoutes({ voice: config.voice, keyterms }));
|
|
294
313
|
app.route(
|
|
295
314
|
"/api",
|
|
@@ -362,6 +381,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
362
381
|
isTurnActive: () => host.coordinator.isTurnActive(),
|
|
363
382
|
cancelActiveTurns: () => host.coordinator.cancelAll("Server shutting down"),
|
|
364
383
|
close: () => {
|
|
384
|
+
activity.close();
|
|
365
385
|
db.close();
|
|
366
386
|
},
|
|
367
387
|
};
|
package/src/config/env.ts
CHANGED
|
@@ -132,6 +132,13 @@ export const ENV_VARS: readonly EnvVarDescriptor[] = [
|
|
|
132
132
|
default: "3",
|
|
133
133
|
required: false,
|
|
134
134
|
},
|
|
135
|
+
{
|
|
136
|
+
name: "BRAIN_UI_TURN_TIMEOUT_MS",
|
|
137
|
+
description:
|
|
138
|
+
"Hard per-turn timeout in ms; the host aborts a turn that runs past it. Raise for agent-heavy research work (e.g. 1800000 for 30 minutes).",
|
|
139
|
+
default: "600000 (10 minutes)",
|
|
140
|
+
required: false,
|
|
141
|
+
},
|
|
135
142
|
// auth
|
|
136
143
|
{
|
|
137
144
|
name: "AUTH_MODE",
|
|
@@ -360,6 +367,11 @@ export interface ServerConfig {
|
|
|
360
367
|
sourceCommit: string;
|
|
361
368
|
allowedOrigins: string[];
|
|
362
369
|
maxConcurrentSessions: number;
|
|
370
|
+
/**
|
|
371
|
+
* Per-turn timeout in ms (BRAIN_UI_TURN_TIMEOUT_MS), or null to use the
|
|
372
|
+
* WsHost default (10 minutes). An explicit createApp option still wins.
|
|
373
|
+
*/
|
|
374
|
+
turnTimeoutMs: number | null;
|
|
363
375
|
/** Threshold for the console log consumer (BRAIN_UI_LOG_LEVEL). */
|
|
364
376
|
logLevel: Severity;
|
|
365
377
|
/** Inbound WebSocket frame metering, per connection. */
|
|
@@ -448,6 +460,13 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
|
|
|
448
460
|
sourceCommit: env.SOURCE_COMMIT ?? "dev",
|
|
449
461
|
allowedOrigins: list(env.ALLOWED_ORIGINS),
|
|
450
462
|
maxConcurrentSessions: Math.max(1, Number(env.MAX_CONCURRENT_SESSIONS) || 3),
|
|
463
|
+
// Positive integer or null — 0, negatives, and garbage all mean "unset",
|
|
464
|
+
// so a typo degrades to the safe default instead of an instant timeout.
|
|
465
|
+
turnTimeoutMs:
|
|
466
|
+
Number.isFinite(Number(env.BRAIN_UI_TURN_TIMEOUT_MS)) &&
|
|
467
|
+
Number(env.BRAIN_UI_TURN_TIMEOUT_MS) > 0
|
|
468
|
+
? Math.floor(Number(env.BRAIN_UI_TURN_TIMEOUT_MS))
|
|
469
|
+
: null,
|
|
451
470
|
auth: {
|
|
452
471
|
mode: validMode,
|
|
453
472
|
invalidMode: validMode ? null : rawAuthMode,
|
package/src/cron/scheduler.ts
CHANGED
|
@@ -115,15 +115,35 @@ export function createCronScheduler(deps: {
|
|
|
115
115
|
|
|
116
116
|
return {
|
|
117
117
|
getCronStatus() {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
118
|
+
// Every job name that ever recorded a run, not just the two in-process
|
|
119
|
+
// triggers: module jobs and the maintain/digest crontab entries land in
|
|
120
|
+
// cron_runs through the same recorder and were previously invisible
|
|
121
|
+
// here (the "cron-status name gap"). The in-process names are unioned
|
|
122
|
+
// in so a fresh deployment still lists them before their first run.
|
|
123
|
+
// One window query yields each job's latest run.
|
|
124
|
+
const lastRuns = db
|
|
125
|
+
.query(
|
|
126
|
+
`SELECT job_name, started_at, status, duration_ms, error_message FROM (
|
|
127
|
+
SELECT *, ROW_NUMBER() OVER (
|
|
128
|
+
PARTITION BY job_name ORDER BY started_at DESC
|
|
129
|
+
) AS rn FROM cron_runs
|
|
130
|
+
) WHERE rn = 1 ORDER BY job_name`
|
|
131
|
+
)
|
|
132
|
+
.all() as Array<{
|
|
133
|
+
job_name: string;
|
|
134
|
+
started_at: number;
|
|
135
|
+
status: string;
|
|
136
|
+
duration_ms: number | null;
|
|
137
|
+
error_message: string | null;
|
|
138
|
+
}>;
|
|
139
|
+
const byName = new Map(lastRuns.map((r) => [r.job_name, r]));
|
|
140
|
+
const names = [
|
|
141
|
+
...new Set([...jobs.map((j) => j.name), ...lastRuns.map((r) => r.job_name)]),
|
|
142
|
+
];
|
|
143
|
+
return names.map((name) => {
|
|
144
|
+
const lastRun = byName.get(name);
|
|
125
145
|
return {
|
|
126
|
-
name
|
|
146
|
+
name,
|
|
127
147
|
lastRunAt: lastRun?.started_at ?? null,
|
|
128
148
|
lastStatus: lastRun?.status ?? null,
|
|
129
149
|
lastDurationMs: lastRun?.duration_ms ?? null,
|
package/src/db/client.ts
CHANGED
|
@@ -26,6 +26,12 @@ export function createUiDb(dbPath: string, options: CreateUiDbOptions = {}): Dat
|
|
|
26
26
|
const db = new Database(dbPath, { create: true });
|
|
27
27
|
db.exec("PRAGMA journal_mode = WAL");
|
|
28
28
|
db.exec("PRAGMA foreign_keys = ON");
|
|
29
|
+
// Two processes write this database (the server and the cron wrapper). The
|
|
30
|
+
// timeout makes a writer wait for a held lock instead of failing on it.
|
|
31
|
+
// It does NOT rescue a deferred transaction that loses the snapshot-upgrade
|
|
32
|
+
// race (that still throws SQLITE_BUSY) — which is why activity writes use
|
|
33
|
+
// immediate transactions; see src/activity/store.ts.
|
|
34
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
29
35
|
runMigrations(db, options.log);
|
|
30
36
|
return db;
|
|
31
37
|
}
|
|
@@ -67,11 +73,30 @@ function runMigrations(database: Database, log?: Logger) {
|
|
|
67
73
|
log?.emit({ severityText: "INFO", body: "applying migration", attributes: { file } });
|
|
68
74
|
const sql = readFileSync(join(migrationsDir, file), "utf-8");
|
|
69
75
|
|
|
70
|
-
|
|
71
|
-
database.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
try {
|
|
77
|
+
database.transaction(() => {
|
|
78
|
+
database.exec(sql);
|
|
79
|
+
database
|
|
80
|
+
.prepare("INSERT INTO _migrations (filename, applied_at) VALUES (?, ?)")
|
|
81
|
+
.run(file, Date.now());
|
|
82
|
+
})();
|
|
83
|
+
} catch (err) {
|
|
84
|
+
// Two processes racing a first boot: the loser's transaction fails on
|
|
85
|
+
// the UNIQUE _migrations.filename insert (or on DDL the winner already
|
|
86
|
+
// ran). If the file is now recorded as applied, treat it as such and
|
|
87
|
+
// move on; anything else is a real migration failure.
|
|
88
|
+
const nowApplied = database
|
|
89
|
+
.query("SELECT 1 FROM _migrations WHERE filename = ?")
|
|
90
|
+
.get(file);
|
|
91
|
+
const uniqueViolation =
|
|
92
|
+
err instanceof Error &&
|
|
93
|
+
err.message.includes("UNIQUE constraint failed: _migrations.filename");
|
|
94
|
+
if (!nowApplied && !uniqueViolation) throw err;
|
|
95
|
+
log?.emit({
|
|
96
|
+
severityText: "INFO",
|
|
97
|
+
body: "migration applied concurrently by another process; skipping",
|
|
98
|
+
attributes: { file },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
76
101
|
}
|
|
77
102
|
}
|
package/src/index.ts
CHANGED
|
@@ -80,6 +80,28 @@ export { createBrainClient, type BrainClient } from "./brain/client.js";
|
|
|
80
80
|
// `cronJobs` reflects what actually ran.
|
|
81
81
|
export { recordCronRun, type CronRunRecord } from "./cron/scheduler.js";
|
|
82
82
|
|
|
83
|
+
// The activity record. The cron wrapper is a second PROCESS writing spans
|
|
84
|
+
// into the same store (root span + heartbeat + span-sink ingest); everything
|
|
85
|
+
// else consumes it through the app.
|
|
86
|
+
export {
|
|
87
|
+
createActivityStore,
|
|
88
|
+
SPAN_OUTCOMES,
|
|
89
|
+
type ActivityStore,
|
|
90
|
+
type SpanRow,
|
|
91
|
+
type SpanEventRow,
|
|
92
|
+
type SpanOutcome,
|
|
93
|
+
type SpanUsage,
|
|
94
|
+
type ActivityChange,
|
|
95
|
+
} from "./activity/store.js";
|
|
96
|
+
export { createActivityStream, type ActivityStream } from "./activity/stream.js";
|
|
97
|
+
export { ingestSpanSink } from "./activity/span-sink.js";
|
|
98
|
+
export {
|
|
99
|
+
generateActivityDigest,
|
|
100
|
+
latestActivityDigest,
|
|
101
|
+
DIGEST_JOB_NAME,
|
|
102
|
+
type ActivityDigest,
|
|
103
|
+
} from "./activity/digest.js";
|
|
104
|
+
|
|
83
105
|
// Share staging: a deployment can sweep expired staging dirs at boot; the
|
|
84
106
|
// intake route also sweeps opportunistically on every share.
|
|
85
107
|
export { pruneShareStaging, shareStagingRoot } from "./share/staging.js";
|