@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.
- package/README.md +1 -0
- package/dist/agent/backend.d.ts +31 -10
- package/dist/agent/backend.d.ts.map +1 -1
- package/dist/agent/backend.js +86 -26
- package/dist/agent/backend.js.map +1 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +47 -16
- package/dist/app.js.map +1 -1
- package/dist/config/env-core.d.ts +73 -0
- package/dist/config/env-core.d.ts.map +1 -0
- package/dist/config/env-core.js +62 -0
- package/dist/config/env-core.js.map +1 -0
- package/dist/config/env.d.ts +19 -5
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +43 -11
- package/dist/config/env.js.map +1 -1
- package/dist/cron/scheduler.d.ts +17 -0
- package/dist/cron/scheduler.d.ts.map +1 -1
- package/dist/cron/scheduler.js +33 -6
- package/dist/cron/scheduler.js.map +1 -1
- package/dist/db/settings.d.ts +1 -1
- package/dist/db/settings.d.ts.map +1 -1
- package/dist/db/settings.js +2 -2
- package/dist/db/settings.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/middleware/auth.d.ts +5 -0
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +46 -1
- package/dist/middleware/auth.js.map +1 -1
- package/dist/routes/health.d.ts +4 -14
- package/dist/routes/health.d.ts.map +1 -1
- package/dist/routes/health.js +22 -6
- package/dist/routes/health.js.map +1 -1
- package/dist/routes/share.d.ts.map +1 -1
- package/dist/routes/share.js +2 -2
- package/dist/routes/share.js.map +1 -1
- package/dist/voice/keyterm-builder.js +2 -2
- package/dist/voice/keyterm-builder.js.map +1 -1
- package/dist/ws/bridge.d.ts.map +1 -1
- package/dist/ws/bridge.js +17 -2
- package/dist/ws/bridge.js.map +1 -1
- package/dist/ws/clients.d.ts +10 -3
- package/dist/ws/clients.d.ts.map +1 -1
- package/dist/ws/clients.js +16 -4
- package/dist/ws/clients.js.map +1 -1
- package/dist/ws/connection.d.ts +1 -1
- package/dist/ws/connection.d.ts.map +1 -1
- package/dist/ws/connection.js +11 -2
- package/dist/ws/connection.js.map +1 -1
- package/dist/ws/dispatch.d.ts.map +1 -1
- package/dist/ws/dispatch.js +3 -1
- package/dist/ws/dispatch.js.map +1 -1
- package/dist/ws/host.d.ts +36 -0
- package/dist/ws/host.d.ts.map +1 -1
- package/dist/ws/host.js +93 -4
- package/dist/ws/host.js.map +1 -1
- package/dist/ws/run-session.d.ts.map +1 -1
- package/dist/ws/run-session.js +27 -5
- package/dist/ws/run-session.js.map +1 -1
- package/dist/ws/session-catalog.d.ts +2 -1
- package/dist/ws/session-catalog.d.ts.map +1 -1
- package/dist/ws/session-catalog.js +20 -3
- package/dist/ws/session-catalog.js.map +1 -1
- package/dist/ws/turns.d.ts +8 -0
- package/dist/ws/turns.d.ts.map +1 -1
- package/dist/ws/turns.js.map +1 -1
- package/package.json +7 -6
- package/src/agent/backend.ts +123 -29
- package/src/app.ts +55 -16
- package/src/config/env-core.ts +93 -0
- package/src/config/env.ts +61 -17
- package/src/cron/scheduler.ts +48 -14
- package/src/db/settings.ts +2 -2
- package/src/index.ts +6 -0
- package/src/middleware/auth.ts +50 -1
- package/src/routes/health.ts +22 -6
- package/src/routes/share.ts +11 -7
- package/src/voice/keyterm-builder.ts +2 -2
- package/src/ws/bridge.ts +17 -5
- package/src/ws/clients.ts +24 -6
- package/src/ws/connection.ts +11 -2
- package/src/ws/dispatch.ts +3 -1
- package/src/ws/host.ts +115 -6
- package/src/ws/run-session.ts +32 -8
- package/src/ws/session-catalog.ts +21 -3
- package/src/ws/turns.ts +8 -0
package/src/cron/scheduler.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
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",
|
package/src/db/settings.ts
CHANGED
|
@@ -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";
|
package/src/middleware/auth.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/routes/health.ts
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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. */
|
package/src/routes/share.ts
CHANGED
|
@@ -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(
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
/**
|
|
5
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
}
|
package/src/ws/connection.ts
CHANGED
|
@@ -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(
|
|
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
|
package/src/ws/dispatch.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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). */
|