diff --git a/backend/src/app.ts b/backend/src/app.ts index 9881fbe8..b40c915c 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -43,7 +43,10 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc app.addHook("preHandler", authPreHandler(config.authMode)); app.get("/health", async () => ({ status: "ok" })); - sessionRoutes(app, { mgr, tht: tht as ThtRunner, hub, getSettings }); + sessionRoutes(app, { + mgr, tht: tht as ThtRunner, hub, getSettings, + ollamaEnsureTimeoutSec: Math.round(config.ollamaEnsureTimeoutMs / 1000), + }); sqlRoutes(app, { tht: tht as ThtRunner }); metaRoutes(app, { harnessDir: config.harnessDir, listModels }); settingsRoutes(app, { cfg: config, listModels }); diff --git a/backend/src/config.ts b/backend/src/config.ts index a10bf53f..d16dd5f8 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -4,6 +4,7 @@ export interface AppConfig { defaults: { provider?: string; model?: string; thinking?: string }; maxPiProcesses: number; settingsFile: string; + ollamaEnsureTimeoutMs: number; } export function loadConfig(env: Record): AppConfig { return { @@ -15,5 +16,6 @@ export function loadConfig(env: Record): AppConfig { defaults: { provider: env.PI_PROVIDER, model: env.PI_MODEL, thinking: env.PI_THINKING }, maxPiProcesses: Number(env.MAX_PI_PROCESSES ?? 4), settingsFile: env.SETTINGS_FILE ?? "data/settings.json", + ollamaEnsureTimeoutMs: Number(env.OLLAMA_ENSURE_TIMEOUT_MS ?? 60000), }; } diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index 5afad862..4c21c47d 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -7,11 +7,13 @@ import { getUser } from "../auth/auth.js"; export function sessionRoutes( app: FastifyInstance, - d: { mgr: PiProcessManager; tht: ThtRunner; hub: SseHub; getSettings: () => Settings }, + d: { mgr: PiProcessManager; tht: ThtRunner; hub: SseHub; getSettings: () => Settings; ollamaEnsureTimeoutSec: number }, ) { app.post("/sessions", async (req, reply) => { const b = req.body as { question: string; name?: string }; const s = d.getSettings(); + const ensure = await d.tht.ollamaEnsure(s.workspace ?? "", d.ollamaEnsureTimeoutSec); + if (!ensure.ok) return reply.code(503).send({ error: ensure.error ?? "Ollama/embeddings non disponibili" }); // Settings (global) supply workspace/provider/model/thinking. The new-question // form sends only the question text. `workspace` selects the tht `-c `. const { id } = await d.tht.sessionNew({ @@ -48,6 +50,8 @@ export function sessionRoutes( }); app.post("/sessions/:id/resume", async (req, reply) => { const id = (req.params as any).id; + const ensure = await d.tht.ollamaEnsure(d.getSettings().workspace ?? "", d.ollamaEnsureTimeoutSec); + if (!ensure.ok) return reply.code(503).send({ error: ensure.error ?? "Ollama/embeddings non disponibili" }); const manifest = (await d.tht.sessionShow(id)) as { status?: string; archived?: boolean } | null; if (manifest?.status === "finalized" || manifest?.archived) { return reply.code(409).send({ error: "sessione in sola lettura (finalizzata o archiviata)" }); diff --git a/backend/test/e2e-f1.test.ts b/backend/test/e2e-f1.test.ts index 8c3f1fb1..2ae45720 100644 --- a/backend/test/e2e-f1.test.ts +++ b/backend/test/e2e-f1.test.ts @@ -10,6 +10,7 @@ const SCRIPT = path.resolve("../harness/tests/fake_pi/scripts/f1_disambiguation. test("loop F1: crea sessione → SSE riceve il widget → risponde → 204", async () => { const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { thtRunner: { + ollamaEnsure: async () => ({ ok: true }), sessionNew: async () => ({ id: "s1" }), sessionShow: async (_id: string) => ({ id: "s1", provider: undefined, model: undefined, thinking: undefined }), sessionList: async () => [], diff --git a/backend/test/routes-sessions.test.ts b/backend/test/routes-sessions.test.ts index ee01fb00..9162892e 100644 --- a/backend/test/routes-sessions.test.ts +++ b/backend/test/routes-sessions.test.ts @@ -9,7 +9,7 @@ const SCRIPT = path.resolve("../harness/tests/fake_pi/scripts/f1_disambiguation. function mutApp(thtRunner: any) { return buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { - thtRunner, + thtRunner: { ollamaEnsure: async () => ({ ok: true }), ...thtRunner }, getSettings: () => ({ workspace: "w" }) as any, spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any, }); @@ -20,6 +20,7 @@ test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+a let spawnArg: any; const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { thtRunner: { + ollamaEnsure: async () => ({ ok: true }), sessionNew: async (o: any) => { sessionNewArg = o; return { id: "s1" }; }, sessionList: async () => [{ id: "s1" }], } as any, @@ -40,6 +41,7 @@ test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+a test("POST /sessions/:id/response inoltra al bridge (no error)", async () => { const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { thtRunner: { + ollamaEnsure: async () => ({ ok: true }), sessionNew: async () => ({ id: "s1" }), sessionList: async () => [], } as any, @@ -118,3 +120,47 @@ test("POST resume on an archived session is refused with 409", async () => { const res = await app.inject({ method: "POST", url: "/sessions/s1/resume" }); expect(res.statusCode).toBe(409); }); + +test("POST /sessions refuses with 503 when ollamaEnsure fails (no session created)", async () => { + let createdCalled = false; + const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + thtRunner: { + ollamaEnsure: async () => ({ ok: false, stage: "model", error: "modello non installato" }), + sessionNew: async () => { createdCalled = true; return { id: "s1" }; }, + } as any, + getSettings: () => ({ workspace: "psd" }) 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(503); + expect(res.json().error).toContain("non installato"); + expect(createdCalled).toBe(false); +}); + +test("POST /sessions proceeds when ollamaEnsure succeeds", async () => { + let ensureWs: string | undefined; + const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + thtRunner: { + ollamaEnsure: async (ws: string) => { ensureWs = ws; return { ok: true }; }, + sessionNew: async () => ({ id: "s1" }), + } as any, + getSettings: () => ({ workspace: "psd" }) as any, + spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any, + }); + const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } }); + expect(res.json()).toEqual({ id: "s1" }); + expect(ensureWs).toBe("psd"); +}); + +test("POST /sessions/:id/resume refuses with 503 when ollamaEnsure fails", async () => { + const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + thtRunner: { + ollamaEnsure: async () => ({ ok: false, error: "Ollama down" }), + sessionShow: async () => ({ status: "open", archived: false }), + } as any, + getSettings: () => ({ workspace: "psd" }) as any, + spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any, + }); + const res = await app.inject({ method: "POST", url: "/sessions/s1/resume" }); + expect(res.statusCode).toBe(503); +});