@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
@@ -1,3 +1,4 @@
1
+ import type { Logger } from "@opentelemetry/api-logs";
1
2
  import { createRequire } from "module";
2
3
  import type { AgentConfig } from "../config/env.js";
3
4
  import type { ProviderInfo } from "@schlessera/brain-ui-sdk";
@@ -13,7 +14,7 @@ import type {
13
14
  * tests install fakes by constructing a registry, not by mutating a module.
14
15
  *
15
16
  * BOTH backend packages are optional peers loaded lazily (the same
16
- * `createRequire` path): a deployment installs the one its AGENT_BACKEND
17
+ * dynamic-import path): a deployment installs the one its AGENT_BACKEND
17
18
  * names, and the other never has to be present — at runtime AND at
18
19
  * type-check time (see the structural mirrors below).
19
20
  *
@@ -47,6 +48,33 @@ import type {
47
48
  * incompatible drift slide.
48
49
  */
49
50
 
51
+ /**
52
+ * Mirror of the backends' `BackendLogFn` — the minimal log seam a backend
53
+ * accepts. A callback rather than a Logger so the backend packages carry no
54
+ * telemetry dependency; {@link toBackendLog} adapts the registry's Logger.
55
+ */
56
+ export type BackendLogFn = (
57
+ level: "debug" | "info" | "warn" | "error",
58
+ message: string,
59
+ attrs?: Record<string, string | number | boolean>
60
+ ) => void;
61
+
62
+ const LEVEL_SEVERITY = {
63
+ debug: "DEBUG",
64
+ info: "INFO",
65
+ warn: "WARN",
66
+ error: "ERROR",
67
+ } as const;
68
+
69
+ function toBackendLog(log: Logger): BackendLogFn {
70
+ return (level, message, attrs) =>
71
+ log.emit({
72
+ severityText: LEVEL_SEVERITY[level],
73
+ body: message,
74
+ ...(attrs ? { attributes: attrs } : {}),
75
+ });
76
+ }
77
+
50
78
  /** Mirror of the Claude package's `InferenceProfileInput` (declarative shape). */
51
79
  export interface ClaudeProfileInput {
52
80
  id: string;
@@ -94,6 +122,8 @@ export interface ClaudeBackendModule {
94
122
  brainPath: string;
95
123
  claudeCodePath?: string;
96
124
  profiles?: ClaudeProfile[] | (() => ClaudeProfile[]);
125
+ confirmBashPatterns?: readonly string[];
126
+ log?: BackendLogFn;
97
127
  }) => AgentBackend;
98
128
  createModelSource: (options: {
99
129
  brainPath: string;
@@ -145,6 +175,12 @@ export interface BackendRegistryOptions {
145
175
  * sessions pinned to it. Defaults to "nothing hidden".
146
176
  */
147
177
  getHiddenModelIds?: () => string[];
178
+ /**
179
+ * Where the registry and its backends report. Adapted to the backends'
180
+ * minimal callback ({@link BackendLogFn}) before crossing the package
181
+ * boundary. Absent means silence.
182
+ */
183
+ log?: Logger;
148
184
  }
149
185
 
150
186
  export interface BackendRegistry {
@@ -257,6 +293,59 @@ function missingBackendError(primary: "claude" | "pi"): Error {
257
293
  * The `resolve` parameter exists for tests (simulating an absent package);
258
294
  * production callers pass nothing.
259
295
  */
296
+ /** The specifier an ERR_MODULE_NOT_FOUND failed on, or null for any other error. */
297
+ function moduleNotFoundSpecifier(error: unknown): string | null {
298
+ if (typeof error !== "object" || error === null) return null;
299
+ const { code, specifier, message } = error as {
300
+ code?: unknown;
301
+ specifier?: unknown;
302
+ message?: unknown;
303
+ };
304
+ if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND") return null;
305
+ // Bun's ResolveMessage (not an Error instance) carries the failing
306
+ // specifier as a property; Node quotes it in the message instead.
307
+ if (typeof specifier === "string") return specifier;
308
+ if (typeof message === "string") {
309
+ const quoted = /Cannot find (?:package|module) '([^']+)'/.exec(message);
310
+ if (quoted) return quoted[1];
311
+ }
312
+ return null;
313
+ }
314
+
315
+ /**
316
+ * Load an optional backend package, mapping ONLY "the backend package itself
317
+ * is not installed" to the actionable install hint. Everything else — the
318
+ * backend missing one of its OWN transitive deps, a syntax error, a throwing
319
+ * top-level — is a different failure whose original error IS the diagnostic,
320
+ * so it is rethrown untouched. The distinction rides on ERR_MODULE_NOT_FOUND
321
+ * naming the specifier it failed on: a transitive miss names the transitive
322
+ * dep, not the backend, and must not read as "backend not installed".
323
+ *
324
+ * `await import()` rather than `createRequire()(...)`: the backend packages
325
+ * are ESM, and a CJS require of them under plain Node dies with
326
+ * ERR_REQUIRE_ESM — which the old blanket catch then reported as "not
327
+ * installed" on a machine where the package was sitting right there.
328
+ *
329
+ * The `importer` parameter exists for tests (simulating absent or broken
330
+ * packages); production callers pass nothing.
331
+ */
332
+ export async function loadBackendModule(
333
+ // "claude" | "pi" spelled out, NOT keyof typeof BACKEND_SPECIFIERS: the
334
+ // keyof form drags the table's literal string types — the backend
335
+ // specifiers — into the emitted .d.ts, which the declaration-surface gate
336
+ // rightly refuses.
337
+ key: "claude" | "pi",
338
+ importer: (specifier: string) => Promise<unknown> = (specifier) => import(specifier)
339
+ ): Promise<unknown> {
340
+ const specifier = BACKEND_SPECIFIERS[key];
341
+ try {
342
+ return await importer(specifier);
343
+ } catch (error) {
344
+ if (moduleNotFoundSpecifier(error) === specifier) throw missingBackendError(key);
345
+ throw error;
346
+ }
347
+ }
348
+
260
349
  export function assertBackendResolvable(
261
350
  agent: AgentConfig,
262
351
  resolve: (specifier: string) => void = (specifier) => {
@@ -278,6 +367,7 @@ export function createBackendRegistry(
278
367
  ): BackendRegistry {
279
368
  const { brainPath, agent } = options;
280
369
  const getHidden = options.getHiddenModelIds ?? (() => []);
370
+ const backendLog = options.log ? toBackendLog(options.log) : undefined;
281
371
 
282
372
  let cachedRegistry: Promise<RegistrySnapshot> | null = null;
283
373
  let modelSource: ClaudeModelSource | null = null;
@@ -391,18 +481,12 @@ export function createBackendRegistry(
391
481
  /**
392
482
  * NEITHER backend package is a hard dependency — a deployment installs the
393
483
  * one its AGENT_BACKEND names (both, if it switches). Loaded lazily through
394
- * the same `createRequire` path so the server still boots without the unused
395
- * one; when the configured backend's package is absent, fail with an
396
- * actionable message.
484
+ * {@link loadBackendModule} so the server still boots without the unused
485
+ * one; only "the package is absent" maps to the actionable install hint,
486
+ * any other load failure surfaces as itself.
397
487
  */
398
- function buildClaudeBackend(): AgentBackend {
399
- const require = createRequire(import.meta.url);
400
- let claude: ClaudeBackendModule;
401
- try {
402
- claude = require("@schlessera/brain-backend-claude");
403
- } catch {
404
- throw missingBackendError("claude");
405
- }
488
+ async function buildClaudeBackend(): Promise<AgentBackend> {
489
+ const claude = (await loadBackendModule("claude")) as ClaudeBackendModule;
406
490
  if (typeof claude.createClaudeBackend !== "function") {
407
491
  throw new Error(
408
492
  '"@schlessera/brain-backend-claude" does not export createClaudeBackend.'
@@ -422,6 +506,12 @@ export function createBackendRegistry(
422
506
  return claude.createClaudeBackend({
423
507
  brainPath,
424
508
  claudeCodePath: agent.claudeCodePath,
509
+ ...(backendLog ? { log: backendLog } : {}),
510
+ // Omitted entirely when unconfigured, so the backend's own defaults
511
+ // apply; an explicit [] passes through and disables confirmation.
512
+ ...(agent.confirmBashPatterns !== null
513
+ ? { confirmBashPatterns: agent.confirmBashPatterns }
514
+ : {}),
425
515
  // A function, not an array: discovery refreshes in the background and the
426
516
  // new roster has to be visible without restarting the process.
427
517
  profiles: () =>
@@ -429,31 +519,30 @@ export function createBackendRegistry(
429
519
  });
430
520
  }
431
521
 
432
- function buildPiBackend(): AgentBackend {
433
- const require = createRequire(import.meta.url);
434
- let mod: { createPiBackend?: (opts: { brainPath: string }) => AgentBackend };
435
- try {
436
- mod = require("@schlessera/brain-backend-pi");
437
- } catch {
438
- throw missingBackendError("pi");
439
- }
522
+ async function buildPiBackend(): Promise<AgentBackend> {
523
+ const mod = (await loadBackendModule("pi")) as {
524
+ createPiBackend?: (opts: { brainPath: string; log?: BackendLogFn }) => AgentBackend;
525
+ };
440
526
  if (typeof mod.createPiBackend !== "function") {
441
527
  throw new Error(
442
528
  '"@schlessera/brain-backend-pi" does not export createPiBackend.'
443
529
  );
444
530
  }
445
- return mod.createPiBackend({ brainPath });
531
+ return mod.createPiBackend({
532
+ brainPath,
533
+ ...(backendLog ? { log: backendLog } : {}),
534
+ });
446
535
  }
447
536
 
448
537
  async function buildRegistry(): Promise<RegistrySnapshot> {
449
538
  const primary = agent.backend || "claude";
450
539
 
451
540
  if (primary === "pi") {
452
- const pi = buildPiBackend();
541
+ const pi = await buildPiBackend();
453
542
  return buildSnapshot([pi], pi.id);
454
543
  }
455
544
 
456
- const backends = [buildClaudeBackend()];
545
+ const backends = [await buildClaudeBackend()];
457
546
 
458
547
  // AGENT_BACKEND must name a configured backend. The in-process options are
459
548
  // "claude" (default) and "pi" (handled above). An unrecognized value is a
@@ -470,10 +559,15 @@ export function createBackendRegistry(
470
559
  return cachedRegistry;
471
560
  }
472
561
 
473
- return makeRegistry(getRegistry, getHidden, async () => {
474
- await getRegistry();
475
- return modelSource;
476
- });
562
+ return makeRegistry(
563
+ getRegistry,
564
+ getHidden,
565
+ async () => {
566
+ await getRegistry();
567
+ return modelSource;
568
+ },
569
+ options.log
570
+ );
477
571
  }
478
572
 
479
573
  /**
@@ -485,13 +579,14 @@ export function createBackendRegistry(
485
579
  export function createStaticBackendRegistry(
486
580
  backends: AgentBackend[],
487
581
  defaultBackendId = backends[0]?.id ?? "",
488
- options: { getHiddenModelIds?: () => string[] } = {}
582
+ options: { getHiddenModelIds?: () => string[]; log?: Logger } = {}
489
583
  ): BackendRegistry {
490
584
  const snapshot = buildSnapshot(backends, defaultBackendId);
491
585
  return makeRegistry(
492
586
  async () => snapshot,
493
587
  options.getHiddenModelIds ?? (() => []),
494
- async () => null
588
+ async () => null,
589
+ options.log
495
590
  );
496
591
  }
497
592
 
@@ -499,7 +594,8 @@ export function createStaticBackendRegistry(
499
594
  function makeRegistry(
500
595
  getRegistry: () => Promise<RegistrySnapshot>,
501
596
  getHidden: () => string[],
502
- getModelSource: () => Promise<ModelDiscoverySource | null>
597
+ getModelSource: () => Promise<ModelDiscoverySource | null>,
598
+ log?: Logger
503
599
  ): BackendRegistry {
504
600
  let profileSnapshot: ProfileSnapshot | null = null;
505
601
 
@@ -536,11 +632,11 @@ function makeRegistry(
536
632
  try {
537
633
  return new Set(getHidden());
538
634
  } catch (err) {
539
- console.warn(
540
- `[models] Could not read hidden models: ${
541
- err instanceof Error ? err.message : String(err)
542
- }`
543
- );
635
+ log?.emit({
636
+ severityText: "WARN",
637
+ body: "could not read hidden models; treating none as hidden",
638
+ attributes: { error: err instanceof Error ? err.message : String(err) },
639
+ });
544
640
  return new Set();
545
641
  }
546
642
  }
package/src/app.ts CHANGED
@@ -1,12 +1,11 @@
1
1
  import { Hono } from "hono";
2
2
  import type { Context } from "hono";
3
3
  import type { Database } from "bun:sqlite";
4
- import { logger } from "hono/logger";
5
4
  import { cors } from "hono/cors";
6
5
  import { serveStatic } from "hono/bun";
7
6
  import { join } from "path";
8
7
  import { resolveServerConfig, type ServerConfig } from "./config/env.js";
9
- import { healthRoutes, createStatusRoutes } from "./routes/health.js";
8
+ import { createHealthRoutes, createStatusRoutes } from "./routes/health.js";
10
9
  import { createBrainRoutes } from "./routes/brain.js";
11
10
  import { createSessionRoutes } from "./routes/sessions.js";
12
11
  import { createVoiceRoutes } from "./routes/voice.js";
@@ -45,6 +44,7 @@ import { WsHost } from "./ws/host.js";
45
44
  import { createWsUpgrade, websocket } from "./ws/connection.js";
46
45
  import { createSessionCatalog } from "./ws/session-catalog.js";
47
46
  import type { KeytermSettings } from "./voice/keyterm-builder.js";
47
+ import { createObservability, type Observability } from "./observability/index.js";
48
48
 
49
49
  export type { AppRenderer };
50
50
 
@@ -81,6 +81,12 @@ export interface CreateAppOptions {
81
81
  turnTimeoutMs?: number;
82
82
  /** Backend registry override (tests/embedders); default is built from config. */
83
83
  registry?: BackendRegistry;
84
+ /**
85
+ * Where this app reports. Defaults to the console consumer; a test passes
86
+ * `createRecordingObservability()` and then asserts on what the server
87
+ * actually said, through the same emission path production uses.
88
+ */
89
+ observability?: Observability;
84
90
  }
85
91
 
86
92
  /** What `createApp` hands back to the deployment shell. */
@@ -99,6 +105,8 @@ export interface BrainUiApp {
99
105
  db: Database;
100
106
  /** The WebSocket coordinator (turn state, clients, catalog, registry). */
101
107
  wsHost: WsHost;
108
+ /** Where this app reports — resolved or injected. */
109
+ observability: Observability;
102
110
  /** True while any session has a running turn. */
103
111
  isTurnActive(): boolean;
104
112
  /** Cancel every running turn (used on shutdown). Returns true if any was. */
@@ -136,14 +144,19 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
136
144
  // silently fall back to the resolved path.
137
145
  const config: ServerConfig =
138
146
  options.dbPath !== undefined ? { ...resolved, dbPath: options.dbPath } : resolved;
147
+ // First thing built, because everything below may want to report — including
148
+ // the auth validation that can refuse to boot and the migration runner.
149
+ const observability =
150
+ options.observability ?? createObservability({ minSeverity: config.logLevel });
139
151
  const auth: AuthRuntime = { ...config.auth, host: config.host };
140
152
 
141
153
  const app = new Hono();
142
- const authMode = resolveAuthMode(auth);
154
+ const authLog = observability.logger("auth");
155
+ const authMode = resolveAuthMode(auth, authLog);
143
156
  // Validate inside the factory, not the bin entry: every consumer of the app
144
157
  // (a deployment bin, tests, another embedder) gets the same refuse-to-boot
145
158
  // guarantee on an unsafe auth configuration.
146
- assertAuthConfig(authMode, auth);
159
+ assertAuthConfig(authMode, auth, authLog);
147
160
  assertPasskeyConfig(config.webauthn);
148
161
  // A missing (or unrecognized) agent backend refuses to boot HERE, not on the
149
162
  // first turn — otherwise /api/health reports healthy while every turn is
@@ -154,38 +167,69 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
154
167
  // Per-instance state: the app's own database, the brain CLI wrapper, the
155
168
  // backend registry, and the WebSocket host. No module-level singletons —
156
169
  // two apps with different configuration coexist in one process.
157
- const db = createUiDb(config.dbPath);
170
+ const dbLog = observability.logger("db");
171
+ const db = createUiDb(config.dbPath, { log: dbLog });
158
172
  const brain = createBrainClient({ brainPath: config.brainPath });
159
- const cron = createCronScheduler({ db, brain });
173
+ const cron = createCronScheduler({ db, brain, log: observability.logger("cron") });
160
174
  const registry =
161
175
  options.registry ??
162
176
  createBackendRegistry({
163
177
  brainPath: config.brainPath,
164
178
  agent: config.agent,
165
- getHiddenModelIds: () => getHiddenModelIds(db),
179
+ getHiddenModelIds: () => getHiddenModelIds(db, dbLog),
180
+ log: observability.logger("agent"),
166
181
  });
167
182
  const host = new WsHost({
168
183
  registry,
169
- catalog: createSessionCatalog(() => db),
184
+ observability,
185
+ catalog: createSessionCatalog(() => db, dbLog),
170
186
  ...(options.appName ? { appName: options.appName } : {}),
171
187
  ...(options.turnTimeoutMs ? { turnTimeoutMs: options.turnTimeoutMs } : {}),
172
188
  maxConcurrentSessions: () => config.maxConcurrentSessions,
189
+ wsRate: config.wsRate,
173
190
  });
174
191
  const wsUpgrade = createWsUpgrade(host);
192
+ // One instrument for every way a login can fail — passkey ceremonies and
193
+ // password logins land in the same series, split by attributes.
194
+ const authFailures = observability.meter("auth").createCounter("auth.failures", {
195
+ description: "Failed authentication ceremonies, by reason",
196
+ });
175
197
  const passkeyCtx: PasskeyContext = {
176
198
  db,
177
199
  webauthn: config.webauthn,
178
200
  auth,
179
201
  allowedOrigins: config.allowedOrigins,
202
+ log: observability.logger("passkeys"),
203
+ failures: authFailures,
180
204
  };
181
205
  const keyterms: KeytermSettings = {
182
206
  brainPath: config.brainPath,
183
207
  cacheDir: config.voice.cacheDir,
184
208
  limit: config.voice.keytermLimit,
209
+ log: observability.logger("voice"),
185
210
  };
186
211
 
187
- // Middleware
188
- app.use("*", logger());
212
+ // Request logging through the observability layer, so BRAIN_UI_LOG_LEVEL
213
+ // governs it like every other emission (hono's logger() wrote raw console
214
+ // lines no threshold or consumer swap could touch). Path only — never the
215
+ // query string or body. /api/health is skipped: the Docker healthcheck
216
+ // polls it and would drown everything else out.
217
+ const httpLog = observability.logger("http");
218
+ app.use("*", async (c, next) => {
219
+ if (c.req.path === "/api/health") return next();
220
+ const startedAt = Date.now();
221
+ await next();
222
+ httpLog.emit({
223
+ severityText: "INFO",
224
+ body: "request",
225
+ attributes: {
226
+ method: c.req.method,
227
+ path: c.req.path,
228
+ status: c.res.status,
229
+ "duration.ms": Date.now() - startedAt,
230
+ },
231
+ });
232
+ });
189
233
 
190
234
  // CORS is only needed for a SPLIT topology where the client is served from a
191
235
  // different origin than the API. ALLOWED_ORIGINS is a comma-separated
@@ -208,7 +252,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
208
252
  // probe and the login/logout routes are reachable without a session.
209
253
  // /api/status is intentionally NOT here — it leaks the git SHA, cron errors,
210
254
  // and a session oracle, so it lives behind the guard below.
211
- app.route("/api", healthRoutes);
255
+ app.route("/api", createHealthRoutes({ db }));
212
256
  // Not under /api, and not behind the guard: this is where a system share
213
257
  // lands when no service worker was around to intercept it. See the route.
214
258
  app.route("/", shareTargetFallbackRoutes);
@@ -216,6 +260,8 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
216
260
  "/api",
217
261
  authRoutes(authMode, auth, {
218
262
  passwordDisabled: (c) => passwordLoginDisabled(c, passkeyCtx),
263
+ log: authLog,
264
+ failures: authFailures,
219
265
  })
220
266
  );
221
267
  app.route("/api", passkeyPublicRoutes(authMode, passkeyCtx));
@@ -234,6 +280,9 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
234
280
  sourceCommit: config.sourceCommit,
235
281
  getCronStatus: () => cron.getCronStatus(),
236
282
  isTurnActive: () => host.coordinator.isTurnActive(),
283
+ // Undefined when the injected consumer cannot be read back (a real OTel
284
+ // SDK exports elsewhere), in which case the field is simply absent.
285
+ getMetrics: () => observability.metrics?.snapshot(),
237
286
  })
238
287
  );
239
288
  app.route(
@@ -242,15 +291,28 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
242
291
  );
243
292
  app.route("/api", createSessionRoutes({ registry, db }));
244
293
  app.route("/api", createVoiceRoutes({ voice: config.voice, keyterms }));
245
- app.route("/api", createFilesRoutes({ brainRoot: config.brainPath }));
246
294
  app.route(
247
295
  "/api",
248
- createShareRoutes({ brainRoot: config.brainPath, allowedOrigins })
296
+ createFilesRoutes({ brainRoot: config.brainPath, log: observability.logger("files") })
297
+ );
298
+ app.route(
299
+ "/api",
300
+ createShareRoutes({
301
+ brainRoot: config.brainPath,
302
+ allowedOrigins,
303
+ log: observability.logger("share"),
304
+ })
249
305
  );
250
- app.route("/api", createRenderRoutes(options.renderer));
306
+ app.route("/api", createRenderRoutes(options.renderer, observability.logger("render")));
251
307
  app.route("/api", createProviderRoutes({ registry }));
252
- app.route("/api", createModelRoutes({ registry, db }));
253
- app.route("/api", createGraphRoutes({ brainRoot: config.brainPath }));
308
+ app.route(
309
+ "/api",
310
+ createModelRoutes({ registry, db, log: observability.logger("models") })
311
+ );
312
+ app.route(
313
+ "/api",
314
+ createGraphRoutes({ brainRoot: config.brainPath, log: observability.logger("graph") })
315
+ );
254
316
 
255
317
  // WebSocket endpoint. Browsers can't set headers on the WS handshake, so the
256
318
  // upgrade authenticates via the session cookie (or IP/proxy header) INSIDE
@@ -269,8 +331,24 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
269
331
  if (options.staticRoot) {
270
332
  const staticRoot = options.staticRoot;
271
333
  app.use("/*", serveStatic({ root: staticRoot }));
272
- // SPA fallback
273
- app.get("*", serveStatic({ path: join(staticRoot, "index.html") }));
334
+
335
+ // SPA fallback, served directly rather than through serveStatic({ path }).
336
+ //
337
+ // That helper resolves its path the same way it resolves `root` — against
338
+ // the process working directory — so `join(staticRoot, "index.html")`
339
+ // only lands correctly when `staticRoot` is itself cwd-relative. It is in
340
+ // the shipped layout, which is why this went unnoticed; an embedder
341
+ // passing an absolute directory, or a process that changed cwd, got a
342
+ // fallback that silently 404ed every deep link. Reading the file
343
+ // ourselves removes the ambiguity for both cases.
344
+ const indexPath = join(staticRoot, "index.html");
345
+ app.get("*", async (c) => {
346
+ const file = Bun.file(indexPath);
347
+ if (!(await file.exists())) return c.notFound();
348
+ return new Response(file, {
349
+ headers: { "Content-Type": "text/html; charset=utf-8" },
350
+ });
351
+ });
274
352
  }
275
353
 
276
354
  return {
@@ -280,6 +358,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
280
358
  authMode,
281
359
  db,
282
360
  wsHost: host,
361
+ observability,
283
362
  isTurnActive: () => host.coordinator.isTurnActive(),
284
363
  cancelActiveTurns: () => host.coordinator.cancelAll("Server shutting down"),
285
364
  close: () => {
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Shared core of every package's environment chokepoint — a SYNC-ENFORCED
3
+ * COPY, not a package.
4
+ *
5
+ * This file exists byte-identically as `src/config/env-core.ts` in every
6
+ * package that has an env chokepoint (`src/config/env.ts`), and the root test
7
+ * `tests/env-core-sync.test.ts` fails the build when any copy differs. To
8
+ * change it: edit ONE copy, copy the file verbatim into every package that
9
+ * has one (each `src/config/env-core.ts`), and rerun the sync test.
10
+ *
11
+ * Why a copy and not a `@schlessera/brain-env` package: the repo adds a seam
12
+ * only where a second implementation is plausible (docs/extending/README.md),
13
+ * and a new package for four fields and three small functions would churn the
14
+ * release manifest and every dependent's dependency list. Byte-equality
15
+ * enforced by a test gives the same single-source-of-truth guarantee without
16
+ * the edge. (`scripts/env-docs.ts` imports the canonical copy in
17
+ * `packages/core` directly — it is a root script, not published code.)
18
+ *
19
+ * What belongs here: the descriptor contract the env-docs generator and the
20
+ * env-parity gate consume, the dynamic-read escape hatch, and the boolean
21
+ * parsing helpers. What does NOT belong here: `resolveEnv` — each package's
22
+ * resolver reads its own variables into its own shape and stays in that
23
+ * package's `env.ts`.
24
+ */
25
+
26
+ /** One environment variable a package reads. */
27
+ export interface EnvVarSpec {
28
+ /** Variable name as it appears in the environment. */
29
+ name: string;
30
+ /** What it controls. */
31
+ description: string;
32
+ /**
33
+ * Behaviour when the variable is unset. Omitted or null when there is no
34
+ * meaningful default to state.
35
+ */
36
+ default?: string | null;
37
+ /**
38
+ * `true` when the package cannot do its job at all without it, `false`
39
+ * when optional; a string states the CONDITION under which it becomes
40
+ * required (e.g. "AUTH_MODE=password") — flattening that to a boolean
41
+ * would lose the only part a reader needs.
42
+ */
43
+ required: boolean | string;
44
+ }
45
+
46
+ /** A family of reads whose variable NAME is data, not code. */
47
+ export interface DynamicEnvReadSpec {
48
+ /** Where the variable name comes from. */
49
+ source: string;
50
+ /** What the value is used for. */
51
+ description: string;
52
+ }
53
+
54
+ /**
55
+ * Call-time read of a single variable whose name is data (see the package's
56
+ * `DYNAMIC_ENV_READS`) or whose presence gates a feature. Never cache the
57
+ * result at module scope.
58
+ */
59
+ export function readEnvVar(
60
+ name: string,
61
+ env: NodeJS.ProcessEnv = process.env
62
+ ): string | undefined {
63
+ return env[name];
64
+ }
65
+
66
+ const TRUTHY_TOKENS: readonly string[] = ["1", "true", "on", "yes"];
67
+ const FALSY_TOKENS: readonly string[] = ["0", "false", "off", "no"];
68
+
69
+ /**
70
+ * The one way a token-valued boolean environment variable is parsed.
71
+ *
72
+ * Truthy: 1 / true / on / yes. Falsy: 0 / false / off / no. Matching is
73
+ * case-insensitive after trimming. Unset, empty, or any unrecognised token
74
+ * returns `defaultValue` — a typo in a flag must never silently flip a
75
+ * behaviour; it falls back to the documented default instead.
76
+ */
77
+ export function envFlag(value: string | undefined, defaultValue: boolean): boolean {
78
+ if (value === undefined) return defaultValue;
79
+ const token = value.trim().toLowerCase();
80
+ if (TRUTHY_TOKENS.includes(token)) return true;
81
+ if (FALSY_TOKENS.includes(token)) return false;
82
+ return defaultValue;
83
+ }
84
+
85
+ /**
86
+ * Presence-based boolean: set to ANY non-empty value means on. This is the
87
+ * `NO_COLOR` convention (https://no-color.org) — `NO_COLOR=0` still disables
88
+ * color — so it must NOT go through `envFlag`. Use only for variables whose
89
+ * documented contract is presence, not a token.
90
+ */
91
+ export function envPresent(value: string | undefined): boolean {
92
+ return value !== undefined && value !== "";
93
+ }