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:
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user