@schlessera/brain-ui-server 0.16.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 (89) hide show
  1. package/README.md +1 -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 +86 -26
  5. package/dist/agent/backend.js.map +1 -1
  6. package/dist/app.d.ts.map +1 -1
  7. package/dist/app.js +47 -16
  8. package/dist/app.js.map +1 -1
  9. package/dist/config/env-core.d.ts +73 -0
  10. package/dist/config/env-core.d.ts.map +1 -0
  11. package/dist/config/env-core.js +62 -0
  12. package/dist/config/env-core.js.map +1 -0
  13. package/dist/config/env.d.ts +19 -5
  14. package/dist/config/env.d.ts.map +1 -1
  15. package/dist/config/env.js +43 -11
  16. package/dist/config/env.js.map +1 -1
  17. package/dist/cron/scheduler.d.ts +17 -0
  18. package/dist/cron/scheduler.d.ts.map +1 -1
  19. package/dist/cron/scheduler.js +33 -6
  20. package/dist/cron/scheduler.js.map +1 -1
  21. package/dist/db/settings.d.ts +1 -1
  22. package/dist/db/settings.d.ts.map +1 -1
  23. package/dist/db/settings.js +2 -2
  24. package/dist/db/settings.js.map +1 -1
  25. package/dist/index.d.ts +2 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +4 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/middleware/auth.d.ts +5 -0
  30. package/dist/middleware/auth.d.ts.map +1 -1
  31. package/dist/middleware/auth.js +46 -1
  32. package/dist/middleware/auth.js.map +1 -1
  33. package/dist/routes/health.d.ts +4 -14
  34. package/dist/routes/health.d.ts.map +1 -1
  35. package/dist/routes/health.js +22 -6
  36. package/dist/routes/health.js.map +1 -1
  37. package/dist/routes/share.d.ts.map +1 -1
  38. package/dist/routes/share.js +2 -2
  39. package/dist/routes/share.js.map +1 -1
  40. package/dist/voice/keyterm-builder.js +2 -2
  41. package/dist/voice/keyterm-builder.js.map +1 -1
  42. package/dist/ws/bridge.d.ts.map +1 -1
  43. package/dist/ws/bridge.js +17 -2
  44. package/dist/ws/bridge.js.map +1 -1
  45. package/dist/ws/clients.d.ts +10 -3
  46. package/dist/ws/clients.d.ts.map +1 -1
  47. package/dist/ws/clients.js +16 -4
  48. package/dist/ws/clients.js.map +1 -1
  49. package/dist/ws/connection.d.ts +1 -1
  50. package/dist/ws/connection.d.ts.map +1 -1
  51. package/dist/ws/connection.js +11 -2
  52. package/dist/ws/connection.js.map +1 -1
  53. package/dist/ws/dispatch.d.ts.map +1 -1
  54. package/dist/ws/dispatch.js +3 -1
  55. package/dist/ws/dispatch.js.map +1 -1
  56. package/dist/ws/host.d.ts +36 -0
  57. package/dist/ws/host.d.ts.map +1 -1
  58. package/dist/ws/host.js +93 -4
  59. package/dist/ws/host.js.map +1 -1
  60. package/dist/ws/run-session.d.ts.map +1 -1
  61. package/dist/ws/run-session.js +27 -5
  62. package/dist/ws/run-session.js.map +1 -1
  63. package/dist/ws/session-catalog.d.ts +2 -1
  64. package/dist/ws/session-catalog.d.ts.map +1 -1
  65. package/dist/ws/session-catalog.js +20 -3
  66. package/dist/ws/session-catalog.js.map +1 -1
  67. package/dist/ws/turns.d.ts +8 -0
  68. package/dist/ws/turns.d.ts.map +1 -1
  69. package/dist/ws/turns.js.map +1 -1
  70. package/package.json +7 -6
  71. package/src/agent/backend.ts +123 -29
  72. package/src/app.ts +55 -16
  73. package/src/config/env-core.ts +93 -0
  74. package/src/config/env.ts +61 -17
  75. package/src/cron/scheduler.ts +48 -14
  76. package/src/db/settings.ts +2 -2
  77. package/src/index.ts +6 -0
  78. package/src/middleware/auth.ts +50 -1
  79. package/src/routes/health.ts +22 -6
  80. package/src/routes/share.ts +11 -7
  81. package/src/voice/keyterm-builder.ts +2 -2
  82. package/src/ws/bridge.ts +17 -5
  83. package/src/ws/clients.ts +24 -6
  84. package/src/ws/connection.ts +11 -2
  85. package/src/ws/dispatch.ts +3 -1
  86. package/src/ws/host.ts +115 -6
  87. package/src/ws/run-session.ts +32 -8
  88. package/src/ws/session-catalog.ts +21 -3
  89. package/src/ws/turns.ts +8 -0
@@ -5,7 +5,8 @@ import type { BrainClient } from "../brain/client.js";
5
5
  // Scheduling is owned by the container crontab (config/crontab) — sync at
6
6
  // 02:00, validate at 03:00, job scrape at 04:00. This module only provides
7
7
  // manual triggers (triggerJob) and run history for the /api/status endpoint.
8
- // Runs started by the crontab do not appear here; they log to syslog.
8
+ // Runs started by the crontab report themselves via recordCronRun below;
9
+ // their stdout/stderr still goes to syslog.
9
10
 
10
11
  export interface CronScheduler {
11
12
  getCronStatus(): Array<{
@@ -23,6 +24,46 @@ interface CronJob {
23
24
  handler: () => Promise<void>;
24
25
  }
25
26
 
27
+ /** One in-flight job run being recorded into `cron_runs`. */
28
+ export interface CronRunRecord {
29
+ /** Mark the run finished: success without an argument, error with one. */
30
+ finish(error?: string): void;
31
+ }
32
+
33
+ /**
34
+ * Record a job run in the `cron_runs` history that `/api/status` serves
35
+ * (`cronJobs`, via {@link CronScheduler.getCronStatus}).
36
+ *
37
+ * The scheduler in this module only writes the table for its own manual
38
+ * triggers; in the shipped deployment the jobs are executed by the container
39
+ * crontab, whose runs were invisible to `/api/status`. An external scheduler's
40
+ * wrapper calls this before the job and `finish()` after, writing exactly the
41
+ * rows the in-process runner writes. A run whose process died before
42
+ * `finish()` stays in status "running" — itself a signal.
43
+ */
44
+ export function recordCronRun(db: Database, jobName: string): CronRunRecord {
45
+ const startedAt = Date.now();
46
+ db.prepare(
47
+ "INSERT INTO cron_runs (job_name, started_at, status) VALUES (?, ?, 'running')"
48
+ ).run(jobName, startedAt);
49
+
50
+ return {
51
+ finish(error?: string) {
52
+ const finishedAt = Date.now();
53
+ const durationMs = finishedAt - startedAt;
54
+ if (error === undefined) {
55
+ db.prepare(
56
+ "UPDATE cron_runs SET status = 'success', finished_at = ?, duration_ms = ? WHERE job_name = ? AND started_at = ?"
57
+ ).run(finishedAt, durationMs, jobName, startedAt);
58
+ } else {
59
+ db.prepare(
60
+ "UPDATE cron_runs SET status = 'error', finished_at = ?, duration_ms = ?, error_message = ? WHERE job_name = ? AND started_at = ?"
61
+ ).run(finishedAt, durationMs, error, jobName, startedAt);
62
+ }
63
+ },
64
+ };
65
+ }
66
+
26
67
  export function createCronScheduler(deps: {
27
68
  db: Database;
28
69
  brain: BrainClient;
@@ -49,28 +90,21 @@ export function createCronScheduler(deps: {
49
90
 
50
91
  async function runJob(job: CronJob) {
51
92
  const startedAt = Date.now();
52
-
53
- db.prepare(
54
- "INSERT INTO cron_runs (job_name, started_at, status) VALUES (?, ?, 'running')"
55
- ).run(job.name, startedAt);
93
+ // Same recorder the external-scheduler path uses, so the two can never
94
+ // drift in what a cron_runs row looks like.
95
+ const record = recordCronRun(db, job.name);
56
96
 
57
97
  try {
58
98
  await job.handler();
59
- const durationMs = Date.now() - startedAt;
60
- db.prepare(
61
- "UPDATE cron_runs SET status = 'success', finished_at = ?, duration_ms = ? WHERE job_name = ? AND started_at = ?"
62
- ).run(Date.now(), durationMs, job.name, startedAt);
99
+ record.finish();
63
100
  log?.emit({
64
101
  severityText: "INFO",
65
102
  body: "job completed",
66
- attributes: { job: job.name, "duration.ms": durationMs },
103
+ attributes: { job: job.name, "duration.ms": Date.now() - startedAt },
67
104
  });
68
105
  } catch (err) {
69
- const durationMs = Date.now() - startedAt;
70
106
  const message = err instanceof Error ? err.message : String(err);
71
- db.prepare(
72
- "UPDATE cron_runs SET status = 'error', finished_at = ?, duration_ms = ?, error_message = ? WHERE job_name = ? AND started_at = ?"
73
- ).run(Date.now(), durationMs, message, job.name, startedAt);
107
+ record.finish(message);
74
108
  log?.emit({
75
109
  severityText: "ERROR",
76
110
  body: "job failed",
@@ -36,8 +36,8 @@ export function setSetting(db: Database, key: string, value: unknown): void {
36
36
  }
37
37
 
38
38
  /** Profile ids the user keeps out of the model picker. */
39
- export function getHiddenModelIds(db: Database): string[] {
40
- const value = getSetting<unknown>(db, HIDDEN_MODELS_KEY, []);
39
+ export function getHiddenModelIds(db: Database, log?: Logger): string[] {
40
+ const value = getSetting<unknown>(db, HIDDEN_MODELS_KEY, [], log);
41
41
  if (!Array.isArray(value)) return [];
42
42
  return value.filter((id): id is string => typeof id === "string");
43
43
  }
package/src/index.ts CHANGED
@@ -34,6 +34,7 @@ export {
34
34
  assertBackendResolvable,
35
35
  createBackendRegistry,
36
36
  createStaticBackendRegistry,
37
+ type BackendLogFn,
37
38
  type BackendRegistry,
38
39
  type ModelDiscoverySource,
39
40
  type ModelDiscoveryState,
@@ -74,6 +75,11 @@ export {
74
75
  // their own routes on top.
75
76
  export { createBrainClient, type BrainClient } from "./brain/client.js";
76
77
 
78
+ // Cron run history. Scheduling belongs to the deployment (container crontab);
79
+ // an external scheduler's wrapper records each run here so /api/status's
80
+ // `cronJobs` reflects what actually ran.
81
+ export { recordCronRun, type CronRunRecord } from "./cron/scheduler.js";
82
+
77
83
  // Share staging: a deployment can sweep expired staging dirs at boot; the
78
84
  // intake route also sweeps opportunistically on every share.
79
85
  export { pruneShareStaging, shareStagingRoot } from "./share/staging.js";
@@ -1,3 +1,4 @@
1
+ import type { Counter } from "@opentelemetry/api";
1
2
  import type { Logger } from "@opentelemetry/api-logs";
2
3
  import { Hono } from "hono";
3
4
  import type { Context, MiddlewareHandler } from "hono";
@@ -289,9 +290,14 @@ export function authRoutes(
289
290
  * of WebAuthn concerns.
290
291
  */
291
292
  passwordDisabled?: (c: Context) => boolean;
293
+ /** Where login outcomes are reported; absent means silence. */
294
+ log?: Logger;
295
+ /** Failed-login counter — the same `auth.failures` instrument passkeys use. */
296
+ failures?: Counter;
292
297
  } = {}
293
298
  ): Hono {
294
299
  const app = new Hono();
300
+ const { log, failures } = deps;
295
301
 
296
302
  app.post("/auth/login", async (c) => {
297
303
  if (mode !== "password") {
@@ -302,6 +308,12 @@ export function authRoutes(
302
308
  const perIpOk = consumeLoginToken(`ip:${key}`, LOGIN_RATE_LIMIT);
303
309
  const globalOk = consumeLoginToken("global", GLOBAL_LOGIN_RATE_LIMIT);
304
310
  if (!perIpOk || !globalOk) {
311
+ failures?.add(1, { reason: "rate_limited", method: "password" });
312
+ log?.emit({
313
+ severityText: "WARN",
314
+ body: "login rate limited",
315
+ attributes: { ip: key, limit: perIpOk ? "global" : "ip" },
316
+ });
305
317
  return c.json({ error: "Too many attempts. Try again in a minute." }, 429);
306
318
  }
307
319
 
@@ -322,15 +334,52 @@ export function authRoutes(
322
334
  }
323
335
  const password = typeof body.password === "string" ? body.password : "";
324
336
  if (!password || !hash || !secret) {
337
+ failures?.add(1, { reason: "invalid_password", method: "password" });
338
+ log?.emit({
339
+ severityText: "WARN",
340
+ body: "login failed",
341
+ attributes: { ip: key },
342
+ });
325
343
  return c.json({ error: "Invalid credentials" }, 401);
326
344
  }
327
345
 
328
- const ok = await Bun.password.verify(password, hash).catch(() => false);
346
+ // A verify that THROWS is not a wrong password — it is a hash Bun cannot
347
+ // parse (a corrupt BRAIN_UI_PASSWORD_HASH locks the owner out of every
348
+ // login), and collapsing it into "invalid credentials" hid exactly that.
349
+ let ok = false;
350
+ let verifyError: unknown = null;
351
+ try {
352
+ ok = await Bun.password.verify(password, hash);
353
+ } catch (err) {
354
+ verifyError = err;
355
+ }
356
+ if (verifyError) {
357
+ failures?.add(1, { reason: "verify_error", method: "password" });
358
+ log?.emit({
359
+ severityText: "ERROR",
360
+ body: "password verification errored — check BRAIN_UI_PASSWORD_HASH",
361
+ attributes: {
362
+ error: verifyError instanceof Error ? verifyError.message : String(verifyError),
363
+ },
364
+ });
365
+ return c.json({ error: "Invalid credentials" }, 401);
366
+ }
329
367
  if (!ok) {
368
+ failures?.add(1, { reason: "invalid_password", method: "password" });
369
+ log?.emit({
370
+ severityText: "WARN",
371
+ body: "login failed",
372
+ attributes: { ip: key },
373
+ });
330
374
  return c.json({ error: "Invalid credentials" }, 401);
331
375
  }
332
376
 
333
377
  await issueSessionCookie(c, auth);
378
+ log?.emit({
379
+ severityText: "INFO",
380
+ body: "login succeeded",
381
+ attributes: { ip: key },
382
+ });
334
383
  return c.json({ ok: true });
335
384
  });
336
385
 
@@ -1,4 +1,5 @@
1
1
  import { Hono } from "hono";
2
+ import type { Database } from "bun:sqlite";
2
3
 
3
4
  import type { MetricSnapshot } from "../observability/index.js";
4
5
 
@@ -7,13 +8,28 @@ const startTime = Date.now();
7
8
  // Public liveness probe. Deliberately minimal: no version/commit, no cron
8
9
  // detail, no session oracle — this route sits in front of the auth guard and
9
10
  // is reachable by anyone on the origin.
10
- export const healthRoutes = new Hono().get("/health", (c) => {
11
- return c.json({
12
- status: "healthy",
13
- uptime: Date.now() - startTime,
14
- 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
+ });
15
31
  });
16
- });
32
+ }
17
33
 
18
34
  export interface StatusDeps {
19
35
  /** Git SHA baked at build time (SOURCE_COMMIT), "dev" when unset. */
@@ -118,7 +118,7 @@ export function createShareRoutes(deps: ShareRoutesDeps): Hono {
118
118
  lastPrune = now;
119
119
  // Deliberately not awaited: pruning is housekeeping, and the client is
120
120
  // waiting on the staging result, not on it.
121
- void pruneShareStaging(brainRoot).catch((err) => {
121
+ void pruneShareStaging(brainRoot, Date.now(), log).catch((err) => {
122
122
  log?.emit({ severityText: "ERROR", body: "share staging prune failed", attributes: { error: err instanceof Error ? err.message : String(err) } });
123
123
  });
124
124
  }
@@ -167,12 +167,16 @@ export function createShareRoutes(deps: ShareRoutesDeps): Hono {
167
167
 
168
168
  inFlight += 1;
169
169
  try {
170
- const result = await stageShare(brainRoot, {
171
- title: firstString(form, "title"),
172
- text: firstString(form, "text"),
173
- url: firstString(form, "url"),
174
- files,
175
- });
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
+ );
176
180
 
177
181
  maybePrune();
178
182
  return c.json(result, 201);
@@ -405,7 +405,7 @@ export function buildKeyterms(settings: KeytermSettings): KeytermsCache {
405
405
  keyterms,
406
406
  generatedAt: Date.now(),
407
407
  count: keyterms.length,
408
- overrides: loadOverrides(brainPath),
408
+ overrides: loadOverrides(brainPath, log),
409
409
  };
410
410
  });
411
411
  } catch (err) {
@@ -429,7 +429,7 @@ export function buildKeyterms(settings: KeytermSettings): KeytermsCache {
429
429
  keyterms: [],
430
430
  generatedAt: Date.now(),
431
431
  count: 0,
432
- overrides: loadOverrides(brainPath),
432
+ overrides: loadOverrides(brainPath, log),
433
433
  degraded: true,
434
434
  };
435
435
  }
package/src/ws/bridge.ts CHANGED
@@ -38,6 +38,16 @@ export function makeBridge(
38
38
  }
39
39
  host.sendToClients(withTurnScope(msg, turn, turnId));
40
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
+ }
41
51
  catalog.persistSession(msg, promptText, turn.providerId, backendId);
42
52
  }
43
53
  },
@@ -144,8 +154,10 @@ export function makeBridge(
144
154
 
145
155
  export function emitTurnError(host: WsHost, turn: RunningTurn, err: unknown): void {
146
156
  // startTurn resolves for runtime failures (it emits its own error frame); it
147
- // 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.
148
159
  if (err instanceof BackendBusyError) {
160
+ host.reportTurnFailed("SESSION_BUSY", turn);
149
161
  host.sendToClients(
150
162
  withTurnScope(
151
163
  { type: "error", code: "SESSION_BUSY", message: "That session already has a running turn." },
@@ -153,15 +165,15 @@ export function emitTurnError(host: WsHost, turn: RunningTurn, err: unknown): vo
153
165
  )
154
166
  );
155
167
  } else if (err instanceof BackendRequestError) {
168
+ host.reportTurnFailed("BACKEND_REQUEST_ERROR", turn, err.message);
156
169
  host.sendToClients(
157
170
  withTurnScope({ type: "error", code: "BACKEND_REQUEST_ERROR", message: err.message }, turn)
158
171
  );
159
172
  } else {
173
+ const message = err instanceof Error ? err.message : String(err);
174
+ host.reportTurnFailed("BACKEND_ERROR", turn, message);
160
175
  host.sendToClients(
161
- withTurnScope(
162
- { type: "error", code: "BACKEND_ERROR", message: err instanceof Error ? err.message : String(err) },
163
- turn
164
- )
176
+ withTurnScope({ type: "error", code: "BACKEND_ERROR", message }, turn)
165
177
  );
166
178
  }
167
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
  }
@@ -141,7 +141,7 @@ export function createWsHandlers(host: WsHost) {
141
141
  // handleClientMessage is async — a rejection must not escape as an
142
142
  // unhandled rejection with no frame sent.
143
143
  void Promise.resolve()
144
- .then(() => handleClientMessage(host, ws, parsed.message))
144
+ .then(() => handleClientMessage(host, ws, parsed.message, connection))
145
145
  .catch((err) => {
146
146
  // This used to swallow the cause entirely: the client got a generic
147
147
  // frame and the server kept no record of what threw.
@@ -161,8 +161,17 @@ export function createWsHandlers(host: WsHost) {
161
161
  });
162
162
  },
163
163
 
164
- onClose(_evt: CloseEvent, ws: WSContext) {
164
+ onClose(evt: CloseEvent, ws: WSContext) {
165
165
  host.log.emit({ severityText: "INFO", body: "client disconnected" });
166
+ // No onError here on purpose: hono's Bun adapter never dispatches it
167
+ // (only open/message/close reach these handlers), so a transport failure
168
+ // is only visible as an abnormal close code. 1000/1001 are the two
169
+ // clean endings (normal closure, going away); anything else — before it
170
+ // was recorded — looked exactly like a clean disconnect.
171
+ const code = (evt as { code?: unknown }).code;
172
+ if (typeof code === "number" && code !== 1000 && code !== 1001) {
173
+ host.reportAbnormalClose(code);
174
+ }
166
175
  host.clients.remove(ws);
167
176
  // Turns keep running in the background. Only reject pending interactive
168
177
  // requests once the LAST client leaves — while another client remains it
@@ -202,10 +202,12 @@ export async function handleClientMessage(
202
202
  ...(runningTurn ? { turnId: runningTurn.turnId } : {}),
203
203
  });
204
204
  } catch (err) {
205
+ const message = err instanceof Error ? err.message : "Failed to load session";
206
+ host.reportTurnFailed("SESSION_LOAD_ERROR", { sessionId: msg.sessionId }, message);
205
207
  host.sendMessage(ws, {
206
208
  type: "error",
207
209
  code: "SESSION_LOAD_ERROR",
208
- message: err instanceof Error ? err.message : "Failed to load session",
210
+ message,
209
211
  sessionId: msg.sessionId,
210
212
  });
211
213
  }
package/src/ws/host.ts CHANGED
@@ -67,6 +67,25 @@ export interface WsHostOptions {
67
67
  wsRate?: { ratePerSecond: number; burst: number };
68
68
  }
69
69
 
70
+ /** Identity of one turn, as it appears on a log record. */
71
+ export interface TurnLogContext {
72
+ sessionId?: string | null;
73
+ turnId?: string | null;
74
+ providerId?: string | null;
75
+ }
76
+
77
+ /**
78
+ * Log attributes for one turn. Null fields are omitted rather than stringified
79
+ * — a new session has no sessionId until `session_info` names it.
80
+ */
81
+ export function turnLogAttributes(turn: TurnLogContext): Record<string, string> {
82
+ return {
83
+ ...(turn.sessionId ? { "session.id": turn.sessionId } : {}),
84
+ ...(turn.turnId ? { "turn.id": turn.turnId } : {}),
85
+ ...(turn.providerId ? { profile: turn.providerId } : {}),
86
+ };
87
+ }
88
+
70
89
  /**
71
90
  * Everything one WebSocket coordinator instance owns: turn state, the session
72
91
  * catalog, the backend registry, the attached client sockets, branding copy,
@@ -88,6 +107,18 @@ export class WsHost {
88
107
  private readonly framesDropped: ReturnType<
89
108
  ReturnType<Observability["meter"]>["createCounter"]
90
109
  >;
110
+ private readonly turnsStarted: ReturnType<
111
+ ReturnType<Observability["meter"]>["createCounter"]
112
+ >;
113
+ private readonly turnsCompleted: ReturnType<
114
+ ReturnType<Observability["meter"]>["createCounter"]
115
+ >;
116
+ private readonly turnsFailed: ReturnType<
117
+ ReturnType<Observability["meter"]>["createCounter"]
118
+ >;
119
+ private readonly wsErrors: ReturnType<
120
+ ReturnType<Observability["meter"]>["createCounter"]
121
+ >;
91
122
 
92
123
  constructor(options: WsHostOptions) {
93
124
  this.registry = options.registry;
@@ -100,11 +131,22 @@ export class WsHost {
100
131
  this.wsRate =
101
132
  options.wsRate && options.wsRate.ratePerSecond > 0 ? options.wsRate : null;
102
133
  this.log = this.observability.logger("ws");
103
- this.framesDropped = this.observability
104
- .meter("ws")
105
- .createCounter("ws.frames.dropped", {
106
- description: "Inbound frames refused before reaching a handler",
107
- });
134
+ const meter = this.observability.meter("ws");
135
+ this.framesDropped = meter.createCounter("ws.frames.dropped", {
136
+ description: "Inbound frames refused before reaching a handler",
137
+ });
138
+ this.turnsStarted = meter.createCounter("turns.started", {
139
+ description: "Turns handed to a backend",
140
+ });
141
+ this.turnsCompleted = meter.createCounter("turns.completed", {
142
+ description: "Turns whose backend call resolved without throwing",
143
+ });
144
+ this.turnsFailed = meter.createCounter("turns.failed", {
145
+ description: "Turn failures surfaced to the client, by error code",
146
+ });
147
+ this.wsErrors = meter.createCounter("ws.errors", {
148
+ description: "Transport errors reported by the socket layer",
149
+ });
108
150
  this.coordinator.log = this.log;
109
151
  }
110
152
 
@@ -130,9 +172,76 @@ export class WsHost {
130
172
  });
131
173
  }
132
174
 
175
+ /** A turn began executing: counted, and logged with its correlation ids. */
176
+ reportTurnStarted(turn: TurnLogContext): void {
177
+ this.turnsStarted.add(1);
178
+ this.log.emit({
179
+ severityText: "INFO",
180
+ body: "turn started",
181
+ attributes: turnLogAttributes(turn),
182
+ });
183
+ }
184
+
185
+ /** A turn's backend call resolved: counted, and logged with its duration. */
186
+ reportTurnCompleted(turn: TurnLogContext, durationMs: number): void {
187
+ this.turnsCompleted.add(1);
188
+ this.log.emit({
189
+ severityText: "INFO",
190
+ body: "turn completed",
191
+ attributes: { ...turnLogAttributes(turn), "duration.ms": durationMs },
192
+ });
193
+ }
194
+
195
+ /**
196
+ * Record a turn failure the client is being told about. Before this existed
197
+ * every such failure was an error FRAME only — visible on one phone screen,
198
+ * absent from the server's own record.
199
+ *
200
+ * `code` is the bounded error-frame code (it feeds a counter attribute);
201
+ * `error` is the thrown message and rides only on the log record — never a
202
+ * caller-supplied frame body, per the reportDroppedFrame model.
203
+ */
204
+ reportTurnFailed(code: string, turn: TurnLogContext, error?: string): void {
205
+ this.turnsFailed.add(1, { code });
206
+ this.log.emit({
207
+ // A busy session is the client racing itself; everything else is a
208
+ // failure the operator should see.
209
+ severityText: code === "SESSION_BUSY" ? "WARN" : "ERROR",
210
+ body: "turn failed",
211
+ attributes: {
212
+ code,
213
+ ...turnLogAttributes(turn),
214
+ ...(error ? { error } : {}),
215
+ },
216
+ });
217
+ }
218
+
219
+ /**
220
+ * Record a socket that closed abnormally. This is the transport-error signal
221
+ * available on Bun: hono's Bun adapter dispatches only open/message/close
222
+ * (never WSEvents.onError, and Bun's ServerWebSocket has no error callback),
223
+ * so a transport failure surfaces as a close with an abnormal code.
224
+ */
225
+ reportAbnormalClose(code: number): void {
226
+ this.wsErrors.add(1, { "close.code": code });
227
+ this.log.emit({
228
+ severityText: "WARN",
229
+ body: "websocket closed abnormally",
230
+ attributes: { "close.code": code },
231
+ });
232
+ }
233
+
133
234
  /** Fan a frame out to every attached client (size-bounded per frame). */
134
235
  sendToClients(msg: ServerMessage): void {
135
- this.clients.broadcast(msg);
236
+ this.clients.broadcast(msg, () => {
237
+ // Outbound counterpart of reportDroppedFrame: the peer never saw this
238
+ // frame. Counted only — a dead socket would otherwise WARN per frame
239
+ // until its onClose prunes it.
240
+ this.framesDropped.add(1, {
241
+ reason: "broadcast_send_failed",
242
+ direction: "outbound",
243
+ });
244
+ });
136
245
  }
137
246
 
138
247
  /** Send a frame to one specific socket (size-bounded). */