@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
@@ -118,6 +118,15 @@ export function createBrainRoutes(deps: BrainRoutesDeps): Hono {
118
118
  env: subprocessEnv({ NO_COLOR: "1" }),
119
119
  });
120
120
 
121
+ // Start draining stderr NOW, not after the process exits.
122
+ //
123
+ // The stdout loop below can run for minutes. Meanwhile stderr fills an
124
+ // OS pipe buffer that nothing is reading — and once it is full the
125
+ // child blocks on write, never exits, and `await proc.exited` never
126
+ // resolves. A chatty run deadlocked the request; a quiet one looked
127
+ // fine, which is why this survived.
128
+ const stderrText = new Response(proc.stderr).text();
129
+
121
130
  const reader = proc.stdout.getReader();
122
131
  const decoder = new TextDecoder();
123
132
  let buffer = "";
@@ -139,7 +148,7 @@ export function createBrainRoutes(deps: BrainRoutesDeps): Hono {
139
148
 
140
149
  const exitCode = await proc.exited;
141
150
 
142
- const stderr = await new Response(proc.stderr).text();
151
+ const stderr = await stderrText;
143
152
  if (stderr.trim()) {
144
153
  for (const line of stderr.split("\n")) {
145
154
  if (line.trim()) await send({ type: "progress", text: line });
@@ -1,3 +1,4 @@
1
+ import type { Logger } from "@opentelemetry/api-logs";
1
2
  import { Hono } from "hono";
2
3
  import {
3
4
  listDirectory,
@@ -12,7 +13,7 @@ import {
12
13
 
13
14
  const WIKILINK_TTL_MS = 30_000;
14
15
 
15
- function errorResponse(err: unknown): { body: { error: string; size?: number }; status: 400 | 404 | 413 | 500 } {
16
+ function errorResponse(err: unknown, log?: Logger): { body: { error: string; size?: number }; status: 400 | 404 | 413 | 500 } {
16
17
  if (err instanceof PathEscapeError) {
17
18
  return { body: { error: "invalid_path" }, status: 400 };
18
19
  }
@@ -22,12 +23,12 @@ function errorResponse(err: unknown): { body: { error: string; size?: number };
22
23
  if (err instanceof TooLargeError) {
23
24
  return { body: { error: "file_too_large", size: err.size }, status: 413 };
24
25
  }
25
- console.error("[files]", err);
26
+ log?.emit({ severityText: "ERROR", body: "file request failed", attributes: { error: err instanceof Error ? err.message : String(err) } });
26
27
  return { body: { error: err instanceof Error ? err.message : "internal_error" }, status: 500 };
27
28
  }
28
29
 
29
- export function createFilesRoutes(deps: { brainRoot: string }): Hono {
30
- const { brainRoot } = deps;
30
+ export function createFilesRoutes(deps: { brainRoot: string; log?: Logger }): Hono {
31
+ const { brainRoot, log } = deps;
31
32
  let wikilinkCache: { generatedAt: number; map: Record<string, string> } | null = null;
32
33
  return new Hono()
33
34
  .get("/files/tree", async (c) => {
@@ -36,7 +37,7 @@ export function createFilesRoutes(deps: { brainRoot: string }): Hono {
36
37
  const entries = await listDirectory(path, brainRoot);
37
38
  return c.json({ path, entries });
38
39
  } catch (err) {
39
- const { body, status } = errorResponse(err);
40
+ const { body, status } = errorResponse(err, log);
40
41
  return c.json(body, status);
41
42
  }
42
43
  })
@@ -64,7 +65,7 @@ export function createFilesRoutes(deps: { brainRoot: string }): Hono {
64
65
  const result = await readFileContent(path, brainRoot);
65
66
  return c.json({ path, ...result });
66
67
  } catch (err) {
67
- const { body, status } = errorResponse(err);
68
+ const { body, status } = errorResponse(err, log);
68
69
  return c.json(body, status);
69
70
  }
70
71
  })
@@ -76,7 +77,7 @@ export function createFilesRoutes(deps: { brainRoot: string }): Hono {
76
77
  const result = await resolveAncestors(path, brainRoot);
77
78
  return c.json({ path, ...result });
78
79
  } catch (err) {
79
- const { body, status } = errorResponse(err);
80
+ const { body, status } = errorResponse(err, log);
80
81
  return c.json(body, status);
81
82
  }
82
83
  })
@@ -95,7 +96,7 @@ export function createFilesRoutes(deps: { brainRoot: string }): Hono {
95
96
  slugs: wikilinkCache.map,
96
97
  });
97
98
  } catch (err) {
98
- const { body, status } = errorResponse(err);
99
+ const { body, status } = errorResponse(err, log);
99
100
  return c.json(body, status);
100
101
  }
101
102
  });
@@ -1,3 +1,4 @@
1
+ import type { Logger } from "@opentelemetry/api-logs";
1
2
  import { Hono } from "hono";
2
3
  import {
3
4
  DEFAULT_STALE_DAYS,
@@ -18,7 +19,7 @@ import {
18
19
  * subgraph endpoints instead refuse with 503 rather than returning an empty
19
20
  * graph that would read as "your repo has no links".
20
21
  */
21
- function errorResponse(err: unknown): {
22
+ function errorResponse(err: unknown, log?: Logger): {
22
23
  body: { error: string; reason?: string; param?: string };
23
24
  status: 400 | 404 | 503 | 500;
24
25
  } {
@@ -28,7 +29,7 @@ function errorResponse(err: unknown): {
28
29
  if (err instanceof GraphNotFoundError) {
29
30
  return { body: { error: "not_found" }, status: 404 };
30
31
  }
31
- console.error("[graph]", err);
32
+ log?.emit({ severityText: "ERROR", body: "graph request failed", attributes: { error: err instanceof Error ? err.message : String(err) } });
32
33
  return { body: { error: err instanceof Error ? err.message : "internal_error" }, status: 500 };
33
34
  }
34
35
 
@@ -49,8 +50,8 @@ function invalid(param: string) {
49
50
  return { error: "invalid_param", param } as const;
50
51
  }
51
52
 
52
- export function createGraphRoutes(deps: { brainRoot: string }): Hono {
53
- const { brainRoot } = deps;
53
+ export function createGraphRoutes(deps: { brainRoot: string; log?: Logger }): Hono {
54
+ const { brainRoot, log } = deps;
54
55
  return new Hono()
55
56
  .get("/graph/meta", (c) => c.json(getGraphMeta({ brainPath: brainRoot })))
56
57
 
@@ -65,7 +66,7 @@ export function createGraphRoutes(deps: { brainRoot: string }): Hono {
65
66
  try {
66
67
  return c.json(getClusters({ brainPath: brainRoot, community, includeIsolates: c.req.query("isolates") === "1" }));
67
68
  } catch (err) {
68
- const { body, status } = errorResponse(err);
69
+ const { body, status } = errorResponse(err, log);
69
70
  return c.json(body, status);
70
71
  }
71
72
  })
@@ -81,7 +82,7 @@ export function createGraphRoutes(deps: { brainRoot: string }): Hono {
81
82
  try {
82
83
  return c.json(getNeighborhood({ brainPath: brainRoot, center, depth, direction }));
83
84
  } catch (err) {
84
- const { body, status } = errorResponse(err);
85
+ const { body, status } = errorResponse(err, log);
85
86
  return c.json(body, status);
86
87
  }
87
88
  })
@@ -96,7 +97,7 @@ export function createGraphRoutes(deps: { brainRoot: string }): Hono {
96
97
  try {
97
98
  return c.json(getDiscovery({ brainPath: brainRoot, root, direction, maxDepth }));
98
99
  } catch (err) {
99
- const { body, status } = errorResponse(err);
100
+ const { body, status } = errorResponse(err, log);
100
101
  return c.json(body, status);
101
102
  }
102
103
  })
@@ -108,7 +109,7 @@ export function createGraphRoutes(deps: { brainRoot: string }): Hono {
108
109
  try {
109
110
  return c.json(getMaintenance({ brainPath: brainRoot, staleDays }));
110
111
  } catch (err) {
111
- const { body, status } = errorResponse(err);
112
+ const { body, status } = errorResponse(err, log);
112
113
  return c.json(body, status);
113
114
  }
114
115
  });
@@ -1,23 +1,43 @@
1
1
  import { Hono } from "hono";
2
+ import type { Database } from "bun:sqlite";
3
+
4
+ import type { MetricSnapshot } from "../observability/index.js";
2
5
 
3
6
  const startTime = Date.now();
4
7
 
5
8
  // Public liveness probe. Deliberately minimal: no version/commit, no cron
6
9
  // detail, no session oracle — this route sits in front of the auth guard and
7
10
  // is reachable by anyone on the origin.
8
- export const healthRoutes = new Hono().get("/health", (c) => {
9
- return c.json({
10
- status: "healthy",
11
- uptime: Date.now() - startTime,
12
- timestamp: new Date().toISOString(),
11
+ //
12
+ // "Healthy" means the app's own SQLite handle answers a real read, not merely
13
+ // that the process accepts connections: the Docker healthcheck gates on this
14
+ // route, and a wedged database previously kept reporting healthy while every
15
+ // stateful route failed. The probe reads sqlite_master rather than a bare
16
+ // SELECT 1 — a constant expression touches no page of the database file, so it
17
+ // cannot notice a locked or corrupted one. The unhealthy body carries no
18
+ // detail — the route is public.
19
+ export function createHealthRoutes(deps: { db: Database }): Hono {
20
+ return new Hono().get("/health", (c) => {
21
+ try {
22
+ deps.db.query("SELECT name FROM sqlite_master LIMIT 1").get();
23
+ } catch {
24
+ return c.json({ status: "unhealthy" }, 503);
25
+ }
26
+ return c.json({
27
+ status: "healthy",
28
+ uptime: Date.now() - startTime,
29
+ timestamp: new Date().toISOString(),
30
+ });
13
31
  });
14
- });
32
+ }
15
33
 
16
34
  export interface StatusDeps {
17
35
  /** Git SHA baked at build time (SOURCE_COMMIT), "dev" when unset. */
18
36
  sourceCommit: string;
19
37
  getCronStatus(): unknown;
20
38
  isTurnActive(): boolean;
39
+ /** Recorded counters; undefined when the consumer cannot be read back. */
40
+ getMetrics?(): MetricSnapshot | undefined;
21
41
  }
22
42
 
23
43
  // Operational status. Registered BEHIND the auth guard: it exposes the git SHA,
@@ -31,6 +51,9 @@ export function createStatusRoutes(deps: StatusDeps): Hono {
31
51
  version: deps.sourceCommit,
32
52
  cronJobs: deps.getCronStatus(),
33
53
  activeSession: deps.isTurnActive(),
54
+ // Counters the server recorded this process lifetime — dropped frames,
55
+ // handler failures. Behind the auth guard with everything else here.
56
+ metrics: deps.getMetrics?.() ?? [],
34
57
  });
35
58
  });
36
59
  }
@@ -5,6 +5,7 @@
5
5
  // with its visibility, plus discovery freshness so the UI can say when the list
6
6
  // was last refreshed and why it might be stale.
7
7
 
8
+ import type { Logger } from "@opentelemetry/api-logs";
8
9
  import { Hono } from "hono";
9
10
  import type { Database } from "bun:sqlite";
10
11
  import type {
@@ -17,6 +18,8 @@ import { getHiddenModelIds, setHiddenModelIds } from "../db/settings.js";
17
18
  export function createModelRoutes(deps: {
18
19
  registry: BackendRegistry;
19
20
  db: Database;
21
+ /** Where refresh failures are reported. */
22
+ log?: Logger;
20
23
  }): Hono {
21
24
  const { registry, db } = deps;
22
25
 
@@ -77,11 +80,11 @@ export function createModelRoutes(deps: {
77
80
  } catch (err) {
78
81
  // The previous roster is still served; report the failure in the payload
79
82
  // rather than 500ing, so the settings screen can show it inline.
80
- console.warn(
81
- `[models] Refresh failed: ${
82
- err instanceof Error ? err.message : String(err)
83
- }`
84
- );
83
+ deps.log?.emit({
84
+ severityText: "WARN",
85
+ body: "model roster refresh failed; serving the previous roster",
86
+ attributes: { error: err instanceof Error ? err.message : String(err) },
87
+ });
85
88
  }
86
89
  registry.invalidateProfiles();
87
90
  return c.json(await buildCatalog());
@@ -1,3 +1,4 @@
1
+ import type { Logger } from "@opentelemetry/api-logs";
1
2
  import { Hono } from "hono";
2
3
  import { z } from "zod";
3
4
  import type { RenderRequest } from "@schlessera/brain-ui-sdk/protocol";
@@ -30,7 +31,7 @@ const bodySchema = z.object({
30
31
  title: z.string().max(200).optional(),
31
32
  }) satisfies z.ZodType<RenderRequest>;
32
33
 
33
- export function createRenderRoutes(renderer?: AppRenderer) {
34
+ export function createRenderRoutes(renderer?: AppRenderer, log?: Logger) {
34
35
  return new Hono().post("/render", async (c) => {
35
36
  if (!renderer) {
36
37
  return c.json(
@@ -65,7 +66,7 @@ export function createRenderRoutes(renderer?: AppRenderer) {
65
66
  },
66
67
  });
67
68
  } catch (err) {
68
- console.error("[render]", err);
69
+ log?.emit({ severityText: "ERROR", body: "render failed", attributes: { error: err instanceof Error ? err.message : String(err) } });
69
70
  return c.json(
70
71
  { error: "render_failed", detail: err instanceof Error ? err.message : String(err) },
71
72
  500
@@ -1,3 +1,4 @@
1
+ import type { Logger } from "@opentelemetry/api-logs";
1
2
  import { Hono } from "hono";
2
3
  import {
3
4
  SHARE_MAX_CONCURRENT_INTAKE,
@@ -93,10 +94,12 @@ export interface ShareRoutesDeps {
93
94
  brainRoot: string;
94
95
  /** ALLOWED_ORIGINS — the same-origin check's split-topology allowlist. */
95
96
  allowedOrigins: string[];
97
+ /** Where failures are reported; absent means silence. */
98
+ log?: Logger;
96
99
  }
97
100
 
98
101
  export function createShareRoutes(deps: ShareRoutesDeps): Hono {
99
- const { brainRoot, allowedOrigins } = deps;
102
+ const { brainRoot, allowedOrigins, log } = deps;
100
103
 
101
104
  /**
102
105
  * In-flight intakes. Each one holds its whole payload in memory while the
@@ -115,8 +118,8 @@ export function createShareRoutes(deps: ShareRoutesDeps): Hono {
115
118
  lastPrune = now;
116
119
  // Deliberately not awaited: pruning is housekeeping, and the client is
117
120
  // waiting on the staging result, not on it.
118
- void pruneShareStaging(brainRoot).catch((err) => {
119
- console.error("[share] prune failed:", err);
121
+ void pruneShareStaging(brainRoot, Date.now(), log).catch((err) => {
122
+ log?.emit({ severityText: "ERROR", body: "share staging prune failed", attributes: { error: err instanceof Error ? err.message : String(err) } });
120
123
  });
121
124
  }
122
125
 
@@ -164,12 +167,16 @@ export function createShareRoutes(deps: ShareRoutesDeps): Hono {
164
167
 
165
168
  inFlight += 1;
166
169
  try {
167
- const result = await stageShare(brainRoot, {
168
- title: firstString(form, "title"),
169
- text: firstString(form, "text"),
170
- url: firstString(form, "url"),
171
- files,
172
- });
170
+ const result = await stageShare(
171
+ brainRoot,
172
+ {
173
+ title: firstString(form, "title"),
174
+ text: firstString(form, "text"),
175
+ url: firstString(form, "url"),
176
+ files,
177
+ },
178
+ log
179
+ );
173
180
 
174
181
  maybePrune();
175
182
  return c.json(result, 201);
@@ -180,7 +187,7 @@ export function createShareRoutes(deps: ShareRoutesDeps): Hono {
180
187
  if (err instanceof ShareTooLargeError) {
181
188
  return c.json({ error: err.reason, limit: err.limit }, 413);
182
189
  }
183
- console.error("[share]", err);
190
+ log?.emit({ severityText: "ERROR", body: "share request failed", attributes: { error: err instanceof Error ? err.message : String(err) } });
184
191
  return c.json({ error: "share_failed" }, 500);
185
192
  } finally {
186
193
  inFlight -= 1;
@@ -1,3 +1,4 @@
1
+ import type { Logger } from "@opentelemetry/api-logs";
1
2
  import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
3
  import { join } from "node:path";
3
4
  import {
@@ -271,7 +272,8 @@ async function countStaged(root: string): Promise<number> {
271
272
  */
272
273
  export async function stageShare(
273
274
  brainPath: string,
274
- input: ShareInput
275
+ input: ShareInput,
276
+ log?: Logger
275
277
  ): Promise<ShareIntakeResult> {
276
278
  const title = cleanTextField(input.title);
277
279
  const rawUrl = cleanTextField(input.url);
@@ -341,7 +343,11 @@ export async function stageShare(
341
343
  // One unwritable file (ENOSPC, a stricter filesystem) must not throw
342
344
  // away the other four. Record it and carry on; the share is only lost
343
345
  // if nothing at all survives.
344
- console.error(`[share] could not stage ${name}:`, err);
346
+ log?.emit({
347
+ severityText: "ERROR",
348
+ body: "could not stage a shared file",
349
+ attributes: { name, error: err instanceof Error ? err.message : String(err) },
350
+ });
345
351
  skipped.push(name);
346
352
  continue;
347
353
  }
@@ -394,7 +400,11 @@ export async function stageShare(
394
400
  * deployment can sweep at boot too — scheduling belongs to the container
395
401
  * crontab, and this sweep is cheap and bounded.
396
402
  */
397
- export async function pruneShareStaging(brainPath: string, now = Date.now()): Promise<number> {
403
+ export async function pruneShareStaging(
404
+ brainPath: string,
405
+ now = Date.now(),
406
+ log?: Logger
407
+ ): Promise<number> {
398
408
  const root = shareStagingRoot(brainPath);
399
409
  let entries;
400
410
  try {
@@ -404,7 +414,11 @@ export async function pruneShareStaging(brainPath: string, now = Date.now()): Pr
404
414
  // problem, a file where the directory should be — must not masquerade as
405
415
  // "nothing to do", or pruning stops forever and silently.
406
416
  if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
407
- console.error("[share] cannot read the staging root:", err);
417
+ log?.emit({
418
+ severityText: "ERROR",
419
+ body: "cannot read the share staging root",
420
+ attributes: { error: err instanceof Error ? err.message : String(err) },
421
+ });
408
422
  }
409
423
  return 0;
410
424
  }
@@ -1,3 +1,4 @@
1
+ import type { Logger } from "@opentelemetry/api-logs";
1
2
  import type { Database } from "bun:sqlite";
2
3
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
3
4
  import { join, dirname } from "path";
@@ -15,6 +16,8 @@ export interface KeytermSettings {
15
16
  cacheDir: string;
16
17
  /** Maximum vocabulary size (VOICE_KEYTERM_LIMIT). */
17
18
  limit: number;
19
+ /** Where degradation is reported; absent means silence. */
20
+ log?: Logger;
18
21
  }
19
22
 
20
23
  // Bump whenever the extractor logic, scoring, or stoplists change so that
@@ -359,7 +362,7 @@ function dedupeAndRank(terms: Keyterm[], limit: number): string[] {
359
362
  return sorted.slice(0, limit).map((k) => k.term);
360
363
  }
361
364
 
362
- export function loadOverrides(brainPath: string): PronunciationOverride[] {
365
+ export function loadOverrides(brainPath: string, log?: Logger): PronunciationOverride[] {
363
366
  const path = overridesPath(brainPath);
364
367
  if (!existsSync(path)) return [];
365
368
  try {
@@ -375,13 +378,17 @@ export function loadOverrides(brainPath: string): PronunciationOverride[] {
375
378
  }
376
379
  return out;
377
380
  } catch (err) {
378
- console.warn("[voice] Failed to load overrides:", err);
381
+ log?.emit({
382
+ severityText: "WARN",
383
+ body: "failed to load pronunciation overrides",
384
+ attributes: { error: err instanceof Error ? err.message : String(err) },
385
+ });
379
386
  return [];
380
387
  }
381
388
  }
382
389
 
383
390
  export function buildKeyterms(settings: KeytermSettings): KeytermsCache {
384
- const { brainPath, limit } = settings;
391
+ const { brainPath, limit, log } = settings;
385
392
 
386
393
  try {
387
394
  return withBrainDb(brainPath, {}, (db) => {
@@ -398,7 +405,7 @@ export function buildKeyterms(settings: KeytermSettings): KeytermsCache {
398
405
  keyterms,
399
406
  generatedAt: Date.now(),
400
407
  count: keyterms.length,
401
- overrides: loadOverrides(brainPath),
408
+ overrides: loadOverrides(brainPath, log),
402
409
  };
403
410
  });
404
411
  } catch (err) {
@@ -410,17 +417,19 @@ export function buildKeyterms(settings: KeytermSettings): KeytermsCache {
410
417
  // Schema too old for this package's SQL: degrade to no custom
411
418
  // vocabulary rather than breaking voice entirely. Pronunciation
412
419
  // overrides live in markdown, so they survive.
413
- console.warn(
414
- `[voice] brain.db schema_version=${err.schemaVersion} is older than this ` +
415
- "server's keyterm SQL; serving an empty custom vocabulary until the " +
416
- "repo is re-indexed."
417
- );
420
+ log?.emit({
421
+ severityText: "WARN",
422
+ body:
423
+ "brain.db schema is older than this server's keyterm SQL; serving an " +
424
+ "empty custom vocabulary until the repo is re-indexed",
425
+ attributes: { "schema.version": err.schemaVersion },
426
+ });
418
427
  return {
419
428
  version: CACHE_VERSION,
420
429
  keyterms: [],
421
430
  generatedAt: Date.now(),
422
431
  count: 0,
423
- overrides: loadOverrides(brainPath),
432
+ overrides: loadOverrides(brainPath, log),
424
433
  degraded: true,
425
434
  };
426
435
  }
package/src/ws/bridge.ts CHANGED
@@ -23,10 +23,14 @@ export function makeBridge(
23
23
  // field would attribute those to the NEXT turn.
24
24
  const turnId = turn.turnId;
25
25
  return {
26
- emit: (msg) => {
26
+ emit: (message) => {
27
+ let msg = message;
27
28
  if (msg.type === "session_info") {
28
29
  turn.sessionId = msg.sessionId;
29
30
  if (msg.providerId) turn.providerId = msg.providerId;
31
+ // Echo the client's correlation id, so it can recognise which
32
+ // announcement is its own rather than adopting the first to arrive.
33
+ if (turn.draftId) msg = { ...msg, draftId: turn.draftId };
30
34
  coordinator.bySession.set(msg.sessionId, turn);
31
35
  // Persist ownership the moment the identity exists — a turn that
32
36
  // later fails or is cancelled must not leave an unowned transcript.
@@ -34,6 +38,16 @@ export function makeBridge(
34
38
  }
35
39
  host.sendToClients(withTurnScope(msg, turn, turnId));
36
40
  if (msg.type === "result") {
41
+ // Only the live turn's own result may set its disposition — a late
42
+ // frame through a previous turn's bridge must not relabel this one.
43
+ if (turn.turnId === turnId) {
44
+ turn.lastResult =
45
+ msg.isError || msg.outcome === "error"
46
+ ? "error"
47
+ : msg.outcome === "cancelled"
48
+ ? "cancelled"
49
+ : "success";
50
+ }
37
51
  catalog.persistSession(msg, promptText, turn.providerId, backendId);
38
52
  }
39
53
  },
@@ -140,8 +154,10 @@ export function makeBridge(
140
154
 
141
155
  export function emitTurnError(host: WsHost, turn: RunningTurn, err: unknown): void {
142
156
  // startTurn resolves for runtime failures (it emits its own error frame); it
143
- // only rejects for caller errors.
157
+ // only rejects for caller errors. Each rejection is reported server-side
158
+ // too — the frame alone leaves no trace once the browser tab is gone.
144
159
  if (err instanceof BackendBusyError) {
160
+ host.reportTurnFailed("SESSION_BUSY", turn);
145
161
  host.sendToClients(
146
162
  withTurnScope(
147
163
  { type: "error", code: "SESSION_BUSY", message: "That session already has a running turn." },
@@ -149,15 +165,15 @@ export function emitTurnError(host: WsHost, turn: RunningTurn, err: unknown): vo
149
165
  )
150
166
  );
151
167
  } else if (err instanceof BackendRequestError) {
168
+ host.reportTurnFailed("BACKEND_REQUEST_ERROR", turn, err.message);
152
169
  host.sendToClients(
153
170
  withTurnScope({ type: "error", code: "BACKEND_REQUEST_ERROR", message: err.message }, turn)
154
171
  );
155
172
  } else {
173
+ const message = err instanceof Error ? err.message : String(err);
174
+ host.reportTurnFailed("BACKEND_ERROR", turn, message);
156
175
  host.sendToClients(
157
- withTurnScope(
158
- { type: "error", code: "BACKEND_ERROR", message: err instanceof Error ? err.message : String(err) },
159
- turn
160
- )
176
+ withTurnScope({ type: "error", code: "BACKEND_ERROR", message }, turn)
161
177
  );
162
178
  }
163
179
  }
package/src/ws/clients.ts CHANGED
@@ -1,8 +1,15 @@
1
1
  import type { ServerMessage } from "@schlessera/brain-ui-sdk/protocol";
2
2
  import { shrinkForReplication } from "./shrink.js";
3
3
 
4
- /** Minimal structural view of a live socket — all we need to write to it. */
5
- export type WSContext = { send: (data: string) => void };
4
+ /**
5
+ * Minimal structural view of a live socket all we need to write to it.
6
+ * `raw` is the underlying Bun ServerWebSocket when hono's Bun adapter built
7
+ * the context; its `send` RETURNS a status instead of throwing.
8
+ */
9
+ export type WSContext = {
10
+ send: (data: string) => void;
11
+ raw?: { send?: (data: string) => number } | undefined;
12
+ };
6
13
 
7
14
  /** Serialize + size-bound a frame, then send it to one specific socket. */
8
15
  export function sendTo(ws: WSContext, msg: ServerMessage): void {
@@ -38,16 +45,27 @@ export class ClientSet {
38
45
  /**
39
46
  * Broadcast a frame to every attached client. Serializes once. A failing
40
47
  * socket is skipped (its `onClose` will prune it) so one dead peer can't
41
- * block delivery to the others.
48
+ * block delivery to the others; `onSendError` lets the owner count the skip.
42
49
  */
43
- broadcast(msg: ServerMessage): void {
50
+ broadcast(msg: ServerMessage, onSendError?: (err: unknown) => void): void {
44
51
  if (this.clients.size === 0) return;
45
52
  const payload = JSON.stringify(shrinkForReplication(msg));
46
53
  for (const ws of this.clients) {
47
54
  try {
48
- ws.send(payload);
49
- } catch {
55
+ // Bun's ServerWebSocket reports a dropped write by RETURNING 0 (closed
56
+ // connection) rather than throwing, and hono's WSContext.send discards
57
+ // that status — so write through the raw socket where one exists. -1
58
+ // is backpressure: the frame is queued, not lost.
59
+ if (typeof ws.raw?.send === "function") {
60
+ if (ws.raw.send(payload) === 0) {
61
+ onSendError?.(new Error("send dropped: connection closed"));
62
+ }
63
+ } else {
64
+ ws.send(payload);
65
+ }
66
+ } catch (err) {
50
67
  // Drop; the socket's onClose handler removes it from the set.
68
+ onSendError?.(err);
51
69
  }
52
70
  }
53
71
  }