@schlessera/brain-ui-server 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/agent/backend.d.ts +31 -10
- package/dist/agent/backend.d.ts.map +1 -1
- package/dist/agent/backend.js +92 -28
- package/dist/agent/backend.js.map +1 -1
- package/dist/app.d.ts +9 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +80 -18
- 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 +27 -5
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +90 -10
- package/dist/config/env.js.map +1 -1
- package/dist/cron/scheduler.d.ts +20 -0
- package/dist/cron/scheduler.d.ts.map +1 -1
- package/dist/cron/scheduler.js +43 -8
- package/dist/cron/scheduler.js.map +1 -1
- package/dist/db/client.d.ts +10 -1
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +5 -14
- package/dist/db/client.js.map +1 -1
- package/dist/db/settings.d.ts +3 -2
- package/dist/db/settings.d.ts.map +1 -1
- package/dist/db/settings.js +8 -4
- package/dist/db/settings.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/middleware/auth.d.ts +8 -2
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +74 -10
- package/dist/middleware/auth.js.map +1 -1
- package/dist/middleware/passkeys.d.ts +10 -0
- package/dist/middleware/passkeys.d.ts.map +1 -1
- package/dist/middleware/passkeys.js +19 -3
- package/dist/middleware/passkeys.js.map +1 -1
- package/dist/observability/index.d.ts +73 -0
- package/dist/observability/index.d.ts.map +1 -0
- package/dist/observability/index.js +74 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/observability/loggers.d.ts +62 -0
- package/dist/observability/loggers.d.ts.map +1 -0
- package/dist/observability/loggers.js +109 -0
- package/dist/observability/loggers.js.map +1 -0
- package/dist/observability/meter.d.ts +39 -0
- package/dist/observability/meter.d.ts.map +1 -0
- package/dist/observability/meter.js +95 -0
- package/dist/observability/meter.js.map +1 -0
- package/dist/observability/types.d.ts +67 -0
- package/dist/observability/types.d.ts.map +1 -0
- package/dist/observability/types.js +44 -0
- package/dist/observability/types.js.map +1 -0
- package/dist/routes/brain.d.ts.map +1 -1
- package/dist/routes/brain.js +9 -1
- package/dist/routes/brain.js.map +1 -1
- package/dist/routes/files.d.ts +2 -0
- package/dist/routes/files.d.ts.map +1 -1
- package/dist/routes/files.js +7 -7
- package/dist/routes/files.js.map +1 -1
- package/dist/routes/graph.d.ts +2 -0
- package/dist/routes/graph.d.ts.map +1 -1
- package/dist/routes/graph.js +7 -7
- package/dist/routes/graph.js.map +1 -1
- package/dist/routes/health.d.ts +7 -14
- package/dist/routes/health.d.ts.map +1 -1
- package/dist/routes/health.js +25 -6
- package/dist/routes/health.js.map +1 -1
- package/dist/routes/models.d.ts +3 -0
- package/dist/routes/models.d.ts.map +1 -1
- package/dist/routes/models.js +5 -1
- package/dist/routes/models.js.map +1 -1
- package/dist/routes/render.d.ts +2 -1
- package/dist/routes/render.d.ts.map +1 -1
- package/dist/routes/render.js +2 -2
- package/dist/routes/render.js.map +1 -1
- package/dist/routes/share.d.ts +3 -0
- package/dist/routes/share.d.ts.map +1 -1
- package/dist/routes/share.js +5 -5
- package/dist/routes/share.js.map +1 -1
- package/dist/share/staging.d.ts +3 -2
- package/dist/share/staging.d.ts.map +1 -1
- package/dist/share/staging.js +12 -4
- package/dist/share/staging.js.map +1 -1
- package/dist/voice/keyterm-builder.d.ts +4 -1
- package/dist/voice/keyterm-builder.d.ts.map +1 -1
- package/dist/voice/keyterm-builder.js +15 -8
- package/dist/voice/keyterm-builder.js.map +1 -1
- package/dist/ws/bridge.d.ts.map +1 -1
- package/dist/ws/bridge.js +23 -3
- 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 +15 -0
- package/dist/ws/connection.d.ts.map +1 -1
- package/dist/ws/connection.js +66 -10
- package/dist/ws/connection.js.map +1 -1
- package/dist/ws/dispatch.d.ts +22 -1
- package/dist/ws/dispatch.d.ts.map +1 -1
- package/dist/ws/dispatch.js +36 -16
- package/dist/ws/dispatch.js.map +1 -1
- package/dist/ws/host.d.ts +73 -0
- package/dist/ws/host.d.ts.map +1 -1
- package/dist/ws/host.js +127 -1
- package/dist/ws/host.js.map +1 -1
- package/dist/ws/rate-limit.d.ts +58 -0
- package/dist/ws/rate-limit.d.ts.map +1 -0
- package/dist/ws/rate-limit.js +62 -0
- package/dist/ws/rate-limit.js.map +1 -0
- package/dist/ws/run-session.d.ts +3 -0
- package/dist/ws/run-session.d.ts.map +1 -1
- package/dist/ws/run-session.js +49 -9
- 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 +21 -0
- package/dist/ws/turns.d.ts.map +1 -1
- package/dist/ws/turns.js +11 -1
- package/dist/ws/turns.js.map +1 -1
- package/package.json +9 -6
- package/src/agent/backend.ts +131 -35
- package/src/app.ts +97 -18
- package/src/config/env-core.ts +93 -0
- package/src/config/env.ts +116 -15
- package/src/cron/scheduler.ts +61 -16
- package/src/db/client.ts +16 -5
- package/src/db/settings.ts +9 -4
- package/src/index.ts +43 -2
- package/src/middleware/auth.ts +80 -15
- package/src/middleware/passkeys.ts +29 -5
- package/src/observability/index.ts +150 -0
- package/src/observability/loggers.ts +174 -0
- package/src/observability/meter.ts +190 -0
- package/src/observability/types.ts +101 -0
- package/src/routes/brain.ts +10 -1
- package/src/routes/files.ts +9 -8
- package/src/routes/graph.ts +9 -8
- package/src/routes/health.ts +29 -6
- package/src/routes/models.ts +8 -5
- package/src/routes/render.ts +3 -2
- package/src/routes/share.ts +17 -10
- package/src/share/staging.ts +18 -4
- package/src/voice/keyterm-builder.ts +19 -10
- package/src/ws/bridge.ts +22 -6
- package/src/ws/clients.ts +24 -6
- package/src/ws/connection.ts +73 -13
- package/src/ws/dispatch.ts +47 -16
- package/src/ws/host.ts +164 -1
- package/src/ws/rate-limit.ts +86 -0
- package/src/ws/run-session.ts +57 -14
- package/src/ws/session-catalog.ts +21 -3
- package/src/ws/turns.ts +27 -3
package/src/routes/brain.ts
CHANGED
|
@@ -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
|
|
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 });
|
package/src/routes/files.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|
package/src/routes/graph.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|
package/src/routes/health.ts
CHANGED
|
@@ -1,23 +1,43 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
+
import type { Database } from "bun:sqlite";
|
|
3
|
+
|
|
4
|
+
import type { MetricSnapshot } from "../observability/index.js";
|
|
2
5
|
|
|
3
6
|
const startTime = Date.now();
|
|
4
7
|
|
|
5
8
|
// Public liveness probe. Deliberately minimal: no version/commit, no cron
|
|
6
9
|
// detail, no session oracle — this route sits in front of the auth guard and
|
|
7
10
|
// is reachable by anyone on the origin.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
//
|
|
12
|
+
// "Healthy" means the app's own SQLite handle answers a real read, not merely
|
|
13
|
+
// that the process accepts connections: the Docker healthcheck gates on this
|
|
14
|
+
// route, and a wedged database previously kept reporting healthy while every
|
|
15
|
+
// stateful route failed. The probe reads sqlite_master rather than a bare
|
|
16
|
+
// SELECT 1 — a constant expression touches no page of the database file, so it
|
|
17
|
+
// cannot notice a locked or corrupted one. The unhealthy body carries no
|
|
18
|
+
// detail — the route is public.
|
|
19
|
+
export function createHealthRoutes(deps: { db: Database }): Hono {
|
|
20
|
+
return new Hono().get("/health", (c) => {
|
|
21
|
+
try {
|
|
22
|
+
deps.db.query("SELECT name FROM sqlite_master LIMIT 1").get();
|
|
23
|
+
} catch {
|
|
24
|
+
return c.json({ status: "unhealthy" }, 503);
|
|
25
|
+
}
|
|
26
|
+
return c.json({
|
|
27
|
+
status: "healthy",
|
|
28
|
+
uptime: Date.now() - startTime,
|
|
29
|
+
timestamp: new Date().toISOString(),
|
|
30
|
+
});
|
|
13
31
|
});
|
|
14
|
-
}
|
|
32
|
+
}
|
|
15
33
|
|
|
16
34
|
export interface StatusDeps {
|
|
17
35
|
/** Git SHA baked at build time (SOURCE_COMMIT), "dev" when unset. */
|
|
18
36
|
sourceCommit: string;
|
|
19
37
|
getCronStatus(): unknown;
|
|
20
38
|
isTurnActive(): boolean;
|
|
39
|
+
/** Recorded counters; undefined when the consumer cannot be read back. */
|
|
40
|
+
getMetrics?(): MetricSnapshot | undefined;
|
|
21
41
|
}
|
|
22
42
|
|
|
23
43
|
// Operational status. Registered BEHIND the auth guard: it exposes the git SHA,
|
|
@@ -31,6 +51,9 @@ export function createStatusRoutes(deps: StatusDeps): Hono {
|
|
|
31
51
|
version: deps.sourceCommit,
|
|
32
52
|
cronJobs: deps.getCronStatus(),
|
|
33
53
|
activeSession: deps.isTurnActive(),
|
|
54
|
+
// Counters the server recorded this process lifetime — dropped frames,
|
|
55
|
+
// handler failures. Behind the auth guard with everything else here.
|
|
56
|
+
metrics: deps.getMetrics?.() ?? [],
|
|
34
57
|
});
|
|
35
58
|
});
|
|
36
59
|
}
|
package/src/routes/models.ts
CHANGED
|
@@ -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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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());
|
package/src/routes/render.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/routes/share.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Logger } from "@opentelemetry/api-logs";
|
|
1
2
|
import { Hono } from "hono";
|
|
2
3
|
import {
|
|
3
4
|
SHARE_MAX_CONCURRENT_INTAKE,
|
|
@@ -93,10 +94,12 @@ export interface ShareRoutesDeps {
|
|
|
93
94
|
brainRoot: string;
|
|
94
95
|
/** ALLOWED_ORIGINS — the same-origin check's split-topology allowlist. */
|
|
95
96
|
allowedOrigins: string[];
|
|
97
|
+
/** Where failures are reported; absent means silence. */
|
|
98
|
+
log?: Logger;
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
export function createShareRoutes(deps: ShareRoutesDeps): Hono {
|
|
99
|
-
const { brainRoot, allowedOrigins } = deps;
|
|
102
|
+
const { brainRoot, allowedOrigins, log } = deps;
|
|
100
103
|
|
|
101
104
|
/**
|
|
102
105
|
* In-flight intakes. Each one holds its whole payload in memory while the
|
|
@@ -115,8 +118,8 @@ export function createShareRoutes(deps: ShareRoutesDeps): Hono {
|
|
|
115
118
|
lastPrune = now;
|
|
116
119
|
// Deliberately not awaited: pruning is housekeeping, and the client is
|
|
117
120
|
// waiting on the staging result, not on it.
|
|
118
|
-
void pruneShareStaging(brainRoot).catch((err) => {
|
|
119
|
-
|
|
121
|
+
void pruneShareStaging(brainRoot, Date.now(), log).catch((err) => {
|
|
122
|
+
log?.emit({ severityText: "ERROR", body: "share staging prune failed", attributes: { error: err instanceof Error ? err.message : String(err) } });
|
|
120
123
|
});
|
|
121
124
|
}
|
|
122
125
|
|
|
@@ -164,12 +167,16 @@ export function createShareRoutes(deps: ShareRoutesDeps): Hono {
|
|
|
164
167
|
|
|
165
168
|
inFlight += 1;
|
|
166
169
|
try {
|
|
167
|
-
const result = await stageShare(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
170
|
+
const result = await stageShare(
|
|
171
|
+
brainRoot,
|
|
172
|
+
{
|
|
173
|
+
title: firstString(form, "title"),
|
|
174
|
+
text: firstString(form, "text"),
|
|
175
|
+
url: firstString(form, "url"),
|
|
176
|
+
files,
|
|
177
|
+
},
|
|
178
|
+
log
|
|
179
|
+
);
|
|
173
180
|
|
|
174
181
|
maybePrune();
|
|
175
182
|
return c.json(result, 201);
|
|
@@ -180,7 +187,7 @@ export function createShareRoutes(deps: ShareRoutesDeps): Hono {
|
|
|
180
187
|
if (err instanceof ShareTooLargeError) {
|
|
181
188
|
return c.json({ error: err.reason, limit: err.limit }, 413);
|
|
182
189
|
}
|
|
183
|
-
|
|
190
|
+
log?.emit({ severityText: "ERROR", body: "share request failed", attributes: { error: err instanceof Error ? err.message : String(err) } });
|
|
184
191
|
return c.json({ error: "share_failed" }, 500);
|
|
185
192
|
} finally {
|
|
186
193
|
inFlight -= 1;
|
package/src/share/staging.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
381
|
+
log?.emit({
|
|
382
|
+
severityText: "WARN",
|
|
383
|
+
body: "failed to load pronunciation overrides",
|
|
384
|
+
attributes: { error: err instanceof Error ? err.message : String(err) },
|
|
385
|
+
});
|
|
379
386
|
return [];
|
|
380
387
|
}
|
|
381
388
|
}
|
|
382
389
|
|
|
383
390
|
export function buildKeyterms(settings: KeytermSettings): KeytermsCache {
|
|
384
|
-
const { brainPath, limit } = settings;
|
|
391
|
+
const { brainPath, limit, log } = settings;
|
|
385
392
|
|
|
386
393
|
try {
|
|
387
394
|
return withBrainDb(brainPath, {}, (db) => {
|
|
@@ -398,7 +405,7 @@ export function buildKeyterms(settings: KeytermSettings): KeytermsCache {
|
|
|
398
405
|
keyterms,
|
|
399
406
|
generatedAt: Date.now(),
|
|
400
407
|
count: keyterms.length,
|
|
401
|
-
overrides: loadOverrides(brainPath),
|
|
408
|
+
overrides: loadOverrides(brainPath, log),
|
|
402
409
|
};
|
|
403
410
|
});
|
|
404
411
|
} catch (err) {
|
|
@@ -410,17 +417,19 @@ export function buildKeyterms(settings: KeytermSettings): KeytermsCache {
|
|
|
410
417
|
// Schema too old for this package's SQL: degrade to no custom
|
|
411
418
|
// vocabulary rather than breaking voice entirely. Pronunciation
|
|
412
419
|
// overrides live in markdown, so they survive.
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
"
|
|
417
|
-
|
|
420
|
+
log?.emit({
|
|
421
|
+
severityText: "WARN",
|
|
422
|
+
body:
|
|
423
|
+
"brain.db schema is older than this server's keyterm SQL; serving an " +
|
|
424
|
+
"empty custom vocabulary until the repo is re-indexed",
|
|
425
|
+
attributes: { "schema.version": err.schemaVersion },
|
|
426
|
+
});
|
|
418
427
|
return {
|
|
419
428
|
version: CACHE_VERSION,
|
|
420
429
|
keyterms: [],
|
|
421
430
|
generatedAt: Date.now(),
|
|
422
431
|
count: 0,
|
|
423
|
-
overrides: loadOverrides(brainPath),
|
|
432
|
+
overrides: loadOverrides(brainPath, log),
|
|
424
433
|
degraded: true,
|
|
425
434
|
};
|
|
426
435
|
}
|
package/src/ws/bridge.ts
CHANGED
|
@@ -23,10 +23,14 @@ export function makeBridge(
|
|
|
23
23
|
// field would attribute those to the NEXT turn.
|
|
24
24
|
const turnId = turn.turnId;
|
|
25
25
|
return {
|
|
26
|
-
emit: (
|
|
26
|
+
emit: (message) => {
|
|
27
|
+
let msg = message;
|
|
27
28
|
if (msg.type === "session_info") {
|
|
28
29
|
turn.sessionId = msg.sessionId;
|
|
29
30
|
if (msg.providerId) turn.providerId = msg.providerId;
|
|
31
|
+
// Echo the client's correlation id, so it can recognise which
|
|
32
|
+
// announcement is its own rather than adopting the first to arrive.
|
|
33
|
+
if (turn.draftId) msg = { ...msg, draftId: turn.draftId };
|
|
30
34
|
coordinator.bySession.set(msg.sessionId, turn);
|
|
31
35
|
// Persist ownership the moment the identity exists — a turn that
|
|
32
36
|
// later fails or is cancelled must not leave an unowned transcript.
|
|
@@ -34,6 +38,16 @@ export function makeBridge(
|
|
|
34
38
|
}
|
|
35
39
|
host.sendToClients(withTurnScope(msg, turn, turnId));
|
|
36
40
|
if (msg.type === "result") {
|
|
41
|
+
// Only the live turn's own result may set its disposition — a late
|
|
42
|
+
// frame through a previous turn's bridge must not relabel this one.
|
|
43
|
+
if (turn.turnId === turnId) {
|
|
44
|
+
turn.lastResult =
|
|
45
|
+
msg.isError || msg.outcome === "error"
|
|
46
|
+
? "error"
|
|
47
|
+
: msg.outcome === "cancelled"
|
|
48
|
+
? "cancelled"
|
|
49
|
+
: "success";
|
|
50
|
+
}
|
|
37
51
|
catalog.persistSession(msg, promptText, turn.providerId, backendId);
|
|
38
52
|
}
|
|
39
53
|
},
|
|
@@ -140,8 +154,10 @@ export function makeBridge(
|
|
|
140
154
|
|
|
141
155
|
export function emitTurnError(host: WsHost, turn: RunningTurn, err: unknown): void {
|
|
142
156
|
// startTurn resolves for runtime failures (it emits its own error frame); it
|
|
143
|
-
// only rejects for caller errors.
|
|
157
|
+
// only rejects for caller errors. Each rejection is reported server-side
|
|
158
|
+
// too — the frame alone leaves no trace once the browser tab is gone.
|
|
144
159
|
if (err instanceof BackendBusyError) {
|
|
160
|
+
host.reportTurnFailed("SESSION_BUSY", turn);
|
|
145
161
|
host.sendToClients(
|
|
146
162
|
withTurnScope(
|
|
147
163
|
{ type: "error", code: "SESSION_BUSY", message: "That session already has a running turn." },
|
|
@@ -149,15 +165,15 @@ export function emitTurnError(host: WsHost, turn: RunningTurn, err: unknown): vo
|
|
|
149
165
|
)
|
|
150
166
|
);
|
|
151
167
|
} else if (err instanceof BackendRequestError) {
|
|
168
|
+
host.reportTurnFailed("BACKEND_REQUEST_ERROR", turn, err.message);
|
|
152
169
|
host.sendToClients(
|
|
153
170
|
withTurnScope({ type: "error", code: "BACKEND_REQUEST_ERROR", message: err.message }, turn)
|
|
154
171
|
);
|
|
155
172
|
} else {
|
|
173
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
174
|
+
host.reportTurnFailed("BACKEND_ERROR", turn, message);
|
|
156
175
|
host.sendToClients(
|
|
157
|
-
withTurnScope(
|
|
158
|
-
{ type: "error", code: "BACKEND_ERROR", message: err instanceof Error ? err.message : String(err) },
|
|
159
|
-
turn
|
|
160
|
-
)
|
|
176
|
+
withTurnScope({ type: "error", code: "BACKEND_ERROR", message }, turn)
|
|
161
177
|
);
|
|
162
178
|
}
|
|
163
179
|
}
|
package/src/ws/clients.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import type { ServerMessage } from "@schlessera/brain-ui-sdk/protocol";
|
|
2
2
|
import { shrinkForReplication } from "./shrink.js";
|
|
3
3
|
|
|
4
|
-
/**
|
|
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
|
}
|