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
@@ -95,6 +95,32 @@ test("a failed create restores the landing view and preserves the question for r
expect(FakeEventSource.instances).toHaveLength(0);
});
test("a DWH-unreachable precheck shows a specific alert and preserves the question", async () => {
server.use(
http.post("http://localhost:8787/sessions", () =>
HttpResponse.json(
{
error: "Cannot start a session: the data warehouse is unreachable. Check the VPN connection and try again.",
code: "dwh_unreachable",
},
{ status: 503 },
)),
);
renderShell();
const composer = screen.getByRole("textbox", { name: /new question/i });
await userEvent.type(composer, "quanti pazienti?");
await userEvent.click(screen.getByRole("button", { name: /send/i }));
// Specific alert, not the generic "failed to create session" hint.
expect(await screen.findByText(/data warehouse is unreachable/i)).toBeInTheDocument();
expect(screen.queryByText(/failed to create session/i)).not.toBeInTheDocument();
// No session was created: landing view stays and the question is kept for retry.
expect(screen.getByText(/type your question/i)).toBeInTheDocument();
expect(composer).toHaveValue("quanti pazienti?");
expect(FakeEventSource.instances).toHaveLength(0);
});
test("model selector shows the three Pi-enabled models and persists the selected provider", async () => {
let saved: unknown;
server.use(
+2 -2
View File
@@ -387,10 +387,10 @@ export function AppShell() {
refresh();
}
function failSessionCreation() {
function failSessionCreation(message?: string) {
setCreatingSession(false);
resetSession();
toast.error("Failed to create session. Your question is ready to retry.");
toast.error(message ?? "Failed to create session. Your question is ready to retry.");
}
async function stopSession() {
+10 -3
View File
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react";
import { CornerDownLeft } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { postSteer, createSession } from "../api/sessions";
import { ApiError } from "../api/client";
import { getSettings, putSettings, type Settings } from "../api/settings";
import { listWorkspaces } from "../api/workspaces";
import { listModels } from "../api/models";
@@ -18,7 +19,7 @@ interface Props {
/** Paint the provisional session view before the create request completes. */
onSessionCreating?: (question: string) => void;
/** Revert the provisional view when session creation fails. */
onSessionCreateFailed?: () => void;
onSessionCreateFailed?: (message?: string) => void;
/** Interrupt the running session, persisting its state. */
onStop?: () => void;
/** Lets the parent focus the composer (e.g. on "New session"). */
@@ -82,9 +83,15 @@ export function SteerInput({
const { id } = await createSession({ question: trimmed });
onSessionCreated?.(id);
setText("");
} catch {
} catch (error) {
// Keep the question in the composer so retrying does not require retyping.
onSessionCreateFailed?.();
// A DWH-unreachable precheck (local dev, VPN down) carries a specific code;
// surface its message as the alert instead of the generic retry hint.
const payload = error instanceof ApiError
? (error.payload as { code?: string; error?: string } | undefined)
: undefined;
const alert = payload?.code === "dwh_unreachable" ? payload.error : undefined;
onSessionCreateFailed?.(alert);
} finally {
setBusy(false);
}