New session now refuses to spawn a Pi runtime that would only die in bootstrap
retrieval when the DWH/vector host is unreachable (e.g. a dropped VPN). Before
`session new`, POST /sessions probes the DWH via `tht db ping`; if it is down it
returns 503 {code:"dwh_unreachable"} with a clear message and creates nothing.
- Gated behind the THT_DWH_PRECHECK flag (default off), enabled only by the local
dev launcher (run-stack.sh) — containers/CI never pay the probe, and existing
tests that don't set it are unaffected.
- ThtRunner.dbPing() runs `tht db ping` with a 10s timeout (run() gains an optional
timeout that SIGKILLs a hung child).
- Frontend: apiFetch throws a typed ApiError (status + parsed payload); the new-
session composer shows the specific alert on `dwh_unreachable` instead of the
generic retry hint, keeping the question for retry.
Verified live on an isolated backend (precheck on + broken DWH host → 503
dwh_unreachable, no session created) and via unit tests (backend 228, frontend 308).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
111 lines
4.9 KiB
TypeScript
111 lines
4.9 KiB
TypeScript
import path from "node:path";
|
|
|
|
export interface AppConfig {
|
|
host: string; port: number; harnessDir: string; thtBin: string; piBin: string;
|
|
authMode: "none" | "mock" | "upstream";
|
|
sessionStorage: {
|
|
mode: "local" | "postgres";
|
|
host?: string; port?: number; database?: string; runtimeUser?: string;
|
|
runtimePasswordFile?: string; sslmode?: "verify-ca" | "verify-full"; sslrootcert?: string;
|
|
};
|
|
defaults: { provider?: string; model?: string; thinking?: string };
|
|
maxPiProcesses: number;
|
|
settingsFile: string;
|
|
dataRoot?: string;
|
|
ollamaEnsureTimeoutMs: number;
|
|
secretsFile?: string;
|
|
secretFiles: Readonly<Record<string, string | undefined>>;
|
|
modelApiKeyFile?: string;
|
|
/**
|
|
* Local-only: when true, POST /sessions probes DWH reachability (`tht db ping`) and
|
|
* refuses to create a session if it is down. Off by default so containers/CI never
|
|
* pay the probe; the local dev launcher (run-stack.sh) opts in via THT_DWH_PRECHECK.
|
|
*/
|
|
dwhPrecheck: boolean;
|
|
}
|
|
export function loadConfig(env: Record<string, string | undefined>): AppConfig {
|
|
const authMode = env.AUTH_MODE ?? "none";
|
|
if (!(["none", "mock", "upstream"] as const).includes(authMode as AppConfig["authMode"])) {
|
|
throw new Error(`unsupported AUTH_MODE=${authMode}; use none, mock, or upstream`);
|
|
}
|
|
if (env.THOTH_PUBLIC_EXPOSURE === "true" && authMode !== "upstream") {
|
|
throw new Error("public exposure requires AUTH_MODE=upstream behind a trusted proxy");
|
|
}
|
|
const sessionStorageMode = env.THT_SESSION_STORAGE ?? "local";
|
|
if (sessionStorageMode !== "local" && sessionStorageMode !== "postgres") {
|
|
throw new Error("session storage configuration is invalid");
|
|
}
|
|
if (sessionStorageMode === "local" && env.THOTH_PUBLIC_EXPOSURE === "true") {
|
|
throw new Error("local session storage requires loopback-only deployment");
|
|
}
|
|
const sessionStorage: AppConfig["sessionStorage"] = { mode: sessionStorageMode };
|
|
if (sessionStorageMode === "postgres") {
|
|
const host = env.THT_SESSION_DB_HOST;
|
|
const database = env.THT_SESSION_DB_NAME;
|
|
const runtimeUser = env.THT_SESSION_RUNTIME_USER;
|
|
const runtimePasswordFile = env.THT_SESSION_RUNTIME_PASSWORD_FILE;
|
|
const sslmode = env.THT_SESSION_DB_SSLMODE;
|
|
const sslrootcert = env.THT_SESSION_DB_SSLROOTCERT;
|
|
const port = Number(env.THT_SESSION_DB_PORT ?? 5432);
|
|
if (
|
|
authMode !== "upstream"
|
|
|| !host || !database || !runtimeUser
|
|
|| !runtimePasswordFile || !path.isAbsolute(runtimePasswordFile)
|
|
|| (sslmode !== "verify-ca" && sslmode !== "verify-full")
|
|
|| !sslrootcert || !path.isAbsolute(sslrootcert)
|
|
|| !Number.isInteger(port) || port < 1 || port > 65535
|
|
) {
|
|
if (authMode !== "upstream") {
|
|
throw new Error("server session storage requires AUTH_MODE=upstream");
|
|
}
|
|
throw new Error("server session storage configuration is invalid");
|
|
}
|
|
sessionStorage.host = host;
|
|
sessionStorage.port = port;
|
|
sessionStorage.database = database;
|
|
sessionStorage.runtimeUser = runtimeUser;
|
|
sessionStorage.runtimePasswordFile = runtimePasswordFile;
|
|
sessionStorage.sslmode = sslmode;
|
|
sessionStorage.sslrootcert = sslrootcert;
|
|
}
|
|
const modelApiKeyFile = env.THT_MODEL_API_KEY_FILE;
|
|
if (modelApiKeyFile !== undefined && (
|
|
modelApiKeyFile.trim() !== modelApiKeyFile
|
|
|| modelApiKeyFile.length === 0
|
|
|| modelApiKeyFile.includes("\0")
|
|
|| !path.isAbsolute(modelApiKeyFile)
|
|
)) {
|
|
throw new Error("model credential configuration is invalid");
|
|
}
|
|
const secretsFile = env.THT_SECRETS_FILE;
|
|
if (secretsFile !== undefined && (
|
|
secretsFile.trim() !== secretsFile || secretsFile.length === 0 || secretsFile.includes("\0")
|
|
|| !path.isAbsolute(secretsFile)
|
|
)) throw new Error("secret bundle configuration is invalid");
|
|
const secretFiles: Record<string, string | undefined> = {};
|
|
for (const name of [
|
|
"THT_MODEL_API_KEY_SECRET_FILE", "THT_DWH_API_KEY_SECRET_FILE", "THT_VEC_API_KEY_SECRET_FILE",
|
|
"THT_VEC_WRITE_API_KEY_SECRET_FILE", "THT_CA_SECRET_FILE", "THT_VECTOR_BOOTSTRAP_PASSWORD_SECRET_FILE",
|
|
"THT_VECTOR_MIGRATOR_PASSWORD_SECRET_FILE", "THT_VECTOR_READER_PASSWORD_SECRET_FILE",
|
|
"THT_VECTOR_WRITER_PASSWORD_SECRET_FILE",
|
|
]) secretFiles[name] = env[name];
|
|
return {
|
|
host: env.HOST ?? "127.0.0.1",
|
|
port: Number(env.PORT ?? 8787),
|
|
harnessDir: env.THT_HARNESS_DIR ?? "../harness",
|
|
thtBin: env.THT_BIN ?? "tht",
|
|
piBin: env.PI_BIN ?? "pi",
|
|
authMode: authMode as AppConfig["authMode"],
|
|
sessionStorage,
|
|
defaults: { provider: env.PI_PROVIDER, model: env.PI_MODEL, thinking: env.PI_THINKING },
|
|
maxPiProcesses: Number(env.MAX_PI_PROCESSES ?? 4),
|
|
settingsFile: env.SETTINGS_FILE ?? "data/settings.json",
|
|
dataRoot: env.THT_DATA_ROOT,
|
|
ollamaEnsureTimeoutMs: Number(env.OLLAMA_ENSURE_TIMEOUT_MS ?? 60000),
|
|
secretsFile,
|
|
secretFiles,
|
|
modelApiKeyFile,
|
|
dwhPrecheck: env.THT_DWH_PRECHECK === "true" || env.THT_DWH_PRECHECK === "1",
|
|
};
|
|
}
|