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
+57
View File
@@ -178,6 +178,63 @@ test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+a
unlinkSync(modelKey);
});
test("POST /sessions refuses to create a session when the local DWH precheck fails", async () => {
let sessionNewCalls = 0;
let pinged = 0;
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness", THT_DWH_PRECHECK: "1" }), {
thtRunner: {
dbPing: async () => { pinged += 1; return { ok: false, detail: "DWH non accessibile" }; },
sessionNew: async () => { sessionNewCalls += 1; return { id: "s1" }; },
searchPack: async () => {},
} as any,
readiness: { ensure: async () => ({ ok: true }) } as any,
getSettings: () => ({ workspace: "w" }) as any,
});
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
expect(res.statusCode).toBe(503);
expect(res.json()).toMatchObject({ code: "dwh_unreachable" });
expect(pinged).toBe(1);
expect(sessionNewCalls).toBe(0); // session must not be created
});
test("POST /sessions proceeds past a passing DWH precheck", async () => {
let pinged = 0;
let created = 0;
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness", THT_DWH_PRECHECK: "1" }), {
thtRunner: {
dbPing: async () => { pinged += 1; return { ok: true, detail: "OK" }; },
sessionNew: async () => { created += 1; return { id: "s1" }; },
searchPack: async () => {},
sessionList: async () => [{ id: "s1" }],
} as any,
readiness: { ensure: async () => ({ ok: true }) } as any,
getSettings: () => ({ workspace: "w" }) as any,
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
});
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
expect(res.statusCode).toBe(200);
expect(pinged).toBe(1);
expect(created).toBe(1);
});
test("POST /sessions skips the DWH precheck when the flag is off (default)", async () => {
let pinged = 0;
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
dbPing: async () => { pinged += 1; return { ok: false, detail: "should never run" }; },
sessionNew: async () => ({ id: "s1" }),
searchPack: async () => {},
sessionList: async () => [{ id: "s1" }],
} as any,
readiness: { ensure: async () => ({ ok: true }) } as any,
getSettings: () => ({ workspace: "w" }) as any,
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
});
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
expect(res.statusCode).toBe(200);
expect(pinged).toBe(0); // probe never runs without the flag
});
test("POST /sessions configura Pi con il thinking globale selezionato", async () => {
let configured: any;
const bridge = { onClientEvent: () => {}, emitClientEvent: () => {} };