feat: pre-check DWH reachability before creating a session (local dev only)
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>
This commit is contained in:
+1
-1
@@ -92,7 +92,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
app.get("/health", async () => ({ status: "ok" }));
|
||||
app.get("/me", async (req) => getPrincipal(req));
|
||||
sessionRoutes(app, {
|
||||
mgr, tht: tht as ThtRunner, hub, getSettings, readiness,
|
||||
mgr, tht: tht as ThtRunner, hub, getSettings, readiness, dwhPrecheck: config.dwhPrecheck,
|
||||
});
|
||||
sqlRoutes(app, { tht: tht as ThtRunner, getSettings });
|
||||
metaRoutes(app, { harnessDir: config.harnessDir, listModels });
|
||||
|
||||
@@ -16,6 +16,12 @@ export interface AppConfig {
|
||||
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";
|
||||
@@ -99,5 +105,6 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
|
||||
secretsFile,
|
||||
secretFiles,
|
||||
modelApiKeyFile,
|
||||
dwhPrecheck: env.THT_DWH_PRECHECK === "true" || env.THT_DWH_PRECHECK === "1",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ const READINESS_FAILURE_MESSAGE =
|
||||
"Session services are not ready. Check configuration and connectivity, then try again.";
|
||||
const RESUME_FAILURE_MESSAGE =
|
||||
"Session could not be resumed. Check configuration and connectivity, then try again.";
|
||||
const DWH_UNREACHABLE_MESSAGE =
|
||||
"Cannot start a session: the data warehouse is unreachable. Check the VPN connection and try again.";
|
||||
|
||||
function eventCursor(...values: unknown[]): number {
|
||||
let cursor = 0;
|
||||
@@ -30,6 +32,8 @@ export function sessionRoutes(
|
||||
mgr: PiProcessManager; tht: ThtRunner; hub: SseHub;
|
||||
getSettings: (principal: PrincipalContext) => Promise<Settings>;
|
||||
readiness: ReadinessManager;
|
||||
/** Local-only guard: probe DWH reachability before creating a session (run-stack.sh). */
|
||||
dwhPrecheck?: boolean;
|
||||
},
|
||||
) {
|
||||
const lifecycleTails = new Map<string, Promise<void>>();
|
||||
@@ -181,6 +185,16 @@ export function sessionRoutes(
|
||||
const runner = runnerFor(principal);
|
||||
const ensure = await d.readiness.ensure(s.workspace ?? "", principal);
|
||||
if (!ensure.ok) return reply.code(503).send({ error: READINESS_FAILURE_MESSAGE });
|
||||
// Local-only: verify the DWH is reachable BEFORE creating the session, so a dropped
|
||||
// VPN surfaces as an up-front alert instead of a session that spawns Pi and then dies
|
||||
// in bootstrap retrieval. `code` lets the client show a specific message.
|
||||
if (d.dwhPrecheck) {
|
||||
const ping = await runner.dbPing(s.workspace);
|
||||
if (!ping.ok) {
|
||||
console.error(`[dwh-precheck] refusing new session — DWH unreachable: ${ping.detail}`);
|
||||
return reply.code(503).send({ error: DWH_UNREACHABLE_MESSAGE, code: "dwh_unreachable" });
|
||||
}
|
||||
}
|
||||
// Settings (global) supply workspace/provider/model/thinking. The new-question
|
||||
// form sends only the question text. `workspace` selects the tht `-c <config>`.
|
||||
let id: string;
|
||||
|
||||
@@ -64,7 +64,9 @@ export class ThtRunner {
|
||||
return [...args, ...this.configArg(workspace)];
|
||||
}
|
||||
|
||||
run(args: string[], workspace?: string): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
run(
|
||||
args: string[], workspace?: string, timeoutMs?: number,
|
||||
): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const env: NodeJS.ProcessEnv = { ...process.env };
|
||||
delete env.THT_DATA_ROOT;
|
||||
@@ -78,11 +80,19 @@ export class ThtRunner {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const finish = (result: { code: number; stdout: string; stderr: string }) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
resolve(result);
|
||||
};
|
||||
if (timeoutMs !== undefined) {
|
||||
timer = setTimeout(() => {
|
||||
try { ch.kill("SIGKILL"); } catch { /* already gone */ }
|
||||
finish({ code: 124, stdout, stderr: stderr || `timed out after ${timeoutMs}ms` });
|
||||
}, timeoutMs);
|
||||
}
|
||||
ch.stdout.on("data", (d: Buffer) => (stdout += d));
|
||||
ch.stderr.on("data", (d: Buffer) => (stderr += d));
|
||||
ch.on("error", (error) => finish({ code: 1, stdout, stderr: stderr || error.message }));
|
||||
@@ -129,6 +139,17 @@ export class ThtRunner {
|
||||
if (code !== 0) throw new Error(`tht ${args.join(" ")} exit ${code}: ${stderr.trim()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe DWH reachability via `tht db ping` (a REST health check). Never throws —
|
||||
* returns ok=false with the failure detail so the caller can refuse a new session
|
||||
* cleanly. Timed out to keep POST /sessions responsive when the host hangs.
|
||||
*/
|
||||
async dbPing(workspace?: string): Promise<{ ok: boolean; detail: string }> {
|
||||
const { code, stdout, stderr } = await this.run(["db", "ping"], workspace, 10_000);
|
||||
if (code === 0) return { ok: true, detail: stdout.trim() };
|
||||
return { ok: false, detail: (stderr || stdout).trim() };
|
||||
}
|
||||
|
||||
sessionList(workspace?: string) {
|
||||
return this.json<SessionRow[]>(["session", "list", "--json"], workspace);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user