fix: use Pi user auth and handle startup failures

This commit is contained in:
2026-07-21 14:01:47 +02:00
parent 2ff63d371f
commit 801f847ec4
12 changed files with 311 additions and 6 deletions
+2 -1
View File
@@ -95,7 +95,8 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
app.get("/health/dwh", async () => tht.dbPing());
app.get("/me", async (req) => getPrincipal(req));
sessionRoutes(app, {
mgr, tht: tht as ThtRunner, hub, getSettings, readiness, dwhPrecheck: config.dwhPrecheck,
mgr, tht: tht as ThtRunner, hub, getSettings, readiness, listModels,
dwhPrecheck: config.dwhPrecheck,
});
sqlRoutes(app, { tht: tht as ThtRunner, getSettings });
metaRoutes(app, { harnessDir: config.harnessDir, listModels });
+39 -2
View File
@@ -6,6 +6,7 @@ import type { Settings } from "../settings/settings-store.js";
import { getPrincipal } from "../auth/auth.js";
import type { PrincipalContext } from "../auth/principal.js";
import type { ReadinessManager } from "../runtime/readiness-manager.js";
import type { ListModelsFn } from "./meta.js";
const BOOTSTRAP_FAILURE_MESSAGE =
"Session startup failed. Check configuration and connectivity, then Resume the session.";
@@ -15,6 +16,8 @@ const RESUME_FAILURE_MESSAGE =
"Session could not be resumed. Check configuration and connectivity, then try again.";
const DWH_UNREACHABLE_MESSAGE =
"Cannot start a session: the database is unreachable. Check the VPN connection and try again.";
const MODEL_UNAVAILABLE_MESSAGE =
"Selected model is unavailable. Check Pi authentication and model settings, then try again.";
export function sessionRoutes(
app: FastifyInstance,
@@ -22,6 +25,7 @@ export function sessionRoutes(
mgr: PiProcessManager; tht: ThtRunner; hub: SseHub;
getSettings: (principal: PrincipalContext) => Promise<Settings>;
readiness: ReadinessManager;
listModels: ListModelsFn;
/** Local-only guard: probe DWH reachability before creating a session (run-stack.sh). */
dwhPrecheck?: boolean;
},
@@ -189,6 +193,26 @@ export function sessionRoutes(
return reply.code(503).send({ error: DWH_UNREACHABLE_MESSAGE, code: "dwh_unreachable" });
}
}
if (s.provider && s.model) {
let available: Awaited<ReturnType<ListModelsFn>>;
try {
available = await d.listModels();
} catch {
return reply.code(503).send({
error: MODEL_UNAVAILABLE_MESSAGE,
code: "model_unavailable",
});
}
const selectedAvailable = available.some(
(candidate) => candidate.provider === s.provider && candidate.id === s.model,
);
if (!selectedAvailable) {
return reply.code(503).send({
error: MODEL_UNAVAILABLE_MESSAGE,
code: "model_unavailable",
});
}
}
// Settings (global) supply workspace/provider/model/thinking. The new-question
// form sends only the question text. `workspace` selects the tht `-c <config>`.
let id: string;
@@ -206,8 +230,21 @@ export function sessionRoutes(
principal,
question: b.question,
};
const rt = d.mgr.createFor(id, options);
bindRuntime(id, rt, runner, s.workspace);
let rt: ReturnType<PiProcessManager["createFor"]> | undefined;
try {
rt = d.mgr.createFor(id, options);
bindRuntime(id, rt, runner, s.workspace);
} catch (error) {
if (rt) d.mgr.teardownIfCurrent(id, rt);
console.error(
`[pi:${id}] runtime construction failed:`,
error instanceof Error ? error.message : "unknown error",
);
await runner.failSession(id, s.workspace).catch((persistenceError: unknown) => {
console.error(`[session:${id}] failSession persistence failed:`, persistenceError);
});
return reply.code(503).send({ error: BOOTSTRAP_FAILURE_MESSAGE });
}
info(id, "Session created");
bootstrap(
id, rt, runner, s.workspace, d.mgr.configure(rt, options),
+69
View File
@@ -164,6 +164,9 @@ test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+a
sessionList: async () => [{ id: "s1" }],
} as any,
getSettings: () => ({ workspace: "w", provider: "zai", model: "glm-5.2", thinking: "high" }),
listModels: async () => [
{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true },
],
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
});
const created = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
@@ -259,6 +262,9 @@ test("POST /sessions configura Pi con il thinking globale selezionato", async ()
sessionNew: async () => ({ id: "s-thinking" }),
} as any,
getSettings: () => ({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "high" }) as any,
listModels: async () => [
{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true },
],
});
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
@@ -1539,6 +1545,69 @@ test("POST /sessions proceeds when ollamaEnsure succeeds", async () => {
expect(ensureWs).toBe("psd");
});
test("POST /sessions rejects an unavailable saved model before persisting a session", async () => {
let created = 0;
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
sessionNew: async () => { created += 1; return { id: "must-not-exist" }; },
} as any,
readiness: { ensure: async () => ({ ok: true }) } as any,
getSettings: () => ({
workspace: "psd",
provider: "deepseek",
model: "deepseek-v4-pro",
thinking: "medium",
}) as any,
listModels: async () => [
{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true },
],
});
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
expect(res.statusCode).toBe(503);
expect(res.json()).toEqual({
error: "Selected model is unavailable. Check Pi authentication and model settings, then try again.",
code: "model_unavailable",
});
expect(created).toBe(0);
});
test("POST /sessions marks a persisted session failed when runtime construction throws", async () => {
let failed = 0;
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
mgr: {
createFor: () => { throw new Error("provider bootstrap unavailable"); },
} as any,
thtRunner: {
sessionNew: async () => ({ id: "s-runtime-failure" }),
failSession: async (id: string, workspace: string) => {
expect(id).toBe("s-runtime-failure");
expect(workspace).toBe("psd");
failed += 1;
},
} as any,
readiness: { ensure: async () => ({ ok: true }) } as any,
getSettings: () => ({
workspace: "psd",
provider: "deepseek",
model: "deepseek-v4-pro",
thinking: "medium",
}) as any,
listModels: async () => [
{ provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true },
],
});
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
expect(res.statusCode).toBe(503);
expect(res.json()).toEqual({
error: "Session startup failed. Check configuration and connectivity, then Resume the session.",
});
expect(failed).toBe(1);
});
test("POST /sessions/:id/resume returns 409 for a read-only session without calling ollamaEnsure", async () => {
let ensureCalled = false;
const app = mutApp({