@schlessera/brain-ui-server 0.15.0 → 0.16.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 (144) hide show
  1. package/README.md +3 -0
  2. package/dist/agent/backend.d.ts.map +1 -1
  3. package/dist/agent/backend.js +6 -2
  4. package/dist/agent/backend.js.map +1 -1
  5. package/dist/app.d.ts +9 -0
  6. package/dist/app.d.ts.map +1 -1
  7. package/dist/app.js +37 -6
  8. package/dist/app.js.map +1 -1
  9. package/dist/config/env.d.ts +8 -0
  10. package/dist/config/env.d.ts.map +1 -1
  11. package/dist/config/env.js +48 -0
  12. package/dist/config/env.js.map +1 -1
  13. package/dist/cron/scheduler.d.ts +3 -0
  14. package/dist/cron/scheduler.d.ts.map +1 -1
  15. package/dist/cron/scheduler.js +11 -3
  16. package/dist/cron/scheduler.js.map +1 -1
  17. package/dist/db/client.d.ts +10 -1
  18. package/dist/db/client.d.ts.map +1 -1
  19. package/dist/db/client.js +5 -14
  20. package/dist/db/client.js.map +1 -1
  21. package/dist/db/settings.d.ts +2 -1
  22. package/dist/db/settings.d.ts.map +1 -1
  23. package/dist/db/settings.js +6 -2
  24. package/dist/db/settings.js.map +1 -1
  25. package/dist/index.d.ts +4 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +6 -2
  28. package/dist/index.js.map +1 -1
  29. package/dist/middleware/auth.d.ts +3 -2
  30. package/dist/middleware/auth.d.ts.map +1 -1
  31. package/dist/middleware/auth.js +28 -9
  32. package/dist/middleware/auth.js.map +1 -1
  33. package/dist/middleware/passkeys.d.ts +10 -0
  34. package/dist/middleware/passkeys.d.ts.map +1 -1
  35. package/dist/middleware/passkeys.js +19 -3
  36. package/dist/middleware/passkeys.js.map +1 -1
  37. package/dist/observability/index.d.ts +73 -0
  38. package/dist/observability/index.d.ts.map +1 -0
  39. package/dist/observability/index.js +74 -0
  40. package/dist/observability/index.js.map +1 -0
  41. package/dist/observability/loggers.d.ts +62 -0
  42. package/dist/observability/loggers.d.ts.map +1 -0
  43. package/dist/observability/loggers.js +109 -0
  44. package/dist/observability/loggers.js.map +1 -0
  45. package/dist/observability/meter.d.ts +39 -0
  46. package/dist/observability/meter.d.ts.map +1 -0
  47. package/dist/observability/meter.js +95 -0
  48. package/dist/observability/meter.js.map +1 -0
  49. package/dist/observability/types.d.ts +67 -0
  50. package/dist/observability/types.d.ts.map +1 -0
  51. package/dist/observability/types.js +44 -0
  52. package/dist/observability/types.js.map +1 -0
  53. package/dist/routes/brain.d.ts.map +1 -1
  54. package/dist/routes/brain.js +9 -1
  55. package/dist/routes/brain.js.map +1 -1
  56. package/dist/routes/files.d.ts +2 -0
  57. package/dist/routes/files.d.ts.map +1 -1
  58. package/dist/routes/files.js +7 -7
  59. package/dist/routes/files.js.map +1 -1
  60. package/dist/routes/graph.d.ts +2 -0
  61. package/dist/routes/graph.d.ts.map +1 -1
  62. package/dist/routes/graph.js +7 -7
  63. package/dist/routes/graph.js.map +1 -1
  64. package/dist/routes/health.d.ts +3 -0
  65. package/dist/routes/health.d.ts.map +1 -1
  66. package/dist/routes/health.js +3 -0
  67. package/dist/routes/health.js.map +1 -1
  68. package/dist/routes/models.d.ts +3 -0
  69. package/dist/routes/models.d.ts.map +1 -1
  70. package/dist/routes/models.js +5 -1
  71. package/dist/routes/models.js.map +1 -1
  72. package/dist/routes/render.d.ts +2 -1
  73. package/dist/routes/render.d.ts.map +1 -1
  74. package/dist/routes/render.js +2 -2
  75. package/dist/routes/render.js.map +1 -1
  76. package/dist/routes/share.d.ts +3 -0
  77. package/dist/routes/share.d.ts.map +1 -1
  78. package/dist/routes/share.js +3 -3
  79. package/dist/routes/share.js.map +1 -1
  80. package/dist/share/staging.d.ts +3 -2
  81. package/dist/share/staging.d.ts.map +1 -1
  82. package/dist/share/staging.js +12 -4
  83. package/dist/share/staging.js.map +1 -1
  84. package/dist/voice/keyterm-builder.d.ts +4 -1
  85. package/dist/voice/keyterm-builder.d.ts.map +1 -1
  86. package/dist/voice/keyterm-builder.js +13 -6
  87. package/dist/voice/keyterm-builder.js.map +1 -1
  88. package/dist/ws/bridge.d.ts.map +1 -1
  89. package/dist/ws/bridge.js +6 -1
  90. package/dist/ws/bridge.js.map +1 -1
  91. package/dist/ws/connection.d.ts +15 -0
  92. package/dist/ws/connection.d.ts.map +1 -1
  93. package/dist/ws/connection.js +55 -8
  94. package/dist/ws/connection.js.map +1 -1
  95. package/dist/ws/dispatch.d.ts +22 -1
  96. package/dist/ws/dispatch.d.ts.map +1 -1
  97. package/dist/ws/dispatch.js +33 -15
  98. package/dist/ws/dispatch.js.map +1 -1
  99. package/dist/ws/host.d.ts +37 -0
  100. package/dist/ws/host.d.ts.map +1 -1
  101. package/dist/ws/host.js +37 -0
  102. package/dist/ws/host.js.map +1 -1
  103. package/dist/ws/rate-limit.d.ts +58 -0
  104. package/dist/ws/rate-limit.d.ts.map +1 -0
  105. package/dist/ws/rate-limit.js +62 -0
  106. package/dist/ws/rate-limit.js.map +1 -0
  107. package/dist/ws/run-session.d.ts +3 -0
  108. package/dist/ws/run-session.d.ts.map +1 -1
  109. package/dist/ws/run-session.js +23 -5
  110. package/dist/ws/run-session.js.map +1 -1
  111. package/dist/ws/turns.d.ts +13 -0
  112. package/dist/ws/turns.d.ts.map +1 -1
  113. package/dist/ws/turns.js +11 -1
  114. package/dist/ws/turns.js.map +1 -1
  115. package/package.json +5 -3
  116. package/src/agent/backend.ts +8 -6
  117. package/src/app.ts +46 -6
  118. package/src/config/env.ts +57 -0
  119. package/src/cron/scheduler.ts +14 -3
  120. package/src/db/client.ts +16 -5
  121. package/src/db/settings.ts +7 -2
  122. package/src/index.ts +37 -2
  123. package/src/middleware/auth.ts +30 -14
  124. package/src/middleware/passkeys.ts +29 -5
  125. package/src/observability/index.ts +150 -0
  126. package/src/observability/loggers.ts +174 -0
  127. package/src/observability/meter.ts +190 -0
  128. package/src/observability/types.ts +101 -0
  129. package/src/routes/brain.ts +10 -1
  130. package/src/routes/files.ts +9 -8
  131. package/src/routes/graph.ts +9 -8
  132. package/src/routes/health.ts +7 -0
  133. package/src/routes/models.ts +8 -5
  134. package/src/routes/render.ts +3 -2
  135. package/src/routes/share.ts +6 -3
  136. package/src/share/staging.ts +18 -4
  137. package/src/voice/keyterm-builder.ts +17 -8
  138. package/src/ws/bridge.ts +5 -1
  139. package/src/ws/connection.ts +63 -12
  140. package/src/ws/dispatch.ts +44 -15
  141. package/src/ws/host.ts +54 -0
  142. package/src/ws/rate-limit.ts +86 -0
  143. package/src/ws/run-session.ts +26 -7
  144. package/src/ws/turns.ts +19 -3
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Shapes the observability consumers expose for inspection.
3
+ *
4
+ * These exist so that "what did the server report?" is answerable as DATA — by
5
+ * a test, by `/api/status`, by a future exporter — rather than by scraping
6
+ * stdout. Every consumer in this directory implements a standard OpenTelemetry
7
+ * provider interface, so swapping one for another is an argument change and
8
+ * never a change to instrumentation.
9
+ */
10
+ import type { Attributes } from "@opentelemetry/api";
11
+ import type { LogRecord } from "@opentelemetry/api-logs";
12
+
13
+ /**
14
+ * Attributes as this package retains them.
15
+ *
16
+ * Log attributes upstream are `AnyValue` — nested maps, arrays, null. Metric
17
+ * attributes are narrower. Both are normalized to scalars on capture so one
18
+ * query shape works over either, `/api/status` stays JSON-safe, and a
19
+ * formatted line never prints `[object Object]`.
20
+ */
21
+ export type FlatAttributes = Record<string, string | number | boolean>;
22
+
23
+ /** Coerce either attribute flavour to scalars, dropping empty values. */
24
+ export function flattenAttributes(attributes: unknown): FlatAttributes {
25
+ const flat: FlatAttributes = {};
26
+ if (!attributes || typeof attributes !== "object") return flat;
27
+ for (const [key, value] of Object.entries(attributes as Record<string, unknown>)) {
28
+ if (value === undefined || value === null) continue;
29
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
30
+ flat[key] = value;
31
+ } else {
32
+ flat[key] = JSON.stringify(value);
33
+ }
34
+ }
35
+ return flat;
36
+ }
37
+
38
+ /** Severities this codebase uses, ordered. Anything below the configured
39
+ * threshold is dropped by the console consumer before it is formatted. */
40
+ export const SEVERITIES = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL"] as const;
41
+ export type Severity = (typeof SEVERITIES)[number];
42
+
43
+ export function severityRank(severity: string | undefined): number {
44
+ const index = SEVERITIES.indexOf((severity ?? "INFO") as Severity);
45
+ return index === -1 ? SEVERITIES.indexOf("INFO") : index;
46
+ }
47
+
48
+ /** One emitted record, with the scope that produced it. */
49
+ export interface CapturedLog {
50
+ /** Instrumentation scope — the `[ws]` / `[auth]` prefix this repo already uses. */
51
+ scope: string;
52
+ severity: Severity;
53
+ body: string;
54
+ attributes: FlatAttributes;
55
+ /** Wall clock at emit, in ms. */
56
+ timestamp: number;
57
+ }
58
+
59
+ /** A query over captured logs. Every field is an AND, every field optional. */
60
+ export interface LogQuery {
61
+ scope?: string;
62
+ severity?: Severity;
63
+ /** Minimum severity, inclusive. */
64
+ minSeverity?: Severity;
65
+ /** Substring match against the body. */
66
+ body?: string;
67
+ /** Every listed attribute must be present and equal. */
68
+ attributes?: FlatAttributes;
69
+ }
70
+
71
+ /** One counter/histogram series: an instrument plus one attribute combination. */
72
+ export interface MetricPoint {
73
+ name: string;
74
+ /** The attribute set that identifies this series. */
75
+ attributes: FlatAttributes;
76
+ /** Sum for counters, count of observations for histograms. */
77
+ value: number;
78
+ kind: "counter" | "updowncounter" | "histogram" | "gauge";
79
+ }
80
+
81
+ /** Serializable metric state, for `/api/status` and for assertions. */
82
+ export type MetricSnapshot = MetricPoint[];
83
+
84
+ /**
85
+ * Stable key for a series. Attributes are sorted, so two calls with the same
86
+ * attributes in a different literal order land on the same series.
87
+ */
88
+ export function seriesKey(name: string, attributes: Attributes | undefined): string {
89
+ const entries = Object.entries(attributes ?? {})
90
+ .filter(([, v]) => v !== undefined)
91
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
92
+ .map(([k, v]) => `${k}=${String(v)}`);
93
+ return entries.length ? `${name}|${entries.join(",")}` : name;
94
+ }
95
+
96
+ /** Convert a `LogRecord` body — typed as unknown-ish upstream — to a string. */
97
+ export function bodyToString(body: LogRecord["body"]): string {
98
+ if (typeof body === "string") return body;
99
+ if (body === undefined || body === null) return "";
100
+ return typeof body === "object" ? JSON.stringify(body) : String(body);
101
+ }
@@ -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,5 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
 
3
+ import type { MetricSnapshot } from "../observability/index.js";
4
+
3
5
  const startTime = Date.now();
4
6
 
5
7
  // Public liveness probe. Deliberately minimal: no version/commit, no cron
@@ -18,6 +20,8 @@ export interface StatusDeps {
18
20
  sourceCommit: string;
19
21
  getCronStatus(): unknown;
20
22
  isTurnActive(): boolean;
23
+ /** Recorded counters; undefined when the consumer cannot be read back. */
24
+ getMetrics?(): MetricSnapshot | undefined;
21
25
  }
22
26
 
23
27
  // Operational status. Registered BEHIND the auth guard: it exposes the git SHA,
@@ -31,6 +35,9 @@ export function createStatusRoutes(deps: StatusDeps): Hono {
31
35
  version: deps.sourceCommit,
32
36
  cronJobs: deps.getCronStatus(),
33
37
  activeSession: deps.isTurnActive(),
38
+ // Counters the server recorded this process lifetime — dropped frames,
39
+ // handler failures. Behind the auth guard with everything else here.
40
+ metrics: deps.getMetrics?.() ?? [],
34
41
  });
35
42
  });
36
43
  }
@@ -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
@@ -116,7 +119,7 @@ export function createShareRoutes(deps: ShareRoutesDeps): Hono {
116
119
  // Deliberately not awaited: pruning is housekeeping, and the client is
117
120
  // waiting on the staging result, not on it.
118
121
  void pruneShareStaging(brainRoot).catch((err) => {
119
- console.error("[share] prune failed:", 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
 
@@ -180,7 +183,7 @@ export function createShareRoutes(deps: ShareRoutesDeps): Hono {
180
183
  if (err instanceof ShareTooLargeError) {
181
184
  return c.json({ error: err.reason, limit: err.limit }, 413);
182
185
  }
183
- console.error("[share]", err);
186
+ log?.emit({ severityText: "ERROR", body: "share request failed", attributes: { error: err instanceof Error ? err.message : String(err) } });
184
187
  return c.json({ error: "share_failed" }, 500);
185
188
  } finally {
186
189
  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) => {
@@ -410,11 +417,13 @@ 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: [],
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.
@@ -3,16 +3,32 @@ import { PROTOCOL_REV } from "@schlessera/brain-ui-sdk/protocol";
3
3
  import { parseClientMessage } from "@schlessera/brain-ui-sdk/schemas";
4
4
  import { withTurnScope } from "./frames.js";
5
5
  import { sendSessionHistory } from "./history.js";
6
- import { handleClientMessage } from "./dispatch.js";
6
+ import { handleClientMessage, type ConnectionState } from "./dispatch.js";
7
7
  import type { WsHost } from "./host.js";
8
+ import type { WSContext } from "./clients.js";
8
9
 
9
10
  export { websocket };
10
11
 
11
- /** Build the Hono WebSocket upgrade handler bound to one host. */
12
- export function createWsUpgrade(host: WsHost) {
13
- return upgradeWebSocket(() => ({
14
- async onOpen(_evt, ws) {
15
- console.log("[ws] Client connected");
12
+ /**
13
+ * The socket lifecycle handlers for one host, separate from the Hono upgrade
14
+ * that wraps them.
15
+ *
16
+ * Split out so the real frame path is reachable from a test without standing
17
+ * up an HTTP server: `createWsHandlers(host).onMessage(...)` runs exactly what
18
+ * production runs, which is what makes an assertion about a dropped frame an
19
+ * assertion about the shipped code rather than about a re-implementation.
20
+ */
21
+ export function createWsHandlers(host: WsHost) {
22
+ // One bucket per connection, created here so it lives and dies with the
23
+ // socket rather than in a map keyed by something a peer controls.
24
+ const limiter = host.newRateLimiter();
25
+ // Per-connection negotiation state: what revision this client declared.
26
+ // Lives with the socket, like the limiter.
27
+ const connection: ConnectionState = {};
28
+
29
+ return {
30
+ async onOpen(_evt: Event, ws: WSContext) {
31
+ host.log.emit({ severityText: "INFO", body: "client connected" });
16
32
  const { coordinator, catalog } = host;
17
33
 
18
34
  // Handshake first (rev 2, additive): protocol revision + coarse
@@ -53,7 +69,11 @@ export function createWsUpgrade(host: WsHost) {
53
69
  const history = await backend.getHistory(sid);
54
70
  if (history.length > 0) sendSessionHistory(ws, sid, history);
55
71
  } catch (err) {
56
- console.error("[ws] snapshot-on-connect failed:", err);
72
+ host.log.emit({
73
+ severityText: "ERROR",
74
+ body: "snapshot-on-connect failed",
75
+ attributes: { error: err instanceof Error ? err.message : String(err) },
76
+ });
57
77
  } finally {
58
78
  host.clients.add(ws);
59
79
  host.sendMessage(
@@ -80,7 +100,7 @@ export function createWsUpgrade(host: WsHost) {
80
100
  });
81
101
  },
82
102
 
83
- onMessage(evt, ws) {
103
+ onMessage(evt: MessageEvent, ws: WSContext) {
84
104
  // Boundary validation (rev 2): byte cap + JSON decode + schema, in one
85
105
  // place. No more casting client JSON to ClientMessage.
86
106
  //
@@ -88,8 +108,21 @@ export function createWsUpgrade(host: WsHost) {
88
108
  // underlying POOLED ArrayBuffer (byteOffset/byteLength discarded), so a
89
109
  // binary frame cannot be decoded correctly here. The protocol is JSON
90
110
  // text; reject anything else rather than parse a slab.
111
+ // Metered BEFORE parsing: the point is to bound work an unmetered peer
112
+ // can make this process do, and parsing is most of that work.
113
+ if (limiter && !limiter.take().allowed) {
114
+ host.reportDroppedFrame("rate_limited");
115
+ host.sendMessage(ws, {
116
+ type: "error",
117
+ code: "RATE_LIMITED",
118
+ message: "Too many frames; slow down.",
119
+ });
120
+ return;
121
+ }
122
+
91
123
  const raw = evt.data;
92
124
  if (typeof raw !== "string") {
125
+ host.reportDroppedFrame("binary_frame");
93
126
  host.sendMessage(ws, {
94
127
  type: "error",
95
128
  code: "PARSE_ERROR",
@@ -99,6 +132,9 @@ export function createWsUpgrade(host: WsHost) {
99
132
  }
100
133
  const parsed = parseClientMessage(raw);
101
134
  if (!parsed.ok) {
135
+ // The reason is the parser's own bounded message, never the frame:
136
+ // the payload is caller-supplied and capped at 12 MB.
137
+ host.reportDroppedFrame("parse_error", parsed.error);
102
138
  host.sendMessage(ws, { type: "error", code: "PARSE_ERROR", message: parsed.error });
103
139
  return;
104
140
  }
@@ -106,7 +142,17 @@ export function createWsUpgrade(host: WsHost) {
106
142
  // unhandled rejection with no frame sent.
107
143
  void Promise.resolve()
108
144
  .then(() => handleClientMessage(host, ws, parsed.message))
109
- .catch(() => {
145
+ .catch((err) => {
146
+ // This used to swallow the cause entirely: the client got a generic
147
+ // frame and the server kept no record of what threw.
148
+ host.log.emit({
149
+ severityText: "ERROR",
150
+ body: "client message handler failed",
151
+ attributes: {
152
+ "frame.type": parsed.message.type,
153
+ error: err instanceof Error ? err.message : String(err),
154
+ },
155
+ });
110
156
  host.sendMessage(ws, {
111
157
  type: "error",
112
158
  code: "INTERNAL_ERROR",
@@ -115,8 +161,8 @@ export function createWsUpgrade(host: WsHost) {
115
161
  });
116
162
  },
117
163
 
118
- onClose(_evt, ws) {
119
- console.log("[ws] Client disconnected");
164
+ onClose(_evt: CloseEvent, ws: WSContext) {
165
+ host.log.emit({ severityText: "INFO", body: "client disconnected" });
120
166
  host.clients.remove(ws);
121
167
  // Turns keep running in the background. Only reject pending interactive
122
168
  // requests once the LAST client leaves — while another client remains it
@@ -126,5 +172,10 @@ export function createWsUpgrade(host: WsHost) {
126
172
  host.coordinator.drainPendingForTurn(turn, "Client disconnected");
127
173
  }
128
174
  },
129
- }));
175
+ };
176
+ }
177
+
178
+ /** Build the Hono WebSocket upgrade handler bound to one host. */
179
+ export function createWsUpgrade(host: WsHost) {
180
+ return upgradeWebSocket(() => createWsHandlers(host));
130
181
  }