@schlessera/brain-ui-server 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/README.md +4 -0
  2. package/dist/agent/backend.d.ts +31 -10
  3. package/dist/agent/backend.d.ts.map +1 -1
  4. package/dist/agent/backend.js +92 -28
  5. package/dist/agent/backend.js.map +1 -1
  6. package/dist/app.d.ts +9 -0
  7. package/dist/app.d.ts.map +1 -1
  8. package/dist/app.js +80 -18
  9. package/dist/app.js.map +1 -1
  10. package/dist/config/env-core.d.ts +73 -0
  11. package/dist/config/env-core.d.ts.map +1 -0
  12. package/dist/config/env-core.js +62 -0
  13. package/dist/config/env-core.js.map +1 -0
  14. package/dist/config/env.d.ts +27 -5
  15. package/dist/config/env.d.ts.map +1 -1
  16. package/dist/config/env.js +90 -10
  17. package/dist/config/env.js.map +1 -1
  18. package/dist/cron/scheduler.d.ts +20 -0
  19. package/dist/cron/scheduler.d.ts.map +1 -1
  20. package/dist/cron/scheduler.js +43 -8
  21. package/dist/cron/scheduler.js.map +1 -1
  22. package/dist/db/client.d.ts +10 -1
  23. package/dist/db/client.d.ts.map +1 -1
  24. package/dist/db/client.js +5 -14
  25. package/dist/db/client.js.map +1 -1
  26. package/dist/db/settings.d.ts +3 -2
  27. package/dist/db/settings.d.ts.map +1 -1
  28. package/dist/db/settings.js +8 -4
  29. package/dist/db/settings.js.map +1 -1
  30. package/dist/index.d.ts +6 -3
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +10 -2
  33. package/dist/index.js.map +1 -1
  34. package/dist/middleware/auth.d.ts +8 -2
  35. package/dist/middleware/auth.d.ts.map +1 -1
  36. package/dist/middleware/auth.js +74 -10
  37. package/dist/middleware/auth.js.map +1 -1
  38. package/dist/middleware/passkeys.d.ts +10 -0
  39. package/dist/middleware/passkeys.d.ts.map +1 -1
  40. package/dist/middleware/passkeys.js +19 -3
  41. package/dist/middleware/passkeys.js.map +1 -1
  42. package/dist/observability/index.d.ts +73 -0
  43. package/dist/observability/index.d.ts.map +1 -0
  44. package/dist/observability/index.js +74 -0
  45. package/dist/observability/index.js.map +1 -0
  46. package/dist/observability/loggers.d.ts +62 -0
  47. package/dist/observability/loggers.d.ts.map +1 -0
  48. package/dist/observability/loggers.js +109 -0
  49. package/dist/observability/loggers.js.map +1 -0
  50. package/dist/observability/meter.d.ts +39 -0
  51. package/dist/observability/meter.d.ts.map +1 -0
  52. package/dist/observability/meter.js +95 -0
  53. package/dist/observability/meter.js.map +1 -0
  54. package/dist/observability/types.d.ts +67 -0
  55. package/dist/observability/types.d.ts.map +1 -0
  56. package/dist/observability/types.js +44 -0
  57. package/dist/observability/types.js.map +1 -0
  58. package/dist/routes/brain.d.ts.map +1 -1
  59. package/dist/routes/brain.js +9 -1
  60. package/dist/routes/brain.js.map +1 -1
  61. package/dist/routes/files.d.ts +2 -0
  62. package/dist/routes/files.d.ts.map +1 -1
  63. package/dist/routes/files.js +7 -7
  64. package/dist/routes/files.js.map +1 -1
  65. package/dist/routes/graph.d.ts +2 -0
  66. package/dist/routes/graph.d.ts.map +1 -1
  67. package/dist/routes/graph.js +7 -7
  68. package/dist/routes/graph.js.map +1 -1
  69. package/dist/routes/health.d.ts +7 -14
  70. package/dist/routes/health.d.ts.map +1 -1
  71. package/dist/routes/health.js +25 -6
  72. package/dist/routes/health.js.map +1 -1
  73. package/dist/routes/models.d.ts +3 -0
  74. package/dist/routes/models.d.ts.map +1 -1
  75. package/dist/routes/models.js +5 -1
  76. package/dist/routes/models.js.map +1 -1
  77. package/dist/routes/render.d.ts +2 -1
  78. package/dist/routes/render.d.ts.map +1 -1
  79. package/dist/routes/render.js +2 -2
  80. package/dist/routes/render.js.map +1 -1
  81. package/dist/routes/share.d.ts +3 -0
  82. package/dist/routes/share.d.ts.map +1 -1
  83. package/dist/routes/share.js +5 -5
  84. package/dist/routes/share.js.map +1 -1
  85. package/dist/share/staging.d.ts +3 -2
  86. package/dist/share/staging.d.ts.map +1 -1
  87. package/dist/share/staging.js +12 -4
  88. package/dist/share/staging.js.map +1 -1
  89. package/dist/voice/keyterm-builder.d.ts +4 -1
  90. package/dist/voice/keyterm-builder.d.ts.map +1 -1
  91. package/dist/voice/keyterm-builder.js +15 -8
  92. package/dist/voice/keyterm-builder.js.map +1 -1
  93. package/dist/ws/bridge.d.ts.map +1 -1
  94. package/dist/ws/bridge.js +23 -3
  95. package/dist/ws/bridge.js.map +1 -1
  96. package/dist/ws/clients.d.ts +10 -3
  97. package/dist/ws/clients.d.ts.map +1 -1
  98. package/dist/ws/clients.js +16 -4
  99. package/dist/ws/clients.js.map +1 -1
  100. package/dist/ws/connection.d.ts +15 -0
  101. package/dist/ws/connection.d.ts.map +1 -1
  102. package/dist/ws/connection.js +66 -10
  103. package/dist/ws/connection.js.map +1 -1
  104. package/dist/ws/dispatch.d.ts +22 -1
  105. package/dist/ws/dispatch.d.ts.map +1 -1
  106. package/dist/ws/dispatch.js +36 -16
  107. package/dist/ws/dispatch.js.map +1 -1
  108. package/dist/ws/host.d.ts +73 -0
  109. package/dist/ws/host.d.ts.map +1 -1
  110. package/dist/ws/host.js +127 -1
  111. package/dist/ws/host.js.map +1 -1
  112. package/dist/ws/rate-limit.d.ts +58 -0
  113. package/dist/ws/rate-limit.d.ts.map +1 -0
  114. package/dist/ws/rate-limit.js +62 -0
  115. package/dist/ws/rate-limit.js.map +1 -0
  116. package/dist/ws/run-session.d.ts +3 -0
  117. package/dist/ws/run-session.d.ts.map +1 -1
  118. package/dist/ws/run-session.js +49 -9
  119. package/dist/ws/run-session.js.map +1 -1
  120. package/dist/ws/session-catalog.d.ts +2 -1
  121. package/dist/ws/session-catalog.d.ts.map +1 -1
  122. package/dist/ws/session-catalog.js +20 -3
  123. package/dist/ws/session-catalog.js.map +1 -1
  124. package/dist/ws/turns.d.ts +21 -0
  125. package/dist/ws/turns.d.ts.map +1 -1
  126. package/dist/ws/turns.js +11 -1
  127. package/dist/ws/turns.js.map +1 -1
  128. package/package.json +9 -6
  129. package/src/agent/backend.ts +131 -35
  130. package/src/app.ts +97 -18
  131. package/src/config/env-core.ts +93 -0
  132. package/src/config/env.ts +116 -15
  133. package/src/cron/scheduler.ts +61 -16
  134. package/src/db/client.ts +16 -5
  135. package/src/db/settings.ts +9 -4
  136. package/src/index.ts +43 -2
  137. package/src/middleware/auth.ts +80 -15
  138. package/src/middleware/passkeys.ts +29 -5
  139. package/src/observability/index.ts +150 -0
  140. package/src/observability/loggers.ts +174 -0
  141. package/src/observability/meter.ts +190 -0
  142. package/src/observability/types.ts +101 -0
  143. package/src/routes/brain.ts +10 -1
  144. package/src/routes/files.ts +9 -8
  145. package/src/routes/graph.ts +9 -8
  146. package/src/routes/health.ts +29 -6
  147. package/src/routes/models.ts +8 -5
  148. package/src/routes/render.ts +3 -2
  149. package/src/routes/share.ts +17 -10
  150. package/src/share/staging.ts +18 -4
  151. package/src/voice/keyterm-builder.ts +19 -10
  152. package/src/ws/bridge.ts +22 -6
  153. package/src/ws/clients.ts +24 -6
  154. package/src/ws/connection.ts +73 -13
  155. package/src/ws/dispatch.ts +47 -16
  156. package/src/ws/host.ts +164 -1
  157. package/src/ws/rate-limit.ts +86 -0
  158. package/src/ws/run-session.ts +57 -14
  159. package/src/ws/session-catalog.ts +21 -3
  160. package/src/ws/turns.ts +27 -3
@@ -5,7 +5,13 @@ import { makeBridge, emitTurnError } from "./bridge.js";
5
5
  import { resolveTurnTarget } from "./routing.js";
6
6
  import type { QueuedFollowUp, RunningTurn } from "./turns.js";
7
7
  import { queuedBytes, queuedFollowUpBytes } from "./turns.js";
8
- import { MAX_SESSION_QUEUE, QUEUE_MAX_BYTES, QUEUE_WARN_BYTES, type WsHost } from "./host.js";
8
+ import {
9
+ MAX_SESSION_QUEUE,
10
+ QUEUE_MAX_BYTES,
11
+ QUEUE_WARN_BYTES,
12
+ turnLogAttributes,
13
+ type WsHost,
14
+ } from "./host.js";
9
15
 
10
16
  /**
11
17
  * Run one session slot: the initial turn, then any queued follow-up turns in
@@ -20,6 +26,8 @@ export async function runSession(
20
26
  attachments: ChatImageAttachment[];
21
27
  providerId?: string;
22
28
  client?: ClientEnvironment;
29
+ /** Client correlation id for a new conversation; echoed on session_info. */
30
+ draftId?: string;
23
31
  }
24
32
  ): Promise<void> {
25
33
  const { coordinator } = host;
@@ -28,10 +36,14 @@ export async function runSession(
28
36
  try {
29
37
  target = await resolveTurnTarget(host.registry, host.catalog, initial.sessionId, initial.providerId);
30
38
  } catch (err) {
39
+ const message = err instanceof Error ? err.message : String(err);
40
+ // No turn exists yet — routing failed before one was minted — so the
41
+ // report carries only the session identity the client asked for.
42
+ host.reportTurnFailed("BACKEND_ERROR", { sessionId: initial.sessionId }, message);
31
43
  host.sendToClients({
32
44
  type: "error",
33
45
  code: "BACKEND_ERROR",
34
- message: err instanceof Error ? err.message : String(err),
46
+ message,
35
47
  ...(initial.sessionId ? { sessionId: initial.sessionId } : {}),
36
48
  });
37
49
  return;
@@ -41,10 +53,11 @@ export async function runSession(
41
53
 
42
54
  const { backend, profileId: initialProfileId } = target;
43
55
  if (target.droppedPin) {
44
- console.warn(
45
- `[agent] session ${initial.sessionId} pinned profile "${target.droppedPin}" ` +
46
- `is unavailable; running on the default profile.`
47
- );
56
+ host.log.emit({
57
+ severityText: "WARN",
58
+ body: "pinned profile unavailable; running on the default profile",
59
+ attributes: { "session.id": initial.sessionId, profile: target.droppedPin },
60
+ });
48
61
  host.sendToClients({
49
62
  type: "status",
50
63
  status: "thinking",
@@ -55,12 +68,15 @@ export async function runSession(
55
68
  const turn: RunningTurn = {
56
69
  sessionId: initial.sessionId ?? null,
57
70
  turnId: crypto.randomUUID(),
71
+ // Only meaningful when the client had no session id to send.
72
+ draftId: initial.sessionId ? null : (initial.draftId ?? null),
58
73
  providerId: initialProfileId ?? null,
59
74
  backend,
60
75
  abortController: new AbortController(),
61
76
  timeoutHandle: setTimeout(() => {}, 0),
62
77
  queue: [],
63
78
  cancelled: false,
79
+ lastResult: null,
64
80
  };
65
81
  clearTimeout(turn.timeoutHandle);
66
82
  coordinator.running.add(turn);
@@ -82,7 +98,13 @@ export async function runSession(
82
98
  const abortController = new AbortController();
83
99
  turn.abortController = abortController;
84
100
  const timeoutHandle = setTimeout(() => {
85
- console.log("[agent] Turn timed out after", host.turnTimeoutMs, "ms");
101
+ host.log.emit({
102
+ severityText: "WARN",
103
+ body: "turn timed out",
104
+ // Read from the live turn: session_info may have named the session
105
+ // after this timer was armed.
106
+ attributes: { ...turnLogAttributes(turn), "timeout.ms": host.turnTimeoutMs },
107
+ });
86
108
  abortController.abort();
87
109
  // Reject any pending interactive request for this turn too. A bridge
88
110
  // that awaits askUser/approval without racing the abort signal would
@@ -93,6 +115,8 @@ export async function runSession(
93
115
  turn.timeoutHandle = timeoutHandle;
94
116
 
95
117
  const bridge = makeBridge(host, turn, text, backend.id);
118
+ const startedAt = Date.now();
119
+ host.reportTurnStarted(turn);
96
120
  try {
97
121
  await backend.startTurn({
98
122
  prompt: text,
@@ -103,6 +127,14 @@ export async function runSession(
103
127
  bridge,
104
128
  ...(client ? { client } : {}),
105
129
  });
130
+ // A resolved startTurn is not a successful turn: backends resolve for
131
+ // runtime failures and report them on the terminal result frame, which
132
+ // the bridge recorded on the turn.
133
+ if (turn.lastResult === "error") {
134
+ host.reportTurnFailed("BACKEND_RESULT_ERROR", turn);
135
+ } else {
136
+ host.reportTurnCompleted(turn, Date.now() - startedAt);
137
+ }
106
138
  } catch (err) {
107
139
  emitTurnError(host, turn, err);
108
140
  } finally {
@@ -118,8 +150,10 @@ export async function runSession(
118
150
 
119
151
  if (!turn.cancelled && turn.queue.length > 0) {
120
152
  next = turn.queue.shift()!;
121
- // A queued follow-up is its own turn — give it a fresh identity.
153
+ // A queued follow-up is its own turn — give it a fresh identity, and
154
+ // its own terminal disposition.
122
155
  turn.turnId = crypto.randomUUID();
156
+ turn.lastResult = null;
123
157
  }
124
158
  }
125
159
  } finally {
@@ -144,9 +178,10 @@ export async function handleChatMessage(
144
178
  attachments: ChatImageAttachment[];
145
179
  providerId?: string;
146
180
  client?: ClientEnvironment;
181
+ draftId?: string;
147
182
  }
148
183
  ): Promise<void> {
149
- const { text, attachments, sessionId, providerId: requestedProviderId, client } = msg;
184
+ const { text, attachments, sessionId, providerId: requestedProviderId, client, draftId } = msg;
150
185
  const { coordinator } = host;
151
186
  const runningTurn = sessionId ? coordinator.bySession.get(sessionId) : undefined;
152
187
 
@@ -158,11 +193,10 @@ export async function handleChatMessage(
158
193
  // device snapshot is deliberately not forwarded: a follow-up joins a
159
194
  // turn whose system prompt was already built and cannot be revised.
160
195
  backend.followUp({ sessionId, prompt: text, attachments }).catch((err) => {
196
+ const message = err instanceof Error ? err.message : String(err);
197
+ host.reportTurnFailed("FOLLOWUP_FAILED", runningTurn, message);
161
198
  host.sendToClients(
162
- withTurnScope(
163
- { type: "error", code: "FOLLOWUP_FAILED", message: err instanceof Error ? err.message : String(err) },
164
- runningTurn
165
- )
199
+ withTurnScope({ type: "error", code: "FOLLOWUP_FAILED", message }, runningTurn)
166
200
  );
167
201
  });
168
202
  } else {
@@ -204,7 +238,15 @@ export async function handleChatMessage(
204
238
  ? `Queue is holding ${formatMb(total)} across ${runningTurn.queue.length} messages (limit ${formatMb(QUEUE_MAX_BYTES)}).`
205
239
  : undefined;
206
240
  if (detail) {
207
- console.warn(`[ws] session ${sessionId} queue at ${formatMb(total)} across ${runningTurn.queue.length} messages`);
241
+ host.log.emit({
242
+ severityText: "WARN",
243
+ body: "session follow-up queue is heavy",
244
+ attributes: {
245
+ "session.id": sessionId,
246
+ size: formatMb(total),
247
+ queued: runningTurn.queue.length,
248
+ },
249
+ });
208
250
  }
209
251
  host.sendToClients(
210
252
  withSessionId({ type: "status", status: "queued", ...(detail ? { detail } : {}) }, sessionId)
@@ -234,5 +276,6 @@ export async function handleChatMessage(
234
276
  attachments,
235
277
  providerId: requestedProviderId,
236
278
  ...(client ? { client } : {}),
279
+ ...(draftId ? { draftId } : {}),
237
280
  });
238
281
  }
@@ -1,4 +1,5 @@
1
1
  import type { Database } from "bun:sqlite";
2
+ import type { Logger } from "@opentelemetry/api-logs";
2
3
  import type { ServerResultMessage } from "@schlessera/brain-ui-sdk/protocol";
3
4
 
4
5
  /**
@@ -42,7 +43,20 @@ const UPSERT_SESSION_SQL = `INSERT INTO sessions (id, title, created_at, last_ac
42
43
  backend_id = COALESCE(excluded.backend_id, backend_id)`;
43
44
 
44
45
  /** SQLite-backed catalog over the app's own database (injected by createApp). */
45
- export function createSessionCatalog(db: () => Database): SessionCatalog {
46
+ export function createSessionCatalog(db: () => Database, log?: Logger): SessionCatalog {
47
+ // Persistence stays non-throwing — losing one accounting row must not kill
48
+ // the turn — but never silent: a full disk or locked database would
49
+ // otherwise drop every session record with nothing anywhere.
50
+ const reportWriteFailure = (sessionId: string, err: unknown): void => {
51
+ log?.emit({
52
+ severityText: "WARN",
53
+ body: "session persistence failed",
54
+ attributes: {
55
+ "session.id": sessionId,
56
+ error: err instanceof Error ? err.message : String(err),
57
+ },
58
+ });
59
+ };
46
60
  return {
47
61
  getStoredProviderId(sessionId) {
48
62
  const row = db()
@@ -72,7 +86,9 @@ export function createSessionCatalog(db: () => Database): SessionCatalog {
72
86
  providerId,
73
87
  backendId
74
88
  );
75
- } catch {}
89
+ } catch (err) {
90
+ reportWriteFailure(sessionId, err);
91
+ }
76
92
  },
77
93
 
78
94
  persistSession(msg, promptText, providerId, backendId) {
@@ -90,7 +106,9 @@ export function createSessionCatalog(db: () => Database): SessionCatalog {
90
106
  providerId,
91
107
  backendId
92
108
  );
93
- } catch {}
109
+ } catch (err) {
110
+ reportWriteFailure(msg.sessionId, err);
111
+ }
94
112
  },
95
113
  };
96
114
  }
package/src/ws/turns.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Logger } from "@opentelemetry/api-logs";
1
2
  import type {
2
3
  AgentBackend,
3
4
  PermissionDecision,
@@ -54,6 +55,20 @@ export interface RunningTurn {
54
55
  timeoutHandle: ReturnType<typeof setTimeout>;
55
56
  queue: QueuedFollowUp[];
56
57
  cancelled: boolean;
58
+ /**
59
+ * Terminal disposition of the CURRENT turn's `result` frame, when one has
60
+ * streamed. Backends RESOLVE startTurn for runtime failures (the failure
61
+ * rides the result frame as outcome "error"), so completion accounting must
62
+ * read this — a resolved startTurn alone does not mean the turn succeeded.
63
+ * Reset when a queued follow-up becomes the next turn.
64
+ */
65
+ lastResult: "success" | "error" | "cancelled" | null;
66
+ /**
67
+ * Correlation id the client minted for this NEW conversation, echoed back on
68
+ * `session_info` so the client can tell its own turn's identity from a
69
+ * background turn's. Null on a resumed session or a client that sent none.
70
+ */
71
+ draftId: string | null;
57
72
  }
58
73
 
59
74
  // Pending interactive requests are tagged with their turn so a per-session
@@ -93,6 +108,13 @@ export interface PendingMask {
93
108
  * explicit object with a defined lifecycle.
94
109
  */
95
110
  export class TurnCoordinator {
111
+ /**
112
+ * Where collisions are reported. Assigned by the owning WsHost so the
113
+ * coordinator reports through the same consumer as everything else; absent
114
+ * (a bare coordinator in a test) means silence.
115
+ */
116
+ log?: Logger;
117
+
96
118
  readonly running = new Set<RunningTurn>();
97
119
  readonly bySession = new Map<string, RunningTurn>();
98
120
  startingSessions = 0;
@@ -173,9 +195,11 @@ export class TurnCoordinator {
173
195
  ): boolean {
174
196
  const existing = map.get(id);
175
197
  if (existing && existing.turn !== turn) {
176
- console.error(
177
- `[ws] interactive id collision: "${id}" is already pending for another turn`
178
- );
198
+ this.log?.emit({
199
+ severityText: "ERROR",
200
+ body: "interactive id collision: already pending for another turn",
201
+ attributes: { "request.id": id },
202
+ });
179
203
  return true;
180
204
  }
181
205
  return false;