feat: harden runtime readiness and session workflow
This commit is contained in:
+7
-2
@@ -11,6 +11,7 @@ import { metaRoutes, type ListModelsFn } from "./routes/meta.js";
|
||||
import { settingsRoutes, effectiveSettings } from "./routes/settings.js";
|
||||
import { createPiModelLister } from "./pi/list-models.js";
|
||||
import { loadSettings, type Settings } from "./settings/settings-store.js";
|
||||
import { ReadinessManager } from "./runtime/readiness-manager.js";
|
||||
|
||||
export interface BuildAppDeps {
|
||||
thtRunner?: ThtRunner;
|
||||
@@ -18,6 +19,7 @@ export interface BuildAppDeps {
|
||||
spawnFn?: () => any;
|
||||
listModels?: ListModelsFn;
|
||||
getSettings?: () => Settings;
|
||||
readiness?: ReadinessManager;
|
||||
}
|
||||
|
||||
export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance {
|
||||
@@ -38,6 +40,10 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
});
|
||||
const mgr = deps?.mgr ?? new PiProcessManager(config, deps?.spawnFn ? { spawnFn: deps.spawnFn } : undefined);
|
||||
const hub = new SseHub();
|
||||
const readiness = deps?.readiness ?? new ReadinessManager(
|
||||
tht as ThtRunner,
|
||||
Math.round(config.ollamaEnsureTimeoutMs / 1000),
|
||||
);
|
||||
|
||||
const listModels = deps?.listModels ?? createPiModelLister(config);
|
||||
const getSettings = deps?.getSettings ?? (() => effectiveSettings(config, loadSettings(config)));
|
||||
@@ -50,8 +56,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
});
|
||||
app.get("/health", async () => ({ status: "ok" }));
|
||||
sessionRoutes(app, {
|
||||
mgr, tht: tht as ThtRunner, hub, getSettings,
|
||||
ollamaEnsureTimeoutSec: Math.round(config.ollamaEnsureTimeoutMs / 1000),
|
||||
mgr, tht: tht as ThtRunner, hub, getSettings, readiness,
|
||||
});
|
||||
sqlRoutes(app, { tht: tht as ThtRunner });
|
||||
metaRoutes(app, { harnessDir: config.harnessDir, listModels });
|
||||
|
||||
@@ -12,6 +12,15 @@ export interface SessionRuntime {
|
||||
child: ChildProcessWithoutNullStreams;
|
||||
}
|
||||
|
||||
export interface RuntimeOptions {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
thinking?: string;
|
||||
author?: string;
|
||||
question?: string;
|
||||
mode?: "new" | "resume";
|
||||
}
|
||||
|
||||
/** Injectable child-process boundary; callbacks may ignore arguments in simpler tests. */
|
||||
type SpawnFn = (
|
||||
command: string,
|
||||
@@ -68,17 +77,13 @@ export class PiProcessManager {
|
||||
|
||||
get(id: string): SessionRuntime | undefined { return this.runtimes.get(id); }
|
||||
|
||||
async spawnFor(
|
||||
sessionId: string,
|
||||
o: { provider?: string; model?: string; thinking?: string; author?: string; question?: string; mode?: "new" | "resume" },
|
||||
): Promise<SessionRuntime> {
|
||||
// Idempotent per session id: tear down any existing runtime for this id
|
||||
// first (before the cap check) so a resume/respawn neither leaks the old
|
||||
// child nor falsely hits the process cap.
|
||||
/** Spawn and register a runtime synchronously, without starting a model turn. */
|
||||
createFor(sessionId: string, o: RuntimeOptions = {}): SessionRuntime {
|
||||
// A duplicate start must never tear down a live session: that used to send
|
||||
// SIGTERM to the in-flight Pi process and lose its pending gate.
|
||||
const existing = this.runtimes.get(sessionId);
|
||||
if (existing) {
|
||||
existing.child.kill();
|
||||
this.runtimes.delete(sessionId);
|
||||
throw new Error(`session runtime already active: ${sessionId}`);
|
||||
}
|
||||
if (this.runtimes.size >= this.cfg.maxPiProcesses) {
|
||||
throw new Error("max Pi processes reached");
|
||||
@@ -103,24 +108,41 @@ export class PiProcessManager {
|
||||
level: "error",
|
||||
text: `Pi process exited unexpectedly (code ${code ?? "?"})`,
|
||||
});
|
||||
rt.bridge.emitClientEvent({ type: "system_event", event: "session_failed" });
|
||||
rt.bridge.emitClientEvent({ type: "system_event", event: "agent_end" });
|
||||
}
|
||||
});
|
||||
|
||||
return rt;
|
||||
}
|
||||
|
||||
/** Configure model and thinking. Safe to run alongside deterministic retrieval. */
|
||||
async configure(rt: SessionRuntime, o: RuntimeOptions = {}): Promise<void> {
|
||||
const provider = canonicalPiProvider(o.provider ?? this.cfg.defaults.provider);
|
||||
const model = o.model ?? this.cfg.defaults.model;
|
||||
const thinking = o.thinking ?? this.cfg.defaults.thinking;
|
||||
|
||||
if (provider && model) {
|
||||
await rpc.request({ type: "set_model", provider, modelId: model } as object & { type: string });
|
||||
await rt.rpc.request({ type: "set_model", provider, modelId: model } as object & { type: string });
|
||||
}
|
||||
if (thinking) {
|
||||
await rpc.request({ type: "set_thinking_level", level: thinking } as object & { type: string });
|
||||
await rt.rpc.request({ type: "set_thinking_level", level: thinking } as object & { type: string });
|
||||
}
|
||||
}
|
||||
|
||||
/** Start the first turn only after callers have attached the runtime bridge. */
|
||||
start(sessionId: string, rt: SessionRuntime, o: RuntimeOptions = {}): void {
|
||||
if (this.runtimes.get(sessionId) !== rt) throw new Error("session runtime is no longer active");
|
||||
const message = o.mode === "resume"
|
||||
? `/riprendi-sessione ${sessionId}`
|
||||
: `/nuova-domanda ${JSON.stringify(o.question ?? "")}`;
|
||||
rpc.send({ type: "prompt", message });
|
||||
rt.rpc.send({ type: "prompt", message });
|
||||
}
|
||||
|
||||
async spawnFor(sessionId: string, o: RuntimeOptions = {}): Promise<SessionRuntime> {
|
||||
const rt = this.createFor(sessionId, o);
|
||||
await this.configure(rt, o);
|
||||
this.start(sessionId, rt, o);
|
||||
return rt;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { OllamaEnsureResult, ThtRunner } from "../tht/tht-runner.js";
|
||||
|
||||
interface ReadyEntry {
|
||||
expiresAt: number;
|
||||
result: OllamaEnsureResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates embedding readiness checks and keeps only short-lived successes.
|
||||
* Failures are deliberately not cached so a submit can retry after a transient outage.
|
||||
*/
|
||||
export class ReadinessManager {
|
||||
private inFlight = new Map<string, Promise<OllamaEnsureResult>>();
|
||||
private ready = new Map<string, ReadyEntry>();
|
||||
|
||||
constructor(
|
||||
private tht: ThtRunner,
|
||||
private timeoutSec: number,
|
||||
private ttlMs = 60_000,
|
||||
private now: () => number = Date.now,
|
||||
) {}
|
||||
|
||||
ensure(workspace = ""): Promise<OllamaEnsureResult> {
|
||||
const cached = this.ready.get(workspace);
|
||||
if (cached && cached.expiresAt > this.now()) return Promise.resolve(cached.result);
|
||||
if (cached) this.ready.delete(workspace);
|
||||
|
||||
const current = this.inFlight.get(workspace);
|
||||
if (current) return current;
|
||||
|
||||
const pending = this.tht.ollamaEnsure(workspace, this.timeoutSec)
|
||||
.then((result) => {
|
||||
if (result.ok) {
|
||||
this.ready.set(workspace, { result, expiresAt: this.now() + this.ttlMs });
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.inFlight.get(workspace) === pending) this.inFlight.delete(workspace);
|
||||
});
|
||||
this.inFlight.set(workspace, pending);
|
||||
return pending;
|
||||
}
|
||||
}
|
||||
@@ -83,8 +83,8 @@ export class ThtRunner {
|
||||
return JSON.parse(stdout) as T;
|
||||
}
|
||||
|
||||
private async ok(args: string[]): Promise<void> {
|
||||
const { code, stderr } = await this.run(args);
|
||||
private async ok(args: string[], workspace?: string): Promise<void> {
|
||||
const { code, stderr } = await this.run(args, workspace);
|
||||
if (code !== 0) throw new Error(`tht ${args.join(" ")} exit ${code}: ${stderr.trim()}`);
|
||||
}
|
||||
|
||||
@@ -109,6 +109,13 @@ export class ThtRunner {
|
||||
return this.json<{ id: string }>(a, o.workspace);
|
||||
}
|
||||
|
||||
/** Build and persist the deterministic F1 retrieval pack for a new session. */
|
||||
async searchPack(question: string, sessionId: string, workspace?: string): Promise<void> {
|
||||
const args = ["search", "pack", question, "--session", sessionId];
|
||||
const { code, stderr } = await this.run(args, workspace);
|
||||
if (code !== 0) throw new Error(`tht ${args.join(" ")} exit ${code}: ${stderr.trim()}`);
|
||||
}
|
||||
|
||||
sessionList(workspace?: string) {
|
||||
return this.json<SessionRow[]>(["session", "list", "--json"], workspace);
|
||||
}
|
||||
@@ -137,6 +144,9 @@ export class ThtRunner {
|
||||
return { path: stdout.trim() };
|
||||
}
|
||||
|
||||
closeSession(id: string, workspace?: string) { return this.ok(["session", "close", id], workspace); }
|
||||
failSession(id: string, workspace?: string) { return this.ok(["session", "fail", id], workspace); }
|
||||
reopenSession(id: string, workspace?: string) { return this.ok(["session", "reopen", id], workspace); }
|
||||
setName(id: string, name: string) { return this.ok(["session", "set-name", id, "--name", name]); }
|
||||
setGroup(id: string, group: string) { return this.ok(["session", "set-group", id, "--group", group]); }
|
||||
archive(id: string) { return this.ok(["session", "archive", id]); }
|
||||
|
||||
@@ -32,6 +32,7 @@ test("loop F1: crea sessione → SSE riceve il widget → risponde → il modell
|
||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||
thtRunner: {
|
||||
ollamaEnsure: async () => ({ ok: true }),
|
||||
searchPack: async () => {},
|
||||
sessionNew: async () => ({ id: "s1" }),
|
||||
sessionShow: async (_id: string) => ({ id: "s1", provider: undefined, model: undefined, thinking: undefined }),
|
||||
sessionList: async () => [],
|
||||
|
||||
@@ -38,6 +38,7 @@ test("SSE response headers are flushed before the first event", async () => {
|
||||
]);
|
||||
expect(response.headers.get("content-type")).toContain("text/event-stream");
|
||||
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||
expect(response.headers.get("x-accel-buffering")).toBe("no");
|
||||
} finally {
|
||||
controller.abort();
|
||||
await app.close();
|
||||
|
||||
@@ -36,27 +36,30 @@ test("l'exit del child rimuove il runtime dalla mappa (exit handler)", async ()
|
||||
expect(mgr.get("exit-test")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("spawnFor sullo STESSO id uccide il vecchio child e sostituisce il runtime (count resta 1)", async () => {
|
||||
test("spawnFor sullo STESSO id rifiuta il duplicato senza interrompere il runtime attivo", async () => {
|
||||
const cfg = loadConfig({ THT_HARNESS_DIR: "../harness" });
|
||||
const mgr = new PiProcessManager(cfg, { spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any });
|
||||
const first = await mgr.spawnFor("dup-id", {});
|
||||
expect(mgr.count()).toBe(1);
|
||||
const firstExited = new Promise<void>((res) => first.child.on("exit", () => res()));
|
||||
const second = await mgr.spawnFor("dup-id", {});
|
||||
await firstExited; // the old child was killed by the idempotent respawn
|
||||
await expect(mgr.spawnFor("dup-id", {})).rejects.toThrow(
|
||||
"session runtime already active: dup-id",
|
||||
);
|
||||
expect(mgr.count()).toBe(1);
|
||||
expect(mgr.get("dup-id")).toBe(second);
|
||||
expect(second).not.toBe(first);
|
||||
expect(mgr.get("dup-id")).toBe(first);
|
||||
mgr.teardown("dup-id");
|
||||
});
|
||||
|
||||
test("l'exit del VECCHIO child non elimina il nuovo runtime (exit identity-checked)", async () => {
|
||||
const cfg = loadConfig({ THT_HARNESS_DIR: "../harness" });
|
||||
const mgr = new PiProcessManager(cfg, { spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any });
|
||||
const first = await mgr.spawnFor("respawn-id", {});
|
||||
const second = await mgr.spawnFor("respawn-id", {});
|
||||
const firstChild = recordingChild();
|
||||
const secondChild = recordingChild();
|
||||
const children = [firstChild, secondChild];
|
||||
const mgr = new PiProcessManager(cfg, { spawnFn: () => children.shift() as any });
|
||||
const first = mgr.createFor("respawn-id", {});
|
||||
mgr.teardown("respawn-id");
|
||||
const second = mgr.createFor("respawn-id", {});
|
||||
// The old child's exit handler fires after the respawn; it must NOT evict `second`.
|
||||
await new Promise((res) => setImmediate(res));
|
||||
firstChild.emit("exit", 0);
|
||||
expect(mgr.get("respawn-id")).toBe(second);
|
||||
expect(mgr.count()).toBe(1);
|
||||
void first;
|
||||
@@ -91,6 +94,7 @@ test("un exit INATTESO del child notifica il client (info error + agent_end)", a
|
||||
child.emit("exit", 137);
|
||||
expect(seen).toEqual([
|
||||
{ type: "info", level: "error", text: expect.stringContaining("137") },
|
||||
{ type: "system_event", event: "session_failed" },
|
||||
{ type: "system_event", event: "agent_end" },
|
||||
]);
|
||||
expect(mgr.count()).toBe(0);
|
||||
@@ -138,6 +142,19 @@ test("spawnFor new mode forwards the real question instead of kickoff", async ()
|
||||
mgr.teardown("sid-question");
|
||||
});
|
||||
|
||||
test("createFor does not prompt until start is called", async () => {
|
||||
const cfg = loadConfig({});
|
||||
const child = recordingChild();
|
||||
const mgr = new PiProcessManager(cfg, { spawnFn: () => child as any });
|
||||
const rt = mgr.createFor("sid-deferred", { question: "q" });
|
||||
expect(child._writes).toEqual([]);
|
||||
await mgr.configure(rt, {});
|
||||
expect(child._writes).toEqual([]);
|
||||
mgr.start("sid-deferred", rt, { question: "q" });
|
||||
expect(JSON.parse(child._writes.at(-1)!).message).toBe('/nuova-domanda "q"');
|
||||
mgr.teardown("sid-deferred");
|
||||
});
|
||||
|
||||
test("production spawn uses explicit Pi path and passes portable data root without rewriting PATH", async () => {
|
||||
vi.stubEnv("PATH", "/usr/local/bin:/usr/bin");
|
||||
vi.stubEnv("PI_PROVIDER_API_KEY", "provider-secret");
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { ReadinessManager } from "../src/runtime/readiness-manager.js";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => { resolve = r; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
test("readiness is single-flight per workspace and caches a short-lived success", async () => {
|
||||
let now = 1_000;
|
||||
let calls = 0;
|
||||
const first = deferred<any>();
|
||||
const tht = {
|
||||
ollamaEnsure: async () => {
|
||||
calls += 1;
|
||||
return first.promise;
|
||||
},
|
||||
} as any;
|
||||
const readiness = new ReadinessManager(tht, 60, 500, () => now);
|
||||
|
||||
const a = readiness.ensure("psd");
|
||||
const b = readiness.ensure("psd");
|
||||
expect(calls).toBe(1);
|
||||
first.resolve({ ok: true, model_name: "embed" });
|
||||
await expect(a).resolves.toMatchObject({ ok: true });
|
||||
await expect(b).resolves.toMatchObject({ ok: true });
|
||||
|
||||
await readiness.ensure("psd");
|
||||
expect(calls).toBe(1);
|
||||
now += 501;
|
||||
tht.ollamaEnsure = async () => { calls += 1; return { ok: true }; };
|
||||
await readiness.ensure("psd");
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
test("readiness keys workspaces independently", async () => {
|
||||
const seen: string[] = [];
|
||||
const tht = {
|
||||
ollamaEnsure: async (workspace: string) => {
|
||||
seen.push(workspace);
|
||||
return { ok: true };
|
||||
},
|
||||
} as any;
|
||||
const readiness = new ReadinessManager(tht, 60);
|
||||
await Promise.all([readiness.ensure("a"), readiness.ensure("b")]);
|
||||
expect(seen.sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
test("readiness does not cache failed results", async () => {
|
||||
let calls = 0;
|
||||
const tht = {
|
||||
ollamaEnsure: async () => {
|
||||
calls += 1;
|
||||
return calls === 1 ? { ok: false, error: "down" } : { ok: true };
|
||||
},
|
||||
} as any;
|
||||
const readiness = new ReadinessManager(tht, 60);
|
||||
await expect(readiness.ensure("psd")).resolves.toMatchObject({ ok: false });
|
||||
await expect(readiness.ensure("psd")).resolves.toMatchObject({ ok: true });
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
@@ -28,6 +28,7 @@ test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+a
|
||||
}), {
|
||||
thtRunner: {
|
||||
ollamaEnsure: async () => ({ ok: true }),
|
||||
searchPack: async () => {},
|
||||
sessionNew: async (o: any) => { sessionNewArg = o; return { id: "s1" }; },
|
||||
sessionList: async () => [{ id: "s1" }],
|
||||
} as any,
|
||||
@@ -50,6 +51,7 @@ test("POST /sessions/:id/response inoltra al bridge (no error)", async () => {
|
||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||
thtRunner: {
|
||||
ollamaEnsure: async () => ({ ok: true }),
|
||||
searchPack: async () => {},
|
||||
sessionNew: async () => ({ id: "s1" }),
|
||||
sessionList: async () => [],
|
||||
} as any,
|
||||
@@ -134,6 +136,7 @@ test("POST /sessions refuses with 503 when ollamaEnsure fails (no session create
|
||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||
thtRunner: {
|
||||
ollamaEnsure: async () => ({ ok: false, stage: "model", error: "modello non installato" }),
|
||||
searchPack: async () => {},
|
||||
sessionNew: async () => { createdCalled = true; return { id: "s1" }; },
|
||||
} as any,
|
||||
getSettings: () => ({ workspace: "psd" }) as any,
|
||||
@@ -150,6 +153,7 @@ test("POST /sessions proceeds when ollamaEnsure succeeds", async () => {
|
||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||
thtRunner: {
|
||||
ollamaEnsure: async (ws: string) => { ensureWs = ws; return { ok: true }; },
|
||||
searchPack: async () => {},
|
||||
sessionNew: async () => ({ id: "s1" }),
|
||||
} as any,
|
||||
getSettings: () => ({ workspace: "psd" }) as any,
|
||||
@@ -183,3 +187,59 @@ test("POST /sessions/:id/resume refuses with 503 when ollamaEnsure fails", async
|
||||
const res = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
||||
expect(res.statusCode).toBe(503);
|
||||
});
|
||||
|
||||
test("POST /runtime/prewarm returns 202 without awaiting readiness", async () => {
|
||||
let workspace: string | undefined;
|
||||
let finish!: (value: any) => void;
|
||||
const pending = new Promise((resolve) => { finish = resolve; });
|
||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||
thtRunner: {} as any,
|
||||
readiness: {
|
||||
ensure: (ws: string) => { workspace = ws; return pending; },
|
||||
} as any,
|
||||
getSettings: () => ({ workspace: "psd" }) as any,
|
||||
});
|
||||
const res = await app.inject({ method: "POST", url: "/runtime/prewarm" });
|
||||
expect(res.statusCode).toBe(202);
|
||||
expect(res.json()).toEqual({ status: "warming" });
|
||||
expect(workspace).toBe("psd");
|
||||
finish({ ok: true });
|
||||
});
|
||||
|
||||
test("POST /sessions returns after bridge attachment but starts only after retrieval", async () => {
|
||||
let finishPack!: () => void;
|
||||
const pack = new Promise<void>((resolve) => { finishPack = resolve; });
|
||||
let bridgeAttached = false;
|
||||
let started = false;
|
||||
const bridge = {
|
||||
onClientEvent: () => { bridgeAttached = true; },
|
||||
emitClientEvent: () => {},
|
||||
};
|
||||
const runtime = { bridge } as any;
|
||||
const mgr = {
|
||||
createFor: () => runtime,
|
||||
configure: async () => {},
|
||||
start: () => {
|
||||
expect(bridgeAttached).toBe(true);
|
||||
started = true;
|
||||
},
|
||||
teardown: () => {},
|
||||
} as any;
|
||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||
mgr,
|
||||
thtRunner: {
|
||||
ollamaEnsure: async () => ({ ok: true }),
|
||||
sessionNew: async () => ({ id: "s-early" }),
|
||||
searchPack: async () => pack,
|
||||
} as any,
|
||||
getSettings: () => ({ workspace: "psd" }) as any,
|
||||
});
|
||||
|
||||
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
|
||||
expect(res.json()).toEqual({ id: "s-early" });
|
||||
expect(bridgeAttached).toBe(true);
|
||||
expect(started).toBe(false);
|
||||
finishPack();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(started).toBe(true);
|
||||
});
|
||||
|
||||
@@ -24,6 +24,17 @@ test("sessionNew parses id from JSON", async () => {
|
||||
expect(await r.sessionNew({ question: "q" })).toEqual({ id: "2026-06-27-100000-x" });
|
||||
});
|
||||
|
||||
test("searchPack persists retrieval context with session and workspace", async () => {
|
||||
const calls: any[] = [];
|
||||
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
|
||||
r.run = async (args, workspace) => {
|
||||
calls.push({ args, workspace });
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
};
|
||||
await r.searchPack("domanda", "sid", "psd");
|
||||
expect(calls).toEqual([{ args: ["search", "pack", "domanda", "--session", "sid"], workspace: "psd" }]);
|
||||
});
|
||||
|
||||
test("run passes configured THT_DATA_ROOT and preserves the remaining environment", async () => {
|
||||
const previousDataRoot = process.env.THT_DATA_ROOT;
|
||||
const previousCa = process.env.NODE_EXTRA_CA_CERTS;
|
||||
|
||||
Reference in New Issue
Block a user