@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/agent/backend.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Logger } from "@opentelemetry/api-logs";
|
|
1
2
|
import { createRequire } from "module";
|
|
2
3
|
import type { AgentConfig } from "../config/env.js";
|
|
3
4
|
import type { ProviderInfo } from "@schlessera/brain-ui-sdk";
|
|
@@ -13,7 +14,7 @@ import type {
|
|
|
13
14
|
* tests install fakes by constructing a registry, not by mutating a module.
|
|
14
15
|
*
|
|
15
16
|
* BOTH backend packages are optional peers loaded lazily (the same
|
|
16
|
-
*
|
|
17
|
+
* dynamic-import path): a deployment installs the one its AGENT_BACKEND
|
|
17
18
|
* names, and the other never has to be present — at runtime AND at
|
|
18
19
|
* type-check time (see the structural mirrors below).
|
|
19
20
|
*
|
|
@@ -47,6 +48,33 @@ import type {
|
|
|
47
48
|
* incompatible drift slide.
|
|
48
49
|
*/
|
|
49
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Mirror of the backends' `BackendLogFn` — the minimal log seam a backend
|
|
53
|
+
* accepts. A callback rather than a Logger so the backend packages carry no
|
|
54
|
+
* telemetry dependency; {@link toBackendLog} adapts the registry's Logger.
|
|
55
|
+
*/
|
|
56
|
+
export type BackendLogFn = (
|
|
57
|
+
level: "debug" | "info" | "warn" | "error",
|
|
58
|
+
message: string,
|
|
59
|
+
attrs?: Record<string, string | number | boolean>
|
|
60
|
+
) => void;
|
|
61
|
+
|
|
62
|
+
const LEVEL_SEVERITY = {
|
|
63
|
+
debug: "DEBUG",
|
|
64
|
+
info: "INFO",
|
|
65
|
+
warn: "WARN",
|
|
66
|
+
error: "ERROR",
|
|
67
|
+
} as const;
|
|
68
|
+
|
|
69
|
+
function toBackendLog(log: Logger): BackendLogFn {
|
|
70
|
+
return (level, message, attrs) =>
|
|
71
|
+
log.emit({
|
|
72
|
+
severityText: LEVEL_SEVERITY[level],
|
|
73
|
+
body: message,
|
|
74
|
+
...(attrs ? { attributes: attrs } : {}),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
50
78
|
/** Mirror of the Claude package's `InferenceProfileInput` (declarative shape). */
|
|
51
79
|
export interface ClaudeProfileInput {
|
|
52
80
|
id: string;
|
|
@@ -94,6 +122,8 @@ export interface ClaudeBackendModule {
|
|
|
94
122
|
brainPath: string;
|
|
95
123
|
claudeCodePath?: string;
|
|
96
124
|
profiles?: ClaudeProfile[] | (() => ClaudeProfile[]);
|
|
125
|
+
confirmBashPatterns?: readonly string[];
|
|
126
|
+
log?: BackendLogFn;
|
|
97
127
|
}) => AgentBackend;
|
|
98
128
|
createModelSource: (options: {
|
|
99
129
|
brainPath: string;
|
|
@@ -145,6 +175,12 @@ export interface BackendRegistryOptions {
|
|
|
145
175
|
* sessions pinned to it. Defaults to "nothing hidden".
|
|
146
176
|
*/
|
|
147
177
|
getHiddenModelIds?: () => string[];
|
|
178
|
+
/**
|
|
179
|
+
* Where the registry and its backends report. Adapted to the backends'
|
|
180
|
+
* minimal callback ({@link BackendLogFn}) before crossing the package
|
|
181
|
+
* boundary. Absent means silence.
|
|
182
|
+
*/
|
|
183
|
+
log?: Logger;
|
|
148
184
|
}
|
|
149
185
|
|
|
150
186
|
export interface BackendRegistry {
|
|
@@ -257,6 +293,59 @@ function missingBackendError(primary: "claude" | "pi"): Error {
|
|
|
257
293
|
* The `resolve` parameter exists for tests (simulating an absent package);
|
|
258
294
|
* production callers pass nothing.
|
|
259
295
|
*/
|
|
296
|
+
/** The specifier an ERR_MODULE_NOT_FOUND failed on, or null for any other error. */
|
|
297
|
+
function moduleNotFoundSpecifier(error: unknown): string | null {
|
|
298
|
+
if (typeof error !== "object" || error === null) return null;
|
|
299
|
+
const { code, specifier, message } = error as {
|
|
300
|
+
code?: unknown;
|
|
301
|
+
specifier?: unknown;
|
|
302
|
+
message?: unknown;
|
|
303
|
+
};
|
|
304
|
+
if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND") return null;
|
|
305
|
+
// Bun's ResolveMessage (not an Error instance) carries the failing
|
|
306
|
+
// specifier as a property; Node quotes it in the message instead.
|
|
307
|
+
if (typeof specifier === "string") return specifier;
|
|
308
|
+
if (typeof message === "string") {
|
|
309
|
+
const quoted = /Cannot find (?:package|module) '([^']+)'/.exec(message);
|
|
310
|
+
if (quoted) return quoted[1];
|
|
311
|
+
}
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Load an optional backend package, mapping ONLY "the backend package itself
|
|
317
|
+
* is not installed" to the actionable install hint. Everything else — the
|
|
318
|
+
* backend missing one of its OWN transitive deps, a syntax error, a throwing
|
|
319
|
+
* top-level — is a different failure whose original error IS the diagnostic,
|
|
320
|
+
* so it is rethrown untouched. The distinction rides on ERR_MODULE_NOT_FOUND
|
|
321
|
+
* naming the specifier it failed on: a transitive miss names the transitive
|
|
322
|
+
* dep, not the backend, and must not read as "backend not installed".
|
|
323
|
+
*
|
|
324
|
+
* `await import()` rather than `createRequire()(...)`: the backend packages
|
|
325
|
+
* are ESM, and a CJS require of them under plain Node dies with
|
|
326
|
+
* ERR_REQUIRE_ESM — which the old blanket catch then reported as "not
|
|
327
|
+
* installed" on a machine where the package was sitting right there.
|
|
328
|
+
*
|
|
329
|
+
* The `importer` parameter exists for tests (simulating absent or broken
|
|
330
|
+
* packages); production callers pass nothing.
|
|
331
|
+
*/
|
|
332
|
+
export async function loadBackendModule(
|
|
333
|
+
// "claude" | "pi" spelled out, NOT keyof typeof BACKEND_SPECIFIERS: the
|
|
334
|
+
// keyof form drags the table's literal string types — the backend
|
|
335
|
+
// specifiers — into the emitted .d.ts, which the declaration-surface gate
|
|
336
|
+
// rightly refuses.
|
|
337
|
+
key: "claude" | "pi",
|
|
338
|
+
importer: (specifier: string) => Promise<unknown> = (specifier) => import(specifier)
|
|
339
|
+
): Promise<unknown> {
|
|
340
|
+
const specifier = BACKEND_SPECIFIERS[key];
|
|
341
|
+
try {
|
|
342
|
+
return await importer(specifier);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
if (moduleNotFoundSpecifier(error) === specifier) throw missingBackendError(key);
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
260
349
|
export function assertBackendResolvable(
|
|
261
350
|
agent: AgentConfig,
|
|
262
351
|
resolve: (specifier: string) => void = (specifier) => {
|
|
@@ -278,6 +367,7 @@ export function createBackendRegistry(
|
|
|
278
367
|
): BackendRegistry {
|
|
279
368
|
const { brainPath, agent } = options;
|
|
280
369
|
const getHidden = options.getHiddenModelIds ?? (() => []);
|
|
370
|
+
const backendLog = options.log ? toBackendLog(options.log) : undefined;
|
|
281
371
|
|
|
282
372
|
let cachedRegistry: Promise<RegistrySnapshot> | null = null;
|
|
283
373
|
let modelSource: ClaudeModelSource | null = null;
|
|
@@ -391,18 +481,12 @@ export function createBackendRegistry(
|
|
|
391
481
|
/**
|
|
392
482
|
* NEITHER backend package is a hard dependency — a deployment installs the
|
|
393
483
|
* one its AGENT_BACKEND names (both, if it switches). Loaded lazily through
|
|
394
|
-
*
|
|
395
|
-
* one;
|
|
396
|
-
*
|
|
484
|
+
* {@link loadBackendModule} so the server still boots without the unused
|
|
485
|
+
* one; only "the package is absent" maps to the actionable install hint,
|
|
486
|
+
* any other load failure surfaces as itself.
|
|
397
487
|
*/
|
|
398
|
-
function buildClaudeBackend(): AgentBackend {
|
|
399
|
-
const
|
|
400
|
-
let claude: ClaudeBackendModule;
|
|
401
|
-
try {
|
|
402
|
-
claude = require("@schlessera/brain-backend-claude");
|
|
403
|
-
} catch {
|
|
404
|
-
throw missingBackendError("claude");
|
|
405
|
-
}
|
|
488
|
+
async function buildClaudeBackend(): Promise<AgentBackend> {
|
|
489
|
+
const claude = (await loadBackendModule("claude")) as ClaudeBackendModule;
|
|
406
490
|
if (typeof claude.createClaudeBackend !== "function") {
|
|
407
491
|
throw new Error(
|
|
408
492
|
'"@schlessera/brain-backend-claude" does not export createClaudeBackend.'
|
|
@@ -422,6 +506,12 @@ export function createBackendRegistry(
|
|
|
422
506
|
return claude.createClaudeBackend({
|
|
423
507
|
brainPath,
|
|
424
508
|
claudeCodePath: agent.claudeCodePath,
|
|
509
|
+
...(backendLog ? { log: backendLog } : {}),
|
|
510
|
+
// Omitted entirely when unconfigured, so the backend's own defaults
|
|
511
|
+
// apply; an explicit [] passes through and disables confirmation.
|
|
512
|
+
...(agent.confirmBashPatterns !== null
|
|
513
|
+
? { confirmBashPatterns: agent.confirmBashPatterns }
|
|
514
|
+
: {}),
|
|
425
515
|
// A function, not an array: discovery refreshes in the background and the
|
|
426
516
|
// new roster has to be visible without restarting the process.
|
|
427
517
|
profiles: () =>
|
|
@@ -429,31 +519,30 @@ export function createBackendRegistry(
|
|
|
429
519
|
});
|
|
430
520
|
}
|
|
431
521
|
|
|
432
|
-
function buildPiBackend(): AgentBackend {
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
mod = require("@schlessera/brain-backend-pi");
|
|
437
|
-
} catch {
|
|
438
|
-
throw missingBackendError("pi");
|
|
439
|
-
}
|
|
522
|
+
async function buildPiBackend(): Promise<AgentBackend> {
|
|
523
|
+
const mod = (await loadBackendModule("pi")) as {
|
|
524
|
+
createPiBackend?: (opts: { brainPath: string; log?: BackendLogFn }) => AgentBackend;
|
|
525
|
+
};
|
|
440
526
|
if (typeof mod.createPiBackend !== "function") {
|
|
441
527
|
throw new Error(
|
|
442
528
|
'"@schlessera/brain-backend-pi" does not export createPiBackend.'
|
|
443
529
|
);
|
|
444
530
|
}
|
|
445
|
-
return mod.createPiBackend({
|
|
531
|
+
return mod.createPiBackend({
|
|
532
|
+
brainPath,
|
|
533
|
+
...(backendLog ? { log: backendLog } : {}),
|
|
534
|
+
});
|
|
446
535
|
}
|
|
447
536
|
|
|
448
537
|
async function buildRegistry(): Promise<RegistrySnapshot> {
|
|
449
538
|
const primary = agent.backend || "claude";
|
|
450
539
|
|
|
451
540
|
if (primary === "pi") {
|
|
452
|
-
const pi = buildPiBackend();
|
|
541
|
+
const pi = await buildPiBackend();
|
|
453
542
|
return buildSnapshot([pi], pi.id);
|
|
454
543
|
}
|
|
455
544
|
|
|
456
|
-
const backends = [buildClaudeBackend()];
|
|
545
|
+
const backends = [await buildClaudeBackend()];
|
|
457
546
|
|
|
458
547
|
// AGENT_BACKEND must name a configured backend. The in-process options are
|
|
459
548
|
// "claude" (default) and "pi" (handled above). An unrecognized value is a
|
|
@@ -470,10 +559,15 @@ export function createBackendRegistry(
|
|
|
470
559
|
return cachedRegistry;
|
|
471
560
|
}
|
|
472
561
|
|
|
473
|
-
return makeRegistry(
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
562
|
+
return makeRegistry(
|
|
563
|
+
getRegistry,
|
|
564
|
+
getHidden,
|
|
565
|
+
async () => {
|
|
566
|
+
await getRegistry();
|
|
567
|
+
return modelSource;
|
|
568
|
+
},
|
|
569
|
+
options.log
|
|
570
|
+
);
|
|
477
571
|
}
|
|
478
572
|
|
|
479
573
|
/**
|
|
@@ -485,13 +579,14 @@ export function createBackendRegistry(
|
|
|
485
579
|
export function createStaticBackendRegistry(
|
|
486
580
|
backends: AgentBackend[],
|
|
487
581
|
defaultBackendId = backends[0]?.id ?? "",
|
|
488
|
-
options: { getHiddenModelIds?: () => string[] } = {}
|
|
582
|
+
options: { getHiddenModelIds?: () => string[]; log?: Logger } = {}
|
|
489
583
|
): BackendRegistry {
|
|
490
584
|
const snapshot = buildSnapshot(backends, defaultBackendId);
|
|
491
585
|
return makeRegistry(
|
|
492
586
|
async () => snapshot,
|
|
493
587
|
options.getHiddenModelIds ?? (() => []),
|
|
494
|
-
async () => null
|
|
588
|
+
async () => null,
|
|
589
|
+
options.log
|
|
495
590
|
);
|
|
496
591
|
}
|
|
497
592
|
|
|
@@ -499,7 +594,8 @@ export function createStaticBackendRegistry(
|
|
|
499
594
|
function makeRegistry(
|
|
500
595
|
getRegistry: () => Promise<RegistrySnapshot>,
|
|
501
596
|
getHidden: () => string[],
|
|
502
|
-
getModelSource: () => Promise<ModelDiscoverySource | null
|
|
597
|
+
getModelSource: () => Promise<ModelDiscoverySource | null>,
|
|
598
|
+
log?: Logger
|
|
503
599
|
): BackendRegistry {
|
|
504
600
|
let profileSnapshot: ProfileSnapshot | null = null;
|
|
505
601
|
|
|
@@ -536,11 +632,11 @@ function makeRegistry(
|
|
|
536
632
|
try {
|
|
537
633
|
return new Set(getHidden());
|
|
538
634
|
} catch (err) {
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
}
|
|
543
|
-
);
|
|
635
|
+
log?.emit({
|
|
636
|
+
severityText: "WARN",
|
|
637
|
+
body: "could not read hidden models; treating none as hidden",
|
|
638
|
+
attributes: { error: err instanceof Error ? err.message : String(err) },
|
|
639
|
+
});
|
|
544
640
|
return new Set();
|
|
545
641
|
}
|
|
546
642
|
}
|
package/src/app.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import type { Context } from "hono";
|
|
3
3
|
import type { Database } from "bun:sqlite";
|
|
4
|
-
import { logger } from "hono/logger";
|
|
5
4
|
import { cors } from "hono/cors";
|
|
6
5
|
import { serveStatic } from "hono/bun";
|
|
7
6
|
import { join } from "path";
|
|
8
7
|
import { resolveServerConfig, type ServerConfig } from "./config/env.js";
|
|
9
|
-
import {
|
|
8
|
+
import { createHealthRoutes, createStatusRoutes } from "./routes/health.js";
|
|
10
9
|
import { createBrainRoutes } from "./routes/brain.js";
|
|
11
10
|
import { createSessionRoutes } from "./routes/sessions.js";
|
|
12
11
|
import { createVoiceRoutes } from "./routes/voice.js";
|
|
@@ -45,6 +44,7 @@ import { WsHost } from "./ws/host.js";
|
|
|
45
44
|
import { createWsUpgrade, websocket } from "./ws/connection.js";
|
|
46
45
|
import { createSessionCatalog } from "./ws/session-catalog.js";
|
|
47
46
|
import type { KeytermSettings } from "./voice/keyterm-builder.js";
|
|
47
|
+
import { createObservability, type Observability } from "./observability/index.js";
|
|
48
48
|
|
|
49
49
|
export type { AppRenderer };
|
|
50
50
|
|
|
@@ -81,6 +81,12 @@ export interface CreateAppOptions {
|
|
|
81
81
|
turnTimeoutMs?: number;
|
|
82
82
|
/** Backend registry override (tests/embedders); default is built from config. */
|
|
83
83
|
registry?: BackendRegistry;
|
|
84
|
+
/**
|
|
85
|
+
* Where this app reports. Defaults to the console consumer; a test passes
|
|
86
|
+
* `createRecordingObservability()` and then asserts on what the server
|
|
87
|
+
* actually said, through the same emission path production uses.
|
|
88
|
+
*/
|
|
89
|
+
observability?: Observability;
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
/** What `createApp` hands back to the deployment shell. */
|
|
@@ -99,6 +105,8 @@ export interface BrainUiApp {
|
|
|
99
105
|
db: Database;
|
|
100
106
|
/** The WebSocket coordinator (turn state, clients, catalog, registry). */
|
|
101
107
|
wsHost: WsHost;
|
|
108
|
+
/** Where this app reports — resolved or injected. */
|
|
109
|
+
observability: Observability;
|
|
102
110
|
/** True while any session has a running turn. */
|
|
103
111
|
isTurnActive(): boolean;
|
|
104
112
|
/** Cancel every running turn (used on shutdown). Returns true if any was. */
|
|
@@ -136,14 +144,19 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
136
144
|
// silently fall back to the resolved path.
|
|
137
145
|
const config: ServerConfig =
|
|
138
146
|
options.dbPath !== undefined ? { ...resolved, dbPath: options.dbPath } : resolved;
|
|
147
|
+
// First thing built, because everything below may want to report — including
|
|
148
|
+
// the auth validation that can refuse to boot and the migration runner.
|
|
149
|
+
const observability =
|
|
150
|
+
options.observability ?? createObservability({ minSeverity: config.logLevel });
|
|
139
151
|
const auth: AuthRuntime = { ...config.auth, host: config.host };
|
|
140
152
|
|
|
141
153
|
const app = new Hono();
|
|
142
|
-
const
|
|
154
|
+
const authLog = observability.logger("auth");
|
|
155
|
+
const authMode = resolveAuthMode(auth, authLog);
|
|
143
156
|
// Validate inside the factory, not the bin entry: every consumer of the app
|
|
144
157
|
// (a deployment bin, tests, another embedder) gets the same refuse-to-boot
|
|
145
158
|
// guarantee on an unsafe auth configuration.
|
|
146
|
-
assertAuthConfig(authMode, auth);
|
|
159
|
+
assertAuthConfig(authMode, auth, authLog);
|
|
147
160
|
assertPasskeyConfig(config.webauthn);
|
|
148
161
|
// A missing (or unrecognized) agent backend refuses to boot HERE, not on the
|
|
149
162
|
// first turn — otherwise /api/health reports healthy while every turn is
|
|
@@ -154,38 +167,69 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
154
167
|
// Per-instance state: the app's own database, the brain CLI wrapper, the
|
|
155
168
|
// backend registry, and the WebSocket host. No module-level singletons —
|
|
156
169
|
// two apps with different configuration coexist in one process.
|
|
157
|
-
const
|
|
170
|
+
const dbLog = observability.logger("db");
|
|
171
|
+
const db = createUiDb(config.dbPath, { log: dbLog });
|
|
158
172
|
const brain = createBrainClient({ brainPath: config.brainPath });
|
|
159
|
-
const cron = createCronScheduler({ db, brain });
|
|
173
|
+
const cron = createCronScheduler({ db, brain, log: observability.logger("cron") });
|
|
160
174
|
const registry =
|
|
161
175
|
options.registry ??
|
|
162
176
|
createBackendRegistry({
|
|
163
177
|
brainPath: config.brainPath,
|
|
164
178
|
agent: config.agent,
|
|
165
|
-
getHiddenModelIds: () => getHiddenModelIds(db),
|
|
179
|
+
getHiddenModelIds: () => getHiddenModelIds(db, dbLog),
|
|
180
|
+
log: observability.logger("agent"),
|
|
166
181
|
});
|
|
167
182
|
const host = new WsHost({
|
|
168
183
|
registry,
|
|
169
|
-
|
|
184
|
+
observability,
|
|
185
|
+
catalog: createSessionCatalog(() => db, dbLog),
|
|
170
186
|
...(options.appName ? { appName: options.appName } : {}),
|
|
171
187
|
...(options.turnTimeoutMs ? { turnTimeoutMs: options.turnTimeoutMs } : {}),
|
|
172
188
|
maxConcurrentSessions: () => config.maxConcurrentSessions,
|
|
189
|
+
wsRate: config.wsRate,
|
|
173
190
|
});
|
|
174
191
|
const wsUpgrade = createWsUpgrade(host);
|
|
192
|
+
// One instrument for every way a login can fail — passkey ceremonies and
|
|
193
|
+
// password logins land in the same series, split by attributes.
|
|
194
|
+
const authFailures = observability.meter("auth").createCounter("auth.failures", {
|
|
195
|
+
description: "Failed authentication ceremonies, by reason",
|
|
196
|
+
});
|
|
175
197
|
const passkeyCtx: PasskeyContext = {
|
|
176
198
|
db,
|
|
177
199
|
webauthn: config.webauthn,
|
|
178
200
|
auth,
|
|
179
201
|
allowedOrigins: config.allowedOrigins,
|
|
202
|
+
log: observability.logger("passkeys"),
|
|
203
|
+
failures: authFailures,
|
|
180
204
|
};
|
|
181
205
|
const keyterms: KeytermSettings = {
|
|
182
206
|
brainPath: config.brainPath,
|
|
183
207
|
cacheDir: config.voice.cacheDir,
|
|
184
208
|
limit: config.voice.keytermLimit,
|
|
209
|
+
log: observability.logger("voice"),
|
|
185
210
|
};
|
|
186
211
|
|
|
187
|
-
//
|
|
188
|
-
|
|
212
|
+
// Request logging through the observability layer, so BRAIN_UI_LOG_LEVEL
|
|
213
|
+
// governs it like every other emission (hono's logger() wrote raw console
|
|
214
|
+
// lines no threshold or consumer swap could touch). Path only — never the
|
|
215
|
+
// query string or body. /api/health is skipped: the Docker healthcheck
|
|
216
|
+
// polls it and would drown everything else out.
|
|
217
|
+
const httpLog = observability.logger("http");
|
|
218
|
+
app.use("*", async (c, next) => {
|
|
219
|
+
if (c.req.path === "/api/health") return next();
|
|
220
|
+
const startedAt = Date.now();
|
|
221
|
+
await next();
|
|
222
|
+
httpLog.emit({
|
|
223
|
+
severityText: "INFO",
|
|
224
|
+
body: "request",
|
|
225
|
+
attributes: {
|
|
226
|
+
method: c.req.method,
|
|
227
|
+
path: c.req.path,
|
|
228
|
+
status: c.res.status,
|
|
229
|
+
"duration.ms": Date.now() - startedAt,
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
});
|
|
189
233
|
|
|
190
234
|
// CORS is only needed for a SPLIT topology where the client is served from a
|
|
191
235
|
// different origin than the API. ALLOWED_ORIGINS is a comma-separated
|
|
@@ -208,7 +252,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
208
252
|
// probe and the login/logout routes are reachable without a session.
|
|
209
253
|
// /api/status is intentionally NOT here — it leaks the git SHA, cron errors,
|
|
210
254
|
// and a session oracle, so it lives behind the guard below.
|
|
211
|
-
app.route("/api",
|
|
255
|
+
app.route("/api", createHealthRoutes({ db }));
|
|
212
256
|
// Not under /api, and not behind the guard: this is where a system share
|
|
213
257
|
// lands when no service worker was around to intercept it. See the route.
|
|
214
258
|
app.route("/", shareTargetFallbackRoutes);
|
|
@@ -216,6 +260,8 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
216
260
|
"/api",
|
|
217
261
|
authRoutes(authMode, auth, {
|
|
218
262
|
passwordDisabled: (c) => passwordLoginDisabled(c, passkeyCtx),
|
|
263
|
+
log: authLog,
|
|
264
|
+
failures: authFailures,
|
|
219
265
|
})
|
|
220
266
|
);
|
|
221
267
|
app.route("/api", passkeyPublicRoutes(authMode, passkeyCtx));
|
|
@@ -234,6 +280,9 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
234
280
|
sourceCommit: config.sourceCommit,
|
|
235
281
|
getCronStatus: () => cron.getCronStatus(),
|
|
236
282
|
isTurnActive: () => host.coordinator.isTurnActive(),
|
|
283
|
+
// Undefined when the injected consumer cannot be read back (a real OTel
|
|
284
|
+
// SDK exports elsewhere), in which case the field is simply absent.
|
|
285
|
+
getMetrics: () => observability.metrics?.snapshot(),
|
|
237
286
|
})
|
|
238
287
|
);
|
|
239
288
|
app.route(
|
|
@@ -242,15 +291,28 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
242
291
|
);
|
|
243
292
|
app.route("/api", createSessionRoutes({ registry, db }));
|
|
244
293
|
app.route("/api", createVoiceRoutes({ voice: config.voice, keyterms }));
|
|
245
|
-
app.route("/api", createFilesRoutes({ brainRoot: config.brainPath }));
|
|
246
294
|
app.route(
|
|
247
295
|
"/api",
|
|
248
|
-
|
|
296
|
+
createFilesRoutes({ brainRoot: config.brainPath, log: observability.logger("files") })
|
|
297
|
+
);
|
|
298
|
+
app.route(
|
|
299
|
+
"/api",
|
|
300
|
+
createShareRoutes({
|
|
301
|
+
brainRoot: config.brainPath,
|
|
302
|
+
allowedOrigins,
|
|
303
|
+
log: observability.logger("share"),
|
|
304
|
+
})
|
|
249
305
|
);
|
|
250
|
-
app.route("/api", createRenderRoutes(options.renderer));
|
|
306
|
+
app.route("/api", createRenderRoutes(options.renderer, observability.logger("render")));
|
|
251
307
|
app.route("/api", createProviderRoutes({ registry }));
|
|
252
|
-
app.route(
|
|
253
|
-
|
|
308
|
+
app.route(
|
|
309
|
+
"/api",
|
|
310
|
+
createModelRoutes({ registry, db, log: observability.logger("models") })
|
|
311
|
+
);
|
|
312
|
+
app.route(
|
|
313
|
+
"/api",
|
|
314
|
+
createGraphRoutes({ brainRoot: config.brainPath, log: observability.logger("graph") })
|
|
315
|
+
);
|
|
254
316
|
|
|
255
317
|
// WebSocket endpoint. Browsers can't set headers on the WS handshake, so the
|
|
256
318
|
// upgrade authenticates via the session cookie (or IP/proxy header) INSIDE
|
|
@@ -269,8 +331,24 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
269
331
|
if (options.staticRoot) {
|
|
270
332
|
const staticRoot = options.staticRoot;
|
|
271
333
|
app.use("/*", serveStatic({ root: staticRoot }));
|
|
272
|
-
|
|
273
|
-
|
|
334
|
+
|
|
335
|
+
// SPA fallback, served directly rather than through serveStatic({ path }).
|
|
336
|
+
//
|
|
337
|
+
// That helper resolves its path the same way it resolves `root` — against
|
|
338
|
+
// the process working directory — so `join(staticRoot, "index.html")`
|
|
339
|
+
// only lands correctly when `staticRoot` is itself cwd-relative. It is in
|
|
340
|
+
// the shipped layout, which is why this went unnoticed; an embedder
|
|
341
|
+
// passing an absolute directory, or a process that changed cwd, got a
|
|
342
|
+
// fallback that silently 404ed every deep link. Reading the file
|
|
343
|
+
// ourselves removes the ambiguity for both cases.
|
|
344
|
+
const indexPath = join(staticRoot, "index.html");
|
|
345
|
+
app.get("*", async (c) => {
|
|
346
|
+
const file = Bun.file(indexPath);
|
|
347
|
+
if (!(await file.exists())) return c.notFound();
|
|
348
|
+
return new Response(file, {
|
|
349
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
350
|
+
});
|
|
351
|
+
});
|
|
274
352
|
}
|
|
275
353
|
|
|
276
354
|
return {
|
|
@@ -280,6 +358,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
280
358
|
authMode,
|
|
281
359
|
db,
|
|
282
360
|
wsHost: host,
|
|
361
|
+
observability,
|
|
283
362
|
isTurnActive: () => host.coordinator.isTurnActive(),
|
|
284
363
|
cancelActiveTurns: () => host.coordinator.cancelAll("Server shutting down"),
|
|
285
364
|
close: () => {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared core of every package's environment chokepoint — a SYNC-ENFORCED
|
|
3
|
+
* COPY, not a package.
|
|
4
|
+
*
|
|
5
|
+
* This file exists byte-identically as `src/config/env-core.ts` in every
|
|
6
|
+
* package that has an env chokepoint (`src/config/env.ts`), and the root test
|
|
7
|
+
* `tests/env-core-sync.test.ts` fails the build when any copy differs. To
|
|
8
|
+
* change it: edit ONE copy, copy the file verbatim into every package that
|
|
9
|
+
* has one (each `src/config/env-core.ts`), and rerun the sync test.
|
|
10
|
+
*
|
|
11
|
+
* Why a copy and not a `@schlessera/brain-env` package: the repo adds a seam
|
|
12
|
+
* only where a second implementation is plausible (docs/extending/README.md),
|
|
13
|
+
* and a new package for four fields and three small functions would churn the
|
|
14
|
+
* release manifest and every dependent's dependency list. Byte-equality
|
|
15
|
+
* enforced by a test gives the same single-source-of-truth guarantee without
|
|
16
|
+
* the edge. (`scripts/env-docs.ts` imports the canonical copy in
|
|
17
|
+
* `packages/core` directly — it is a root script, not published code.)
|
|
18
|
+
*
|
|
19
|
+
* What belongs here: the descriptor contract the env-docs generator and the
|
|
20
|
+
* env-parity gate consume, the dynamic-read escape hatch, and the boolean
|
|
21
|
+
* parsing helpers. What does NOT belong here: `resolveEnv` — each package's
|
|
22
|
+
* resolver reads its own variables into its own shape and stays in that
|
|
23
|
+
* package's `env.ts`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** One environment variable a package reads. */
|
|
27
|
+
export interface EnvVarSpec {
|
|
28
|
+
/** Variable name as it appears in the environment. */
|
|
29
|
+
name: string;
|
|
30
|
+
/** What it controls. */
|
|
31
|
+
description: string;
|
|
32
|
+
/**
|
|
33
|
+
* Behaviour when the variable is unset. Omitted or null when there is no
|
|
34
|
+
* meaningful default to state.
|
|
35
|
+
*/
|
|
36
|
+
default?: string | null;
|
|
37
|
+
/**
|
|
38
|
+
* `true` when the package cannot do its job at all without it, `false`
|
|
39
|
+
* when optional; a string states the CONDITION under which it becomes
|
|
40
|
+
* required (e.g. "AUTH_MODE=password") — flattening that to a boolean
|
|
41
|
+
* would lose the only part a reader needs.
|
|
42
|
+
*/
|
|
43
|
+
required: boolean | string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A family of reads whose variable NAME is data, not code. */
|
|
47
|
+
export interface DynamicEnvReadSpec {
|
|
48
|
+
/** Where the variable name comes from. */
|
|
49
|
+
source: string;
|
|
50
|
+
/** What the value is used for. */
|
|
51
|
+
description: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Call-time read of a single variable whose name is data (see the package's
|
|
56
|
+
* `DYNAMIC_ENV_READS`) or whose presence gates a feature. Never cache the
|
|
57
|
+
* result at module scope.
|
|
58
|
+
*/
|
|
59
|
+
export function readEnvVar(
|
|
60
|
+
name: string,
|
|
61
|
+
env: NodeJS.ProcessEnv = process.env
|
|
62
|
+
): string | undefined {
|
|
63
|
+
return env[name];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const TRUTHY_TOKENS: readonly string[] = ["1", "true", "on", "yes"];
|
|
67
|
+
const FALSY_TOKENS: readonly string[] = ["0", "false", "off", "no"];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The one way a token-valued boolean environment variable is parsed.
|
|
71
|
+
*
|
|
72
|
+
* Truthy: 1 / true / on / yes. Falsy: 0 / false / off / no. Matching is
|
|
73
|
+
* case-insensitive after trimming. Unset, empty, or any unrecognised token
|
|
74
|
+
* returns `defaultValue` — a typo in a flag must never silently flip a
|
|
75
|
+
* behaviour; it falls back to the documented default instead.
|
|
76
|
+
*/
|
|
77
|
+
export function envFlag(value: string | undefined, defaultValue: boolean): boolean {
|
|
78
|
+
if (value === undefined) return defaultValue;
|
|
79
|
+
const token = value.trim().toLowerCase();
|
|
80
|
+
if (TRUTHY_TOKENS.includes(token)) return true;
|
|
81
|
+
if (FALSY_TOKENS.includes(token)) return false;
|
|
82
|
+
return defaultValue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Presence-based boolean: set to ANY non-empty value means on. This is the
|
|
87
|
+
* `NO_COLOR` convention (https://no-color.org) — `NO_COLOR=0` still disables
|
|
88
|
+
* color — so it must NOT go through `envFlag`. Use only for variables whose
|
|
89
|
+
* documented contract is presence, not a token.
|
|
90
|
+
*/
|
|
91
|
+
export function envPresent(value: string | undefined): boolean {
|
|
92
|
+
return value !== undefined && value !== "";
|
|
93
|
+
}
|