feat: harden runtime readiness and session workflow

This commit is contained in:
User
2026-07-14 10:27:25 +02:00
parent 6d7738d538
commit 6dbf93fff9
52 changed files with 1464 additions and 169 deletions
+85 -11
View File
@@ -4,15 +4,57 @@ import type { ThtRunner } from "../tht/tht-runner.js";
import type { SseHub } from "../sse/sse-hub.js";
import type { Settings } from "../settings/settings-store.js";
import { getUser } from "../auth/auth.js";
import type { ReadinessManager } from "../runtime/readiness-manager.js";
export function sessionRoutes(
app: FastifyInstance,
d: { mgr: PiProcessManager; tht: ThtRunner; hub: SseHub; getSettings: () => Settings; ollamaEnsureTimeoutSec: number },
d: { mgr: PiProcessManager; tht: ThtRunner; hub: SseHub; getSettings: () => Settings; readiness: ReadinessManager },
) {
const info = (id: string, text: string, level = "info") =>
d.hub.publish(id, "info", { type: "info", level, text });
const bindRuntime = (id: string, rt: ReturnType<PiProcessManager["createFor"]>) =>
rt.bridge.onClientEvent((e) => {
if (e.type === "system_event" && e.event === "session_failed") {
void d.tht.failSession(id, d.getSettings().workspace).catch(() => undefined);
}
d.hub.publish(id, e.type, e);
});
const bootstrap = (
id: string,
rt: ReturnType<PiProcessManager["createFor"]>,
configure: Promise<void>,
retrieval: Promise<void> | null,
start: () => void,
) => {
void (async () => {
try {
if (retrieval) info(id, "Preparing retrieval context");
await Promise.all([configure, retrieval]);
info(id, "Starting model");
start();
} catch (error) {
d.mgr.teardown(id);
const text = error instanceof Error ? error.message : String(error);
void d.tht.failSession(id, d.getSettings().workspace).catch(() => undefined);
rt.bridge.emitClientEvent({ type: "info", level: "error", text: `Session bootstrap failed: ${text}` });
rt.bridge.emitClientEvent({ type: "system_event", event: "session_failed" });
rt.bridge.emitClientEvent({ type: "system_event", event: "agent_end" });
}
})();
};
app.post("/runtime/prewarm", async (_req, reply) => {
const workspace = d.getSettings().workspace ?? "";
void d.readiness.ensure(workspace).catch(() => undefined);
return reply.code(202).send({ status: "warming" });
});
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);
const ensure = await d.readiness.ensure(s.workspace ?? "");
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 <config>`.
@@ -24,14 +66,22 @@ export function sessionRoutes(
model: s.model,
thinking: s.thinking,
});
const rt = await d.mgr.spawnFor(id, {
const options = {
provider: s.provider,
model: s.model,
thinking: s.thinking,
// Keep the saved preference in the manifest; F1 starts tool-first.
thinking: "off",
author: getUser(req).id,
question: b.question,
});
rt.bridge.onClientEvent((e) => d.hub.publish(id, e.type, e));
};
const rt = d.mgr.createFor(id, options);
bindRuntime(id, rt);
info(id, "Session created");
bootstrap(
id, rt, d.mgr.configure(rt, options),
d.tht.searchPack(b.question, id, s.workspace),
() => d.mgr.start(id, rt, options),
);
return { id };
});
app.get("/sessions", async () => d.tht.sessionList(d.getSettings().workspace));
@@ -51,20 +101,43 @@ export function sessionRoutes(
});
app.post("/sessions/:id/resume", async (req, reply) => {
const id = (req.params as any).id;
if (d.mgr.get(id)) {
// Idempotent resume: reattach the browser to the existing Pi runtime.
// Do not respawn it (which would discard a pending reviewer widget).
return reply.code(200).send({ id, alreadyActive: true });
}
const manifest = (await d.tht.sessionShow(id, d.getSettings().workspace)) 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)" });
}
const ensure = await d.tht.ollamaEnsure(d.getSettings().workspace ?? "", d.ollamaEnsureTimeoutSec);
const settings = d.getSettings();
const ensure = await d.readiness.ensure(settings.workspace ?? "");
if (!ensure.ok) return reply.code(503).send({ error: ensure.error ?? "Ollama/embeddings non disponibili" });
const rt = await d.mgr.resume(id, d.tht);
rt.bridge.onClientEvent((e) => d.hub.publish(id, e.type, e));
const saved = manifest as { provider?: string; model?: string; thinking?: string } | null;
const options = {
provider: saved?.provider,
model: saved?.model,
// Phase 1 must reach a widget instead of exposing a long reasoning trace.
// The session keeps its saved preference for later turns.
thinking: "off",
author: getUser(req).id,
mode: "resume" as const,
};
await d.tht.reopenSession(id, settings.workspace);
const rt = d.mgr.createFor(id, options);
bindRuntime(id, rt);
info(id, "Resuming session");
bootstrap(id, rt, d.mgr.configure(rt, options), null, () => d.mgr.start(id, rt, options));
return reply.code(200).send({ id });
});
app.post("/sessions/:id/close", async (req) => {
const id = (req.params as { id: string }).id;
d.mgr.teardown(id);
d.hub.clear(id);
try {
await d.tht.closeSession(id, d.getSettings().workspace);
} finally {
d.mgr.teardown(id);
d.hub.clear(id);
}
return { closed: true };
});
app.get("/sessions/:id/events", (req, reply) => {
@@ -76,6 +149,7 @@ export function sessionRoutes(
reply.raw.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
Connection: "keep-alive",
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",