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:
2026-07-18 12:08:47 +02:00
co-authored by Claude Opus 4.8
parent 84da3b149b
commit 3453f3ae23
10 changed files with 158 additions and 8 deletions
+22 -1
View File
@@ -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);
}