@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,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The activity runtime: everything the activity record needs to live inside
|
|
3
|
+
* one app instance — store, live stream, notifier, push sender, the boot
|
|
4
|
+
* orphan sweep, and the always-on lifecycle tick (stale sweep, notification
|
|
5
|
+
* detection, push delivery, hourly prune). `createApp` constructs one and
|
|
6
|
+
* wires its pieces into the host, routes, and bridge; `close()` releases the
|
|
7
|
+
* interval and subscriptions.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here may block boot or fail the observed work: the sweep and every
|
|
10
|
+
* tick pass log and continue on failure.
|
|
11
|
+
*/
|
|
12
|
+
import type { Database } from "bun:sqlite";
|
|
13
|
+
import type { Logger } from "@opentelemetry/api-logs";
|
|
14
|
+
import type { ActivityQuery, ActivityQueryResult } from "@schlessera/brain-ui-sdk/server";
|
|
15
|
+
|
|
16
|
+
import { createActivityStore, type ActivityStore } from "./store.js";
|
|
17
|
+
import { createActivityStream, type ActivityStream } from "./stream.js";
|
|
18
|
+
import { createActivityNotifier, type ActivityNotifier } from "./notify.js";
|
|
19
|
+
import { createPushSender, type PushSender } from "./push-sender.js";
|
|
20
|
+
import { digestRetentionFloor } from "./digest.js";
|
|
21
|
+
import { runActivityQuery } from "./query.js";
|
|
22
|
+
|
|
23
|
+
// Activity lifecycle cadence. The stale threshold must comfortably exceed
|
|
24
|
+
// the cron wrapper's heartbeat interval (~30s) so a live writer is never
|
|
25
|
+
// swept; the tick doubles as the always-on low-frequency sweep the
|
|
26
|
+
// notification layer extends.
|
|
27
|
+
const ACTIVITY_TICK_MS = 20_000;
|
|
28
|
+
const ACTIVITY_STALE_AFTER_MS = 2 * 60 * 1000;
|
|
29
|
+
const ACTIVITY_PRUNE_INTERVAL_MS = 60 * 60 * 1000;
|
|
30
|
+
const ACTIVITY_HARD_CEILING_MS = 90 * 24 * 60 * 60 * 1000;
|
|
31
|
+
/** Acknowledged notification intents are kept this long, then deleted. */
|
|
32
|
+
const INTENT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
33
|
+
|
|
34
|
+
export interface ActivityRuntime {
|
|
35
|
+
store: ActivityStore;
|
|
36
|
+
stream: ActivityStream;
|
|
37
|
+
notifier: ActivityNotifier;
|
|
38
|
+
pushSender: PushSender;
|
|
39
|
+
/** The agent-facing read seam (bridge.queryActivity). */
|
|
40
|
+
query(query: ActivityQuery): ActivityQueryResult;
|
|
41
|
+
close(): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createActivityRuntime(
|
|
45
|
+
db: Database,
|
|
46
|
+
deps: { log: Logger }
|
|
47
|
+
): ActivityRuntime {
|
|
48
|
+
const { log } = deps;
|
|
49
|
+
const store = createActivityStore(db);
|
|
50
|
+
const stream = createActivityStream(store, log);
|
|
51
|
+
|
|
52
|
+
// Boot sweep: close this server's orphans from a previous life (interrupted).
|
|
53
|
+
try {
|
|
54
|
+
const orphans = store.sweepOwnOrphans();
|
|
55
|
+
if (orphans > 0) {
|
|
56
|
+
log.emit({
|
|
57
|
+
severityText: "WARN",
|
|
58
|
+
body: "closed orphaned activity spans from a previous process",
|
|
59
|
+
attributes: { count: orphans },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
} catch (err) {
|
|
63
|
+
log.emit({
|
|
64
|
+
severityText: "WARN",
|
|
65
|
+
body: "boot activity sweep failed",
|
|
66
|
+
attributes: { error: err instanceof Error ? err.message : String(err) },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const notifier = createActivityNotifier({
|
|
71
|
+
db,
|
|
72
|
+
store,
|
|
73
|
+
isWatched: (scope) => stream.isWatched(scope),
|
|
74
|
+
log,
|
|
75
|
+
});
|
|
76
|
+
const pushSender = createPushSender(db, { log });
|
|
77
|
+
|
|
78
|
+
let delivering = false;
|
|
79
|
+
let lastPrune = 0;
|
|
80
|
+
const tick = setInterval(() => {
|
|
81
|
+
try {
|
|
82
|
+
const stale = store.sweepStale(ACTIVITY_STALE_AFTER_MS);
|
|
83
|
+
if (stale > 0) {
|
|
84
|
+
log.emit({
|
|
85
|
+
severityText: "WARN",
|
|
86
|
+
body: "closed stale activity spans (writer went silent)",
|
|
87
|
+
attributes: { count: stale },
|
|
88
|
+
});
|
|
89
|
+
stream.pump();
|
|
90
|
+
}
|
|
91
|
+
notifier.tick();
|
|
92
|
+
// Async delivery, reentrancy-guarded: a slow push service must not
|
|
93
|
+
// stack passes; the next tick simply retries what stayed pending.
|
|
94
|
+
if (!delivering) {
|
|
95
|
+
delivering = true;
|
|
96
|
+
void pushSender
|
|
97
|
+
.deliverPending(notifier)
|
|
98
|
+
.catch((err) =>
|
|
99
|
+
log.emit({
|
|
100
|
+
severityText: "WARN",
|
|
101
|
+
body: "push delivery pass failed",
|
|
102
|
+
attributes: { error: err instanceof Error ? err.message : String(err) },
|
|
103
|
+
})
|
|
104
|
+
)
|
|
105
|
+
.finally(() => {
|
|
106
|
+
delivering = false;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
if (Date.now() - lastPrune > ACTIVITY_PRUNE_INTERVAL_MS) {
|
|
110
|
+
lastPrune = Date.now();
|
|
111
|
+
store.prune({
|
|
112
|
+
// Full detail survives until the digest has covered it; the hard
|
|
113
|
+
// ceiling bounds growth even if the digest job silently dies.
|
|
114
|
+
digestFloorAt: digestRetentionFloor(db),
|
|
115
|
+
hardCeilingMs: ACTIVITY_HARD_CEILING_MS,
|
|
116
|
+
});
|
|
117
|
+
notifier.pruneAcknowledged(INTENT_RETENTION_MS);
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
log.emit({
|
|
121
|
+
severityText: "WARN",
|
|
122
|
+
body: "activity tick failed",
|
|
123
|
+
attributes: { error: err instanceof Error ? err.message : String(err) },
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}, ACTIVITY_TICK_MS);
|
|
127
|
+
// A Bun interval would otherwise keep a closed test app's process alive.
|
|
128
|
+
if (typeof tick === "object" && "unref" in tick) tick.unref();
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
store,
|
|
132
|
+
stream,
|
|
133
|
+
notifier,
|
|
134
|
+
pushSender,
|
|
135
|
+
query: (query) => runActivityQuery(db, store, query, notifier),
|
|
136
|
+
close() {
|
|
137
|
+
clearInterval(tick);
|
|
138
|
+
stream.close();
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The span-sink JSONL contract: how an agent-running cron step reports its
|
|
3
|
+
* activity to the wrapper that records the run.
|
|
4
|
+
*
|
|
5
|
+
* The wrapper (brain-ui's cron-run.ts) exports a file path in
|
|
6
|
+
* `BRAIN_ACTIVITY_SPAN_SINK`; the child process appends one JSON object per
|
|
7
|
+
* line and never reads the file back. The wrapper ingests incrementally
|
|
8
|
+
* (liveness) and once at exit (completeness), attaching everything under the
|
|
9
|
+
* run's root span. No core call site emits this yet — the contract ships
|
|
10
|
+
* first, fixture-verified, and producers arrive when core grows an agent
|
|
11
|
+
* loop (see the plan's Deferred to Follow-Up Work).
|
|
12
|
+
*
|
|
13
|
+
* Line shapes (unknown fields ignored, malformed lines skipped and counted):
|
|
14
|
+
*
|
|
15
|
+
* {"type":"span_start","spanId":"s1","name":"execute_tool Read","ts":123,
|
|
16
|
+
* "parentSpanId":"s0"?, "kind":"tool"?}
|
|
17
|
+
* {"type":"span_end","spanId":"s1","outcome":"success","ts":124,
|
|
18
|
+
* "reason":"..."?, "usage":{"inputTokens":1,...}?}
|
|
19
|
+
* {"type":"event","spanId":"s1","eventType":"text","payload":...,"ts":125}
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync } from "fs";
|
|
22
|
+
|
|
23
|
+
import { SPAN_OUTCOMES, type ActivityStore, type SpanOutcome, type SpanUsage } from "./store.js";
|
|
24
|
+
|
|
25
|
+
export interface SpanSinkContext {
|
|
26
|
+
/** The run every ingested span belongs to. */
|
|
27
|
+
runId: string;
|
|
28
|
+
/** Parent for lines that name none — the cron root span. */
|
|
29
|
+
rootSpanId: string;
|
|
30
|
+
jobName?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SpanSinkResult {
|
|
34
|
+
/** Byte offset consumed so far — pass back on the next incremental call. */
|
|
35
|
+
offset: number;
|
|
36
|
+
ingested: number;
|
|
37
|
+
skipped: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Ingest new complete lines from a span-sink file. Incremental: pass the
|
|
42
|
+
* previous call's `offset`; a partial trailing line is left for next time.
|
|
43
|
+
* A missing file is an empty result, not an error — the child may simply
|
|
44
|
+
* never have emitted.
|
|
45
|
+
*/
|
|
46
|
+
export function ingestSpanSink(
|
|
47
|
+
store: ActivityStore,
|
|
48
|
+
path: string,
|
|
49
|
+
context: SpanSinkContext,
|
|
50
|
+
offset = 0
|
|
51
|
+
): SpanSinkResult {
|
|
52
|
+
let text: string;
|
|
53
|
+
try {
|
|
54
|
+
text = readFileSync(path, "utf-8");
|
|
55
|
+
} catch {
|
|
56
|
+
return { offset, ingested: 0, skipped: 0 };
|
|
57
|
+
}
|
|
58
|
+
if (text.length <= offset) return { offset, ingested: 0, skipped: 0 };
|
|
59
|
+
|
|
60
|
+
const chunk = text.slice(offset);
|
|
61
|
+
const lastNewline = chunk.lastIndexOf("\n");
|
|
62
|
+
if (lastNewline === -1) return { offset, ingested: 0, skipped: 0 };
|
|
63
|
+
const complete = chunk.slice(0, lastNewline);
|
|
64
|
+
const consumed = offset + lastNewline + 1;
|
|
65
|
+
|
|
66
|
+
let ingested = 0;
|
|
67
|
+
let skipped = 0;
|
|
68
|
+
for (const line of complete.split("\n")) {
|
|
69
|
+
if (!line.trim()) continue;
|
|
70
|
+
if (applyLine(store, line, context)) ingested++;
|
|
71
|
+
else skipped++;
|
|
72
|
+
}
|
|
73
|
+
return { offset: consumed, ingested, skipped };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function applyLine(store: ActivityStore, line: string, context: SpanSinkContext): boolean {
|
|
77
|
+
let parsed: Record<string, unknown>;
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(line);
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
if (!parsed || typeof parsed !== "object") return false;
|
|
84
|
+
const spanId = str(parsed.spanId);
|
|
85
|
+
if (!spanId) return false;
|
|
86
|
+
// Sink span ids are namespaced by run so two runs' children can never
|
|
87
|
+
// collide in the shared store.
|
|
88
|
+
const scopedId = `${context.runId}:${spanId}`;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
switch (parsed.type) {
|
|
92
|
+
case "span_start": {
|
|
93
|
+
const name = str(parsed.name);
|
|
94
|
+
if (!name) return false;
|
|
95
|
+
const parent = str(parsed.parentSpanId);
|
|
96
|
+
store.startSpan({
|
|
97
|
+
spanId: scopedId,
|
|
98
|
+
runId: context.runId,
|
|
99
|
+
parentSpanId: parent ? `${context.runId}:${parent}` : context.rootSpanId,
|
|
100
|
+
name,
|
|
101
|
+
kind: parsed.kind === "subagent" ? "subagent" : "tool",
|
|
102
|
+
origin: "cron",
|
|
103
|
+
jobName: context.jobName,
|
|
104
|
+
startedAt: num(parsed.ts),
|
|
105
|
+
});
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
case "span_end": {
|
|
109
|
+
const outcome = str(parsed.outcome);
|
|
110
|
+
if (!outcome || !(SPAN_OUTCOMES as readonly string[]).includes(outcome)) return false;
|
|
111
|
+
return store.endSpan(scopedId, {
|
|
112
|
+
outcome: outcome as SpanOutcome,
|
|
113
|
+
reason: str(parsed.reason),
|
|
114
|
+
endedAt: num(parsed.ts),
|
|
115
|
+
usage: usageOf(parsed.usage),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
case "event": {
|
|
119
|
+
const eventType = str(parsed.eventType);
|
|
120
|
+
if (!eventType) return false;
|
|
121
|
+
return store.appendEvent(scopedId, eventType, parsed.payload, num(parsed.ts)) !== null;
|
|
122
|
+
}
|
|
123
|
+
default:
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// A duplicate span_start (replayed line) or similar must not abort the
|
|
128
|
+
// whole ingest.
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function str(v: unknown): string | undefined {
|
|
134
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function num(v: unknown): number | undefined {
|
|
138
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function usageOf(v: unknown): SpanUsage | undefined {
|
|
142
|
+
if (!v || typeof v !== "object") return undefined;
|
|
143
|
+
const u = v as Record<string, unknown>;
|
|
144
|
+
return {
|
|
145
|
+
inputTokens: num(u.inputTokens),
|
|
146
|
+
outputTokens: num(u.outputTokens),
|
|
147
|
+
cacheReadTokens: num(u.cacheReadTokens),
|
|
148
|
+
cacheCreationTokens: num(u.cacheCreationTokens),
|
|
149
|
+
costUsd: num(u.costUsd),
|
|
150
|
+
model: str(u.model),
|
|
151
|
+
};
|
|
152
|
+
}
|