Implement approved specification #32 and tickets #33-#37. Keep host authentication server-verified and pin session interaction language. Compile scoped base selectors for browser compatibility and retain full gutters during CSS pruning.
3268 lines
129 KiB
TypeScript
3268 lines
129 KiB
TypeScript
import { test, expect, vi } from "vitest";
|
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
import path from "node:path";
|
|
import os from "node:os";
|
|
import { chmodSync, readFileSync, unlinkSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { buildApp as buildRealApp } from "../src/app.js";
|
|
import { loadConfig } from "../src/config.js";
|
|
import { SseHub } from "../src/sse/sse-hub.js";
|
|
import { MaintenanceBarrier } from "../src/runtime/maintenance-gate.js";
|
|
import { PiProcessManager } from "../src/pi/pi-process-manager.js";
|
|
import { validateDeclarativePiConfig } from "../src/pi/managed-config.js";
|
|
import Fastify from "fastify";
|
|
import { sessionRoutes } from "../src/routes/sessions.js";
|
|
import { MemoryCatalogRepository } from "../src/catalog/memory-repository.js";
|
|
|
|
const FAKE = path.resolve("../harness/tests/fake_pi/fake_pi_rpc.mjs");
|
|
const SCRIPT = path.resolve("../harness/tests/fake_pi/scripts/f1_disambiguation.json");
|
|
|
|
function operationalWorkspace(id = "default") {
|
|
return {
|
|
workspace: { schema_version: 4, id, name: id, language: "en" },
|
|
dwh: {
|
|
engine: "postgres", database: "warehouse", schema: "public",
|
|
supported_transports: ["postgres_direct"],
|
|
},
|
|
} as const;
|
|
}
|
|
|
|
function sessionCatalog(defaultInteraction = "zai/glm-5.2", available = [defaultInteraction]) {
|
|
return {
|
|
defaultInteraction,
|
|
embedding: { id: "ollama/qwen3-embedding:0.6b", dimensions: 1024 },
|
|
sessionModels: () => [],
|
|
metadataModels: () => [],
|
|
hasSession: (id: string) => available.includes(id),
|
|
} as any;
|
|
}
|
|
|
|
const defaultWorkspaceRegistry = {
|
|
list: vi.fn(async () => [{
|
|
id: "default", commit: "e".repeat(40), blob: "f".repeat(40),
|
|
snapshotPath: `/data/workspace-registry/snapshots/${"e".repeat(40)}/default.yaml`,
|
|
}]),
|
|
read: vi.fn(async (id: string) => ({
|
|
workspace: operationalWorkspace(id),
|
|
revision: {
|
|
id, commit: "e".repeat(40), blob: "f".repeat(40),
|
|
snapshotPath: `/data/workspace-registry/snapshots/${"e".repeat(40)}/${id}.yaml`,
|
|
},
|
|
})),
|
|
};
|
|
|
|
function buildApp(config: Parameters<typeof buildRealApp>[0], deps: Record<string, unknown> = {}) {
|
|
const thtRunner = deps.thtRunner
|
|
? {
|
|
qdrantEnsure: async () => ({ ok: true }),
|
|
ensureInteractionLanguage: async () => ({ interaction_language: "en" }),
|
|
...(deps.thtRunner as object),
|
|
}
|
|
: undefined;
|
|
return buildRealApp(config, {
|
|
workspaceRuntimeSupport: () => true,
|
|
...deps,
|
|
...(thtRunner ? { thtRunner } : {}),
|
|
workspaceRegistry: { ...defaultWorkspaceRegistry, ...(deps.workspaceRegistry as object | undefined) },
|
|
} as any);
|
|
}
|
|
|
|
function mutApp(thtRunner: any) {
|
|
return buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: { ollamaEnsure: async () => ({ ok: true }), ...thtRunner },
|
|
getSettings: () => ({ workspace: "w" }) as any,
|
|
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
|
|
});
|
|
}
|
|
|
|
function deferred<T = void>() {
|
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((onResolve, onReject) => {
|
|
resolve = onResolve;
|
|
reject = onReject;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
const aliceHeaders = {
|
|
"x-thoth-principal-issuer": "portal",
|
|
"x-thoth-principal-subject": "alice",
|
|
"x-thoth-principal-display-name": "Alice",
|
|
"x-thoth-is-admin": "0",
|
|
};
|
|
|
|
test.each([undefined, null, "", "en--US", "en_US", "en\nIGNORE", "en<script>", 42])(
|
|
"new sessions reject invalid interaction language %s before persistence", async (interactionLanguage) => {
|
|
const app = mutApp({ sessionNew: async () => { throw new Error("must not create"); } });
|
|
try {
|
|
const response = await app.inject({
|
|
method: "POST", url: "/sessions", payload: { question: "Pazienti", interactionLanguage },
|
|
});
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json()).toMatchObject({ code: "invalid_interaction_language" });
|
|
} finally { await app.close(); }
|
|
},
|
|
);
|
|
|
|
test.each([["en", "en"], ["fr-FR", "fr-FR"], ["FR-fr", "fr-FR"]])(
|
|
"new session forwards canonical interaction language %s without changing the question", async (language, canonical) => {
|
|
let created: any;
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
sessionNew: async (options: any) => { created = options; return { id: "language" }; },
|
|
searchPack: async () => {},
|
|
},
|
|
readiness: { ensure: async () => ({ ok: true }) },
|
|
mgr: {
|
|
get: () => undefined, createFor: () => ({ bridge: { onClientEvent: () => {} } }),
|
|
configure: async () => {}, start: () => {},
|
|
},
|
|
getSettings: () => ({ workspace: "default" }),
|
|
});
|
|
try {
|
|
const response = await app.inject({
|
|
method: "POST", url: "/sessions", payload: { question: "Pazienti", interactionLanguage: language },
|
|
});
|
|
expect(response.statusCode).toBe(200);
|
|
expect(created).toMatchObject({ question: "Pazienti", interactionLanguage: canonical });
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
test("resume rejects browser interaction language overrides", async () => {
|
|
const app = mutApp({ sessionShow: async () => ({ status: "open", interaction_language: "it" }) });
|
|
try {
|
|
const response = await app.inject({
|
|
method: "POST", url: "/sessions/s1/resume", payload: { interactionLanguage: "en" },
|
|
});
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json()).toMatchObject({ code: "interaction_language_pinned" });
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
test("resume pins legacy interaction language using the resolved harness workspace", async () => {
|
|
let pinnedWorkspace: string | undefined;
|
|
let runtime: any;
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
sessionShow: async () => ({ id: "legacy", status: "closed" }),
|
|
ensureInteractionLanguage: async (_id: string, workspace: string) => {
|
|
pinnedWorkspace = workspace;
|
|
return { interaction_language: "it" };
|
|
},
|
|
reopenSession: async () => {},
|
|
},
|
|
readiness: { ensure: async () => ({ ok: true }) },
|
|
mgr: {
|
|
get: () => undefined,
|
|
createFor: (_id: string, options: any) => {
|
|
runtime = options;
|
|
return { bridge: { onClientEvent: () => {} } };
|
|
},
|
|
configure: async () => {}, start: () => {},
|
|
},
|
|
getSettings: () => ({ workspace: "other-browser-workspace" }),
|
|
});
|
|
try {
|
|
const response = await app.inject({ method: "POST", url: "/sessions/legacy/resume" });
|
|
expect(response.statusCode).toBe(200);
|
|
expect(pinnedWorkspace).toContain("/default.yaml");
|
|
expect(runtime).toMatchObject({ interactionLanguage: "it", mode: "resume" });
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
test("resume retains persisted interaction language despite different browser settings", async () => {
|
|
let options: any;
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
sessionShow: async () => ({ id: "saved", status: "closed", interaction_language: "it" }),
|
|
ensureInteractionLanguage: async () => { throw new Error("language is already pinned"); },
|
|
reopenSession: async () => {},
|
|
},
|
|
readiness: { ensure: async () => ({ ok: true }) },
|
|
mgr: {
|
|
get: () => undefined,
|
|
createFor: (_id: string, value: any) => {
|
|
options = value;
|
|
return { bridge: { onClientEvent: () => {} } };
|
|
},
|
|
configure: async () => {}, start: () => {},
|
|
},
|
|
getSettings: () => ({ workspace: "english-workspace", locale: "en" }),
|
|
});
|
|
try {
|
|
const response = await app.inject({ method: "POST", url: "/sessions/saved/resume" });
|
|
expect(response.statusCode).toBe(200);
|
|
expect(options.interactionLanguage).toBe("it");
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
test("resume cannot start Pi if legacy interaction language cannot be persisted", async () => {
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
sessionShow: async () => ({ id: "legacy", status: "closed" }),
|
|
ensureInteractionLanguage: async () => { throw new Error("private storage details"); },
|
|
reopenSession: async () => { throw new Error("must not reopen"); },
|
|
},
|
|
readiness: { ensure: async () => ({ ok: true }) },
|
|
mgr: { createFor: () => { throw new Error("must not spawn"); } },
|
|
getSettings: () => ({ workspace: "default" }),
|
|
});
|
|
try {
|
|
const response = await app.inject({ method: "POST", url: "/sessions/legacy/resume" });
|
|
expect(response.statusCode).toBe(503);
|
|
expect(response.body).not.toContain("private storage details");
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
test("upstream requests without a principal fail before a Pi runtime can be created", async () => {
|
|
let created = false;
|
|
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: { createFor: () => { created = true; throw new Error("must not spawn"); } } as any,
|
|
thtRunner: {} as any,
|
|
});
|
|
|
|
const response = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
|
|
|
|
expect(response.statusCode).toBe(401);
|
|
expect(created).toBe(false);
|
|
});
|
|
|
|
test("GET /sessions with a query still requires session.use", async () => {
|
|
const app = Fastify();
|
|
app.addHook("preHandler", async (request) => {
|
|
request.principal = { issuer: "oidc", subject: "no-role", roles: [], permissions: [], isAdmin: false };
|
|
});
|
|
sessionRoutes(app, {
|
|
mgr: { get: () => undefined } as any,
|
|
tht: { sessionList: async () => [] } as any,
|
|
hub: {} as any,
|
|
getSettings: async () => ({}),
|
|
readiness: {} as any,
|
|
listModels: async () => [],
|
|
workspaceRegistry: { list: async () => [] } as any,
|
|
workspaceRuntimeSupport: () => true,
|
|
maintenanceBarrier: new MaintenanceBarrier(),
|
|
});
|
|
|
|
const response = await app.inject({ method: "GET", url: "/sessions?scope=mine" });
|
|
|
|
expect(response.statusCode).toBe(403);
|
|
expect(response.json()).toEqual({ code: "auth_forbidden", error: "This operation is not permitted" });
|
|
});
|
|
|
|
test("maintenance rejects new and resumed session admission without interrupting running sessions", async () => {
|
|
const maintenanceBarrier = new MaintenanceBarrier();
|
|
await maintenanceBarrier.activate();
|
|
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
|
|
maintenanceBarrier,
|
|
thtRunner: { withPrincipal: () => ({ sessionShow: async () => ({ id: "open", status: "open" }) }) } as any,
|
|
});
|
|
|
|
const create = await app.inject({
|
|
method: "POST", url: "/sessions", headers: aliceHeaders, payload: { interactionLanguage: "en", question: "q" },
|
|
});
|
|
const resume = await app.inject({ method: "POST", url: "/sessions/open/resume", headers: aliceHeaders });
|
|
|
|
expect(create.statusCode).toBe(503);
|
|
expect(resume.statusCode).toBe(503);
|
|
expect(create.json()).toEqual({
|
|
code: "maintenance", error: "Session admission is temporarily paused for maintenance. Try again shortly.",
|
|
});
|
|
expect(resume.json()).toEqual(create.json());
|
|
});
|
|
|
|
test("a durable maintenance marker initializes admission closed after backend recreation", async () => {
|
|
const dir = mkdtempSync(path.join(tmpdir(), "tht-maintenance-"));
|
|
try {
|
|
const marker = path.join(dir, "maintenance.json");
|
|
writeFileSync(marker, '{"transaction":"test"}\n');
|
|
const app = buildApp(loadConfig({
|
|
AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness", THT_MAINTENANCE_FILE: marker,
|
|
}), { thtRunner: {} as any });
|
|
const response = await app.inject({
|
|
method: "POST", url: "/sessions", headers: aliceHeaders, payload: { interactionLanguage: "en", question: "q" },
|
|
});
|
|
expect(response.statusCode).toBe(503);
|
|
expect(response.json()).toMatchObject({ code: "maintenance" });
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("maintenance control requires an upstream identity and remains loopback-only", async () => {
|
|
const dir = mkdtempSync(path.join(tmpdir(), "tht-maintenance-control-"));
|
|
const marker = path.join(dir, "maintenance.json");
|
|
try {
|
|
const app = buildApp(loadConfig({
|
|
AUTH_MODE: "upstream",
|
|
THT_HARNESS_DIR: "../harness",
|
|
THT_MAINTENANCE_FILE: marker,
|
|
}), { thtRunner: {} as any });
|
|
|
|
expect((await app.inject({ method: "POST", url: "/internal/maintenance/activate" })).statusCode).toBe(401);
|
|
const activated = await app.inject({
|
|
method: "POST", url: "/internal/maintenance/activate", headers: aliceHeaders,
|
|
});
|
|
expect(activated.statusCode).toBe(200);
|
|
expect(activated.json()).toEqual({ active: true, admissions: 0 });
|
|
|
|
const spoofedProxy = await app.inject({
|
|
method: "POST",
|
|
url: "/internal/maintenance/deactivate",
|
|
remoteAddress: "172.30.0.9",
|
|
headers: {
|
|
"x-thoth-principal-subject": "thothctl-maintenance",
|
|
"x-thoth-is-admin": "1",
|
|
},
|
|
});
|
|
expect(spoofedProxy.statusCode).toBe(403);
|
|
|
|
const status = await app.inject({ method: "GET", url: "/internal/maintenance/status", headers: aliceHeaders });
|
|
expect(status.json()).toEqual({ active: true, admissions: 0 });
|
|
const deactivated = await app.inject({
|
|
method: "POST", url: "/internal/maintenance/deactivate", headers: aliceHeaders,
|
|
});
|
|
expect(deactivated.json()).toEqual({ active: false, admissions: 0 });
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("maintenance endpoints report marker-derived state after post-rename and post-remove fsync failures", async () => {
|
|
const dir = mkdtempSync(path.join(tmpdir(), "tht-maintenance-endpoint-fsync-"));
|
|
const marker = path.join(dir, "maintenance.json");
|
|
let failSync = true;
|
|
try {
|
|
const maintenanceBarrier = new MaintenanceBarrier(marker, {
|
|
syncDirectory() {
|
|
if (failSync) throw new Error("injected maintenance fsync failure");
|
|
},
|
|
});
|
|
const app = buildApp(loadConfig({
|
|
AUTH_MODE: "none",
|
|
THT_HARNESS_DIR: "../harness",
|
|
THT_MAINTENANCE_FILE: marker,
|
|
}), { thtRunner: {} as any, maintenanceBarrier });
|
|
|
|
const activated = await app.inject({ method: "POST", url: "/internal/maintenance/activate" });
|
|
expect(activated.statusCode).toBe(500);
|
|
expect(activated.json()).toMatchObject({
|
|
active: true,
|
|
admissions: 0,
|
|
recoveryRequired: true,
|
|
code: "maintenance_durability_failed",
|
|
});
|
|
|
|
failSync = false;
|
|
expect((await app.inject({ method: "GET", url: "/internal/maintenance/status" })).json())
|
|
.toEqual({ active: true, admissions: 0, recoveryRequired: true });
|
|
failSync = true;
|
|
const deactivated = await app.inject({ method: "POST", url: "/internal/maintenance/deactivate" });
|
|
expect(deactivated.statusCode).toBe(500);
|
|
expect(deactivated.json()).toMatchObject({
|
|
active: true,
|
|
admissions: 0,
|
|
recoveryRequired: true,
|
|
code: "maintenance_durability_failed",
|
|
});
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("admin all-sessions response matches the authenticated lifecycle wire fixture", async () => {
|
|
const fixture = JSON.parse(readFileSync(
|
|
path.join(import.meta.dirname, "fixtures", "sessions-scope-all.json"),
|
|
"utf8",
|
|
));
|
|
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
withPrincipal: () => ({ sessionList: async () => fixture.map(({ active: _active, ...row }: any) => row) }),
|
|
} as any,
|
|
mgr: { get: () => undefined } as any,
|
|
workspaceRegistry: defaultWorkspaceRegistry as any,
|
|
});
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: "/sessions?scope=all",
|
|
headers: { ...aliceHeaders, "x-thoth-is-admin": "1" },
|
|
});
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual(fixture);
|
|
});
|
|
|
|
test("session routes conceal foreign or missing sessions and deny SSE before it subscribes", async () => {
|
|
let subscribed = false;
|
|
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
withPrincipal: () => ({ sessionShow: async () => null }),
|
|
} as any,
|
|
hub: { subscribe: () => { subscribed = true; return () => {}; } } as any,
|
|
});
|
|
|
|
const document = await app.inject({ method: "GET", url: "/sessions/foreign/documents", headers: aliceHeaders });
|
|
const events = await app.inject({ method: "GET", url: "/sessions/foreign/events", headers: aliceHeaders });
|
|
|
|
expect(document.statusCode).toBe(404);
|
|
expect(events.statusCode).toBe(404);
|
|
expect(subscribed).toBe(false);
|
|
});
|
|
|
|
test("session listing permits all scope only to admins", async () => {
|
|
const seen: boolean[] = [];
|
|
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
withPrincipal: (principal: any) => ({
|
|
sessionList: async () => { seen.push(principal.isAdmin); return [{ id: "s1" }]; },
|
|
}),
|
|
} as any,
|
|
});
|
|
const regularAll = await app.inject({ method: "GET", url: "/sessions?scope=all", headers: aliceHeaders });
|
|
const mine = await app.inject({ method: "GET", url: "/sessions?scope=mine", headers: aliceHeaders });
|
|
const adminAll = await app.inject({
|
|
method: "GET", url: "/sessions?scope=all",
|
|
headers: { ...aliceHeaders, "x-thoth-is-admin": "1" },
|
|
});
|
|
|
|
expect(regularAll.statusCode).toBe(403);
|
|
expect(regularAll.json()).toEqual({ code: "auth_forbidden", error: "This operation is not permitted" });
|
|
expect(mine.statusCode).toBe(200);
|
|
expect(adminAll.statusCode).toBe(200);
|
|
expect(seen).toEqual([false, true]);
|
|
});
|
|
|
|
test("an administrator session listing retains revisions referenced by resumable manifests", async () => {
|
|
const retained = vi.fn(async () => {});
|
|
const retainedRevision = "a".repeat(40);
|
|
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
withPrincipal: () => ({ sessionList: async () => [
|
|
{ id: "open", status: "open", archived: false, workspace_revision: retainedRevision },
|
|
{ id: "finalized", status: "finalized", archived: false, workspace_revision: "b".repeat(40) },
|
|
{ id: "archived", status: "closed", archived: true, workspace_revision: "c".repeat(40) },
|
|
] }),
|
|
} as any,
|
|
workspaceRegistry: { reconcileSnapshotRetention: retained } as any,
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "GET", url: "/sessions?scope=all",
|
|
headers: { ...aliceHeaders, "x-thoth-is-admin": "1" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(retained).toHaveBeenCalledWith([retainedRevision]);
|
|
});
|
|
|
|
test("retention scans a removed workspace's retained snapshot", async () => {
|
|
const retained = vi.fn(async () => {});
|
|
const removedRevision = "e".repeat(40);
|
|
const activeSnapshot = "/registry/snapshots/a/other.yaml";
|
|
const removedSnapshot = "/registry/snapshots/e/removed.yaml";
|
|
const listRetainedSnapshots = vi.fn(async () => [
|
|
{ id: "other", commit: "a".repeat(40), snapshotPath: activeSnapshot },
|
|
{ id: "removed", commit: removedRevision, snapshotPath: removedSnapshot },
|
|
]);
|
|
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
withPrincipal: () => ({
|
|
sessionList: async (snapshotPath: string) => snapshotPath === removedSnapshot
|
|
? [{ id: "resumable", status: "closed", archived: false, workspace_revision: removedRevision }]
|
|
: [],
|
|
}),
|
|
} as any,
|
|
workspaceRegistry: {
|
|
list: async () => [{ id: "other", commit: "a".repeat(40), snapshotPath: activeSnapshot }],
|
|
listRetainedSnapshots,
|
|
reconcileSnapshotRetention: retained,
|
|
} as any,
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "GET", url: "/sessions?scope=all",
|
|
headers: { ...aliceHeaders, "x-thoth-is-admin": "1" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(listRetainedSnapshots).toHaveBeenCalledOnce();
|
|
expect(retained).toHaveBeenCalledWith([removedRevision]);
|
|
expect(response.json()).toEqual([expect.objectContaining({ id: "resumable" })]);
|
|
});
|
|
|
|
test("active sessions remain available when retained snapshot discovery is unreadable", async () => {
|
|
const activeSnapshot = "/registry/snapshots/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/active.yaml";
|
|
const retainedFailure = "invalid retained snapshot /registry/snapshots/secret/legacy.yaml";
|
|
const reconcileSnapshotRetention = vi.fn(async () => {});
|
|
const subscribed = vi.fn();
|
|
const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const manifest = {
|
|
id: "live-session", status: "open", archived: false,
|
|
workspace_id: "active", workspace_revision: "a".repeat(40),
|
|
};
|
|
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
withPrincipal: () => ({
|
|
sessionList: async (snapshotPath: string) => {
|
|
expect(snapshotPath).toBe(activeSnapshot);
|
|
return [manifest];
|
|
},
|
|
sessionShow: async (id: string, snapshotPath?: string) => {
|
|
if (id === manifest.id && snapshotPath === activeSnapshot) return manifest;
|
|
throw new Error("session not found");
|
|
},
|
|
}),
|
|
} as any,
|
|
mgr: { get: () => undefined } as any,
|
|
hub: {
|
|
subscribe: (_id: string, _send: unknown, options: { close?: () => void }) => {
|
|
subscribed();
|
|
options.close?.();
|
|
return () => {};
|
|
},
|
|
} as any,
|
|
workspaceRegistry: {
|
|
listRetainedSnapshots: async () => { throw new Error(retainedFailure); },
|
|
list: async () => [{
|
|
id: "active", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: activeSnapshot,
|
|
}],
|
|
reconcileSnapshotRetention,
|
|
} as any,
|
|
});
|
|
|
|
try {
|
|
const list = await app.inject({ method: "GET", url: "/sessions", headers: aliceHeaders });
|
|
const detail = await app.inject({ method: "GET", url: `/sessions/${manifest.id}`, headers: aliceHeaders });
|
|
const events = await app.inject({ method: "GET", url: `/sessions/${manifest.id}/events`, headers: aliceHeaders });
|
|
|
|
expect(list.statusCode).toBe(200);
|
|
expect(list.json()).toEqual([expect.objectContaining({ id: manifest.id, active: false })]);
|
|
expect(detail.statusCode).toBe(200);
|
|
expect(detail.json()).toMatchObject(manifest);
|
|
expect(events.statusCode).toBe(200);
|
|
expect(subscribed).toHaveBeenCalledOnce();
|
|
expect(reconcileSnapshotRetention).not.toHaveBeenCalled();
|
|
expect(list.body + detail.body + events.body).not.toContain(retainedFailure);
|
|
} finally {
|
|
warning.mockRestore();
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test("the single local installation listing reconciles its resumable workspace pins", async () => {
|
|
const retained = vi.fn(async () => {});
|
|
const retainedRevision = "d".repeat(40);
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
sessionList: async () => [{ id: "open", status: "closed", archived: false, workspace_revision: retainedRevision }],
|
|
} as any,
|
|
workspaceRegistry: { reconcileSnapshotRetention: retained } as any,
|
|
});
|
|
|
|
const response = await app.inject({ method: "GET", url: "/sessions" });
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(retained).toHaveBeenCalledWith([retainedRevision]);
|
|
});
|
|
|
|
test("A, B, and admin requests preserve owner isolation through session route mutations", async () => {
|
|
const owners = new Map([["a", "alice"], ["b", "bob"]]);
|
|
const closed: Array<{ id: string; subject: string }> = [];
|
|
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
withPrincipal: (principal: any) => ({
|
|
sessionList: async () => [...owners]
|
|
.filter(([, owner]) => principal.isAdmin || owner === principal.subject)
|
|
.map(([id, owner]) => ({ id, author: owner })),
|
|
sessionShow: async (id: string) =>
|
|
(principal.isAdmin || owners.get(id) === principal.subject) ? { id, status: "open" } : null,
|
|
closeSession: async (id: string) => { closed.push({ id, subject: principal.subject }); },
|
|
}),
|
|
} as any,
|
|
mgr: { get: () => undefined } as any,
|
|
hub: { clear: () => {} } as any,
|
|
getSettings: () => ({ workspace: "w" }) as any,
|
|
});
|
|
const bobHeaders = { ...aliceHeaders, "x-thoth-principal-subject": "bob" };
|
|
const adminHeaders = { ...aliceHeaders, "x-thoth-principal-subject": "admin", "x-thoth-is-admin": "1" };
|
|
|
|
expect((await app.inject({ method: "GET", url: "/sessions", headers: aliceHeaders })).json())
|
|
.toEqual([{ id: "a", author: "alice", active: false }]);
|
|
expect((await app.inject({ method: "GET", url: "/sessions", headers: bobHeaders })).json())
|
|
.toEqual([{ id: "b", author: "bob", active: false }]);
|
|
expect((await app.inject({ method: "GET", url: "/sessions?scope=all", headers: adminHeaders })).json())
|
|
.toEqual([{ id: "a", author: "alice", active: false }, { id: "b", author: "bob", active: false }]);
|
|
|
|
expect((await app.inject({ method: "POST", url: "/sessions/a/close", headers: bobHeaders })).statusCode)
|
|
.toBe(404);
|
|
expect((await app.inject({ method: "POST", url: "/sessions/a/close", headers: adminHeaders })).statusCode)
|
|
.toBe(200);
|
|
expect(closed).toEqual([{ id: "a", subject: "admin" }]);
|
|
});
|
|
|
|
test("new sessions are created through the authenticated principal, not a client owner field", async () => {
|
|
let principal: any;
|
|
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
withPrincipal: (p: any) => {
|
|
principal = p;
|
|
return { sessionNew: async () => ({ id: "owned" }), searchPack: async () => {} };
|
|
},
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
mgr: {
|
|
createFor: () => ({ bridge: { onClientEvent: () => {} } }),
|
|
configure: async () => {}, start: () => {}, get: () => undefined,
|
|
} as any,
|
|
getSettings: () => ({ workspace: "w" }) as any,
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST", url: "/sessions", headers: aliceHeaders,
|
|
payload: { interactionLanguage: "en", question: "q", owner: "mallory" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(principal).toMatchObject({ issuer: "portal", subject: "alice" });
|
|
});
|
|
|
|
test("new sessions reject the client legacy workspace field unless local legacy mode is explicit", async () => {
|
|
const sessionNew = vi.fn(async () => ({ id: "legacy" }));
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: { sessionNew, searchPack: async () => {} } as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
mgr: { get: () => undefined, createFor: () => ({ bridge: { onClientEvent: () => {} } }), configure: async () => {}, start: () => {} } as any,
|
|
getSettings: () => ({}) as any,
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q", workspace: "legacy" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(409);
|
|
expect(response.json()).toMatchObject({ code: "workspace_revision_unavailable" });
|
|
expect(sessionNew).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("explicit local legacy mode permits the unpinned client workspace request", async () => {
|
|
const sessionNew = vi.fn(async () => ({ id: "legacy" }));
|
|
const app = buildApp(loadConfig({
|
|
THT_HARNESS_DIR: "../harness", THT_LEGACY_WORKSPACE_MODE: "local",
|
|
}), {
|
|
thtRunner: { sessionNew, searchPack: async () => {} } as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
mgr: { get: () => undefined, createFor: () => ({ bridge: { onClientEvent: () => {} } }), configure: async () => {}, start: () => {} } as any,
|
|
getSettings: () => ({}) as any,
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q", workspace: "legacy" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(sessionNew).toHaveBeenCalledWith(expect.objectContaining({
|
|
workspaceConfigPath: undefined, workspaceId: undefined, workspaceRevision: undefined,
|
|
}));
|
|
});
|
|
|
|
test("creates a session from the active immutable workspace revision", async () => {
|
|
const sessionNew = vi.fn(async () => ({ id: "pinned" }));
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: { sessionNew, searchPack: async () => {} } as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
mgr: {
|
|
get: () => undefined,
|
|
createFor: () => ({ bridge: { onClientEvent: () => {} } }),
|
|
configure: async () => {},
|
|
start: () => {},
|
|
} as any,
|
|
getSettings: () => ({ provider: "zai", model: "glm-5.2", thinking: "low" }) as any,
|
|
listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }],
|
|
workspaceRegistry: {
|
|
read: vi.fn(async () => ({
|
|
workspace: {
|
|
workspace: { schema_version: 4, id: "psd-clinical", name: "PSD", language: "it" },
|
|
dwh: {},
|
|
},
|
|
revision: {
|
|
id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40),
|
|
snapshotPath: "/data/workspace-registry/snapshots/abc/psd-clinical.yaml",
|
|
},
|
|
})),
|
|
} as any,
|
|
});
|
|
|
|
await app.inject({
|
|
method: "POST", url: "/sessions",
|
|
payload: { interactionLanguage: "en", question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low" },
|
|
});
|
|
|
|
expect(sessionNew).toHaveBeenCalledWith(expect.objectContaining({
|
|
workspaceConfigPath: "/data/workspace-registry/snapshots/abc/psd-clinical.yaml",
|
|
workspaceId: "psd-clinical", workspaceRevision: "a".repeat(40),
|
|
}));
|
|
});
|
|
|
|
test("hands one Catalog-backed runtime to both retrieval and Pi", async () => {
|
|
const effective = JSON.stringify({
|
|
schemaVersion: 1,
|
|
workspaceId: "default",
|
|
relationships: [],
|
|
});
|
|
const render = vi.fn(async () => effective);
|
|
const acquireWorkspaceRuntime = vi.fn((_workspace: string, relationships?: string) => ({
|
|
path: "/runtime/with-relationships.yaml",
|
|
workspaceId: "default",
|
|
workspaceRevision: "e".repeat(40),
|
|
release: vi.fn(),
|
|
}));
|
|
const searchPack = vi.fn(async () => {});
|
|
const createFor = vi.fn(() => ({ bridge: { onClientEvent: () => {} } }));
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
sessionNew: async () => ({ id: "effective-map" }),
|
|
acquireWorkspaceRuntime,
|
|
searchPack,
|
|
} as any,
|
|
effectiveRelationshipSnapshotProvider: { render } as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
mgr: {
|
|
get: () => undefined,
|
|
createFor,
|
|
configure: async () => {},
|
|
start: () => {},
|
|
} as any,
|
|
getSettings: () => ({ workspace: "default", thinking: "low" }) as any,
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/sessions",
|
|
payload: { interactionLanguage: "en", question: "Which users placed orders?", workspaceId: "default" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(render).not.toHaveBeenCalled();
|
|
expect(acquireWorkspaceRuntime).toHaveBeenCalledWith(
|
|
expect.stringContaining("/default.yaml"),
|
|
);
|
|
expect(createFor).toHaveBeenCalledWith(
|
|
"effective-map",
|
|
expect.objectContaining({
|
|
runtimeConfig: expect.objectContaining({ path: "/runtime/with-relationships.yaml" }),
|
|
}),
|
|
);
|
|
expect(searchPack).toHaveBeenCalledWith(
|
|
"Which users placed orders?",
|
|
"effective-map",
|
|
"/runtime/with-relationships.yaml",
|
|
);
|
|
});
|
|
|
|
test("refuses core admission when the workspace preprocessing fingerprint is stale", async () => {
|
|
const sessionNew = vi.fn();
|
|
const workspaceInputFingerprint = vi.fn(async () => "sha256:current");
|
|
const revision = {
|
|
id: "default",
|
|
commit: "a".repeat(40),
|
|
blob: "b".repeat(40),
|
|
snapshotPath: `/data/workspace-registry/snapshots/${"a".repeat(40)}/default.yaml`,
|
|
};
|
|
const catalogRepository = new MemoryCatalogRepository();
|
|
const database = await catalogRepository.create({
|
|
workspaceId: "default",
|
|
engine: "postgres",
|
|
databaseName: "warehouse",
|
|
schema: "public",
|
|
binding: {
|
|
transport: "postgres_direct",
|
|
host: "db.internal",
|
|
port: 5432,
|
|
username: "reader",
|
|
},
|
|
});
|
|
await catalogRepository.applySchemaSync(database.id, database.version, "all", [], {
|
|
schemaVersion: 1,
|
|
capabilities: { tables: "available", columns: "available", relationships: "available" },
|
|
tables: [],
|
|
columns: [],
|
|
relationships: [],
|
|
});
|
|
const started = await catalogRepository.beginPreprocessing("default", "sha256:previous");
|
|
expect(started.kind).toBe("started");
|
|
await catalogRepository.finishPreprocessing(
|
|
"default",
|
|
0,
|
|
"sha256:previous",
|
|
{ status: "succeeded" },
|
|
);
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: { sessionNew, workspaceInputFingerprint } as any,
|
|
catalogRepository,
|
|
workspaceRegistry: {
|
|
acquireSessionRevision: async () => ({
|
|
workspace: operationalWorkspace("default"),
|
|
revision,
|
|
abort: async () => {},
|
|
markPersisted: async () => {},
|
|
}),
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({ workspace: "default", thinking: "low" }) as any,
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/sessions",
|
|
payload: { interactionLanguage: "en", question: "q", workspaceId: "default" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(409);
|
|
expect(response.json()).toMatchObject({ code: "preprocessing_required" });
|
|
expect(workspaceInputFingerprint).toHaveBeenCalledWith(revision.snapshotPath);
|
|
expect(sessionNew).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("rejects an SSH-only Catalog binding before persisting or starting a session", async () => {
|
|
const sessionNew = vi.fn(async () => ({ id: "must-not-exist" }));
|
|
const workspaceInputFingerprint = vi.fn(async () => "sha256:unused");
|
|
const ensure = vi.fn(async () => ({ ok: true }));
|
|
const createFor = vi.fn();
|
|
const abort = vi.fn(async () => {});
|
|
const markPersisted = vi.fn(async () => {});
|
|
const catalogRepository = new MemoryCatalogRepository();
|
|
await catalogRepository.create({
|
|
workspaceId: "ssh-workspace",
|
|
engine: "postgres",
|
|
databaseName: "postgres",
|
|
schema: "public",
|
|
binding: {
|
|
transport: "ssh_tunnel",
|
|
username: "reader",
|
|
sshHost: "bastion.internal",
|
|
sshPort: 22,
|
|
sshUsername: "tunnel",
|
|
sshTargetHost: "postgres.internal",
|
|
sshTargetPort: 5432,
|
|
},
|
|
});
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: { sessionNew, searchPack: async () => {}, workspaceInputFingerprint } as any,
|
|
catalogRepository,
|
|
readiness: { ensure } as any,
|
|
mgr: { get: () => undefined, createFor } as any,
|
|
getSettings: () => ({ workspace: "ssh-workspace" }) as any,
|
|
workspaceRuntimeSupport: vi.fn(() => true),
|
|
workspaceRegistry: {
|
|
acquireSessionRevision: vi.fn(async () => ({
|
|
workspace: operationalWorkspace("ssh-workspace"),
|
|
revision: {
|
|
id: "ssh-workspace", commit: "a".repeat(40), blob: "b".repeat(40),
|
|
snapshotPath: `/data/workspace-registry/snapshots/${"a".repeat(40)}/ssh-workspace.yaml`,
|
|
},
|
|
abort,
|
|
markPersisted,
|
|
})),
|
|
} as any,
|
|
});
|
|
|
|
const response = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
|
|
|
|
expect(response.statusCode).toBe(409);
|
|
expect(response.json()).toMatchObject({ code: "workspace_not_activatable" });
|
|
expect(ensure).not.toHaveBeenCalled();
|
|
expect(workspaceInputFingerprint).not.toHaveBeenCalled();
|
|
expect(sessionNew).not.toHaveBeenCalled();
|
|
expect(createFor).not.toHaveBeenCalled();
|
|
expect(abort).toHaveBeenCalledOnce();
|
|
expect(markPersisted).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("hands a revision lease to retention only after the session manifest is durable", async () => {
|
|
const persisted = deferred<{ id: string }>();
|
|
const markPersisted = vi.fn(async () => {});
|
|
const abort = vi.fn(async () => {});
|
|
const acquireSessionRevision = vi.fn(async () => ({
|
|
workspace: operationalWorkspace("leased"),
|
|
revision: {
|
|
id: "leased", commit: "a".repeat(40), blob: "b".repeat(40),
|
|
snapshotPath: `/data/workspace-registry/snapshots/${"a".repeat(40)}/leased.yaml`,
|
|
},
|
|
markPersisted,
|
|
abort,
|
|
}));
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: { sessionNew: () => persisted.promise, searchPack: async () => {} } as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
mgr: {
|
|
get: () => undefined,
|
|
createFor: () => ({ bridge: { onClientEvent: () => {} } }),
|
|
configure: async () => {},
|
|
start: () => {},
|
|
} as any,
|
|
getSettings: () => ({ workspace: "leased", provider: "zai", model: "glm-5.2" }) as any,
|
|
listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM", reasoning: true }],
|
|
workspaceRuntimeSupport: () => true,
|
|
workspaceRegistry: { acquireSessionRevision } as any,
|
|
});
|
|
|
|
const request = app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
expect(markPersisted).not.toHaveBeenCalled();
|
|
expect(abort).not.toHaveBeenCalled();
|
|
|
|
persisted.resolve({ id: "leased-session" });
|
|
expect((await request).statusCode).toBe(200);
|
|
expect(markPersisted).toHaveBeenCalledOnce();
|
|
expect(abort).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("creates a session from the configured default workspace revision when workspaceId is omitted", async () => {
|
|
const sessionNew = vi.fn(async () => ({ id: "default-pinned" }));
|
|
const registry = {
|
|
read: vi.fn(async (id: string) => ({
|
|
workspace: operationalWorkspace(id),
|
|
revision: {
|
|
id, commit: "c".repeat(40), blob: "d".repeat(40),
|
|
snapshotPath: `/data/workspace-registry/snapshots/${"c".repeat(40)}/${id}.yaml`,
|
|
},
|
|
})),
|
|
};
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: { sessionNew, searchPack: async () => {} } as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
mgr: { get: () => undefined, createFor: () => ({ bridge: { onClientEvent: () => {} } }), configure: async () => {}, start: () => {} } as any,
|
|
getSettings: () => ({ workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low" }) as any,
|
|
listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }],
|
|
workspaceRegistry: registry as any,
|
|
});
|
|
|
|
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
|
|
|
|
expect(registry.read).toHaveBeenCalledWith("psd-clinical");
|
|
expect(sessionNew).toHaveBeenCalledWith(expect.objectContaining({
|
|
workspaceId: "psd-clinical", workspaceRevision: "c".repeat(40),
|
|
workspaceConfigPath: `/data/workspace-registry/snapshots/${"c".repeat(40)}/psd-clinical.yaml`,
|
|
}));
|
|
});
|
|
|
|
test("session lifecycle locates a B session when installation default is A", async () => {
|
|
const aPath = "/registry/snapshots/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/a-workspace.yaml";
|
|
const bPath = "/registry/snapshots/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb/b-workspace.yaml";
|
|
const bPinnedPath = "/registry/snapshots/cccccccccccccccccccccccccccccccccccccccc/b-workspace.yaml";
|
|
const bManifest = {
|
|
id: "session-b", status: "open", archived: false,
|
|
workspace_id: "b-workspace", workspace_revision: "c".repeat(40),
|
|
provider: "zai", model: "glm-5.2", thinking: "low",
|
|
};
|
|
const calls: string[] = [];
|
|
const runtimeSources: string[] = [];
|
|
const runtimeOptions: string[] = [];
|
|
let active: any;
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
sessionNew: async (input: any) => {
|
|
calls.push(`new:${input.workspaceConfigPath}`);
|
|
return { id: "session-b" };
|
|
},
|
|
searchPack: async () => {},
|
|
sessionList: async (workspace: string) => {
|
|
calls.push(`list:${workspace}`);
|
|
return workspace === bPath ? [{ id: "session-b", status: "open", question: "B question" }] : [];
|
|
},
|
|
sessionShow: async (id: string, workspace: string) => {
|
|
calls.push(`show:${workspace}`);
|
|
if (id === "session-b" && workspace === bPath) return bManifest;
|
|
throw new Error("session not found");
|
|
},
|
|
reopenSession: async (id: string, workspace: string) => {
|
|
calls.push(`reopen:${workspace}`);
|
|
expect(id).toBe("session-b");
|
|
},
|
|
acquireWorkspaceRuntime: (workspace: string) => {
|
|
runtimeSources.push(workspace);
|
|
return {
|
|
path: `/runtime/${runtimeSources.length}.yaml`,
|
|
workspaceId: "b-workspace",
|
|
workspaceRevision: "c".repeat(40),
|
|
release: vi.fn(),
|
|
};
|
|
},
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
mgr: {
|
|
get: () => active,
|
|
createFor: (_id: string, options: any) => {
|
|
runtimeOptions.push(options.runtimeConfig?.path ?? "missing");
|
|
active = { bridge: { onClientEvent: () => {}, respond: () => true, turnState: () => "idle" } };
|
|
return active;
|
|
},
|
|
configure: async () => {}, start: () => {},
|
|
teardownForPrincipal: () => [],
|
|
teardownIfCurrent: (_id: string, expected: any) => {
|
|
if (active !== expected) return false;
|
|
active = undefined;
|
|
return true;
|
|
},
|
|
} as any,
|
|
getSettings: () => ({ workspace: "a-workspace", provider: "zai", model: "glm-5.2", thinking: "low" }) as any,
|
|
listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }],
|
|
workspaceRegistry: {
|
|
read: async (id: string) => ({
|
|
workspace: operationalWorkspace(id),
|
|
revision: { id, commit: "b".repeat(40), blob: "d".repeat(40), snapshotPath: bPath },
|
|
}),
|
|
list: async () => [
|
|
{ id: "a-workspace", commit: "a".repeat(40), blob: "a".repeat(40), snapshotPath: aPath },
|
|
{ id: "b-workspace", commit: "b".repeat(40), blob: "b".repeat(40), snapshotPath: bPath },
|
|
],
|
|
readPinned: vi.fn(async (id: string, revision: string) => {
|
|
expect([id, revision]).toEqual(["b-workspace", "c".repeat(40)]);
|
|
return { workspace: operationalWorkspace(id), workspaceConfigPath: bPinnedPath };
|
|
}),
|
|
} as any,
|
|
});
|
|
|
|
expect((await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en",
|
|
question: "B question", workspaceId: "b-workspace", provider: "zai", model: "glm-5.2", thinking: "low",
|
|
} })).statusCode).toBe(200);
|
|
expect((await app.inject({ method: "GET", url: "/sessions" })).json()).toEqual([
|
|
expect.objectContaining({ id: "session-b", active: true }),
|
|
]);
|
|
expect((await app.inject({ method: "GET", url: "/sessions/session-b" })).json()).toMatchObject(bManifest);
|
|
expect((await app.inject({ method: "POST", url: "/sessions/session-b/response", payload: { ui_response: {} } })).statusCode)
|
|
.toBe(204);
|
|
|
|
active = undefined;
|
|
expect((await app.inject({ method: "POST", url: "/sessions/session-b/resume" })).json())
|
|
.toEqual({ id: "session-b", alreadyActive: false, workspaceId: "b-workspace" });
|
|
expect(calls).toContain(`new:${bPath}`);
|
|
expect(calls).toContain(`list:${bPath}`);
|
|
expect(calls).toContain(`show:${bPath}`);
|
|
expect(calls).toContain(`reopen:${bPinnedPath}`);
|
|
expect(runtimeSources).toEqual([bPath, bPinnedPath]);
|
|
expect(runtimeOptions).toEqual(["/runtime/1.yaml", "/runtime/2.yaml"]);
|
|
});
|
|
|
|
test("POST /sessions uses the catalog default with workspace/thinking settings and starts", async () => {
|
|
const modelKey = path.join(os.tmpdir(), `thoth-model-key-${process.pid}`);
|
|
writeFileSync(modelKey, "test-model-key", { mode: 0o600 });
|
|
chmodSync(modelKey, 0o600);
|
|
let sessionNewArg: any;
|
|
let spawnArg: any;
|
|
const app = buildApp(loadConfig({
|
|
THT_HARNESS_DIR: "../harness", THT_MODEL_API_KEY_FILE: modelKey,
|
|
}), {
|
|
thtRunner: {
|
|
ollamaEnsure: async () => ({ ok: true }),
|
|
searchPack: async () => {},
|
|
sessionNew: async (o: any) => { sessionNewArg = o; return { id: "s1" }; },
|
|
sessionList: async () => [{ id: "s1" }],
|
|
} as any,
|
|
getSettings: () => ({ workspace: "w", thinking: "high" }),
|
|
runtimeModelCatalog: sessionCatalog(),
|
|
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: { interactionLanguage: "en", question: "q" } });
|
|
expect(created.json()).toEqual({ id: "s1" });
|
|
expect(sessionNewArg.workspaceConfigPath).toContain(`/snapshots/${"e".repeat(40)}/w.yaml`);
|
|
expect(sessionNewArg.provider).toBe("zai");
|
|
expect(sessionNewArg.model).toBe("glm-5.2");
|
|
expect(sessionNewArg.thinking).toBe("high");
|
|
expect(sessionNewArg.question).toBe("q");
|
|
const list = await app.inject({ method: "GET", url: "/sessions" });
|
|
expect(list.json()).toEqual([{ id: "s1", active: expect.any(Boolean) }]);
|
|
unlinkSync(modelKey);
|
|
});
|
|
|
|
test("POST /sessions stops the user's previous open Pi runtime before creating another", async () => {
|
|
const runtimes = new Map<string, any>();
|
|
const tornDown: string[] = [];
|
|
const order: string[] = [];
|
|
let nextId = 0;
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: {
|
|
get: (id: string) => runtimes.get(id),
|
|
teardownForPrincipal: () => {
|
|
const stopped: string[] = [];
|
|
for (const [id, runtime] of [...runtimes]) {
|
|
runtimes.delete(id);
|
|
stopped.push(id);
|
|
tornDown.push(id);
|
|
order.push(`teardown:${id}`);
|
|
runtime.bridge.emitClientEvent?.({ type: "system_event", event: "agent_end" });
|
|
}
|
|
return stopped;
|
|
},
|
|
createFor: (id: string) => {
|
|
order.push(`create:${id}`);
|
|
if (runtimes.size >= 1) throw new Error("max Pi processes reached");
|
|
const runtime = { bridge: { onClientEvent: () => {} } };
|
|
runtimes.set(id, runtime);
|
|
return runtime;
|
|
},
|
|
configure: async () => {},
|
|
start: () => {},
|
|
teardownIfCurrent: (id: string, expected: any) => {
|
|
if (runtimes.get(id) !== expected) return false;
|
|
runtimes.delete(id);
|
|
tornDown.push(id);
|
|
order.push(`teardown:${id}`);
|
|
return true;
|
|
},
|
|
} as any,
|
|
thtRunner: {
|
|
sessionNew: async () => {
|
|
const id = `s${++nextId}`;
|
|
order.push(`new:${id}`);
|
|
return { id };
|
|
},
|
|
searchPack: async () => {},
|
|
failSession: async () => {},
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({ workspace: "local" }) as any,
|
|
});
|
|
|
|
expect((await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "one" } })).statusCode)
|
|
.toBe(200);
|
|
order.length = 0;
|
|
|
|
const second = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "two" } });
|
|
|
|
expect(second.statusCode).toBe(200);
|
|
expect(second.json()).toEqual({ id: "s2" });
|
|
expect(tornDown).toEqual(["s1"]);
|
|
expect(order).toEqual(["teardown:s1", "new:s2", "create:s2"]);
|
|
expect(runtimes.has("s2")).toBe(true);
|
|
});
|
|
|
|
test("POST /sessions refuses to create a session when the local DWH precheck fails", async () => {
|
|
let sessionNewCalls = 0;
|
|
let pinged = 0;
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness", THT_DWH_PRECHECK: "1" }), {
|
|
thtRunner: {
|
|
dbPing: async () => { pinged += 1; return { ok: false, detail: "DWH non accessibile" }; },
|
|
sessionNew: async () => { sessionNewCalls += 1; return { id: "s1" }; },
|
|
searchPack: async () => {},
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({ workspace: "w" }) as any,
|
|
});
|
|
const res = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
|
|
expect(res.statusCode).toBe(503);
|
|
expect(res.json()).toMatchObject({ code: "dwh_unreachable" });
|
|
expect(pinged).toBe(1);
|
|
expect(sessionNewCalls).toBe(0); // session must not be created
|
|
});
|
|
|
|
test("POST /sessions proceeds past a passing DWH precheck", async () => {
|
|
let pinged = 0;
|
|
let created = 0;
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness", THT_DWH_PRECHECK: "1" }), {
|
|
thtRunner: {
|
|
dbPing: async () => { pinged += 1; return { ok: true, detail: "OK" }; },
|
|
sessionNew: async () => { created += 1; return { id: "s1" }; },
|
|
searchPack: async () => {},
|
|
sessionList: async () => [{ id: "s1" }],
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({ workspace: "w" }) as any,
|
|
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
|
|
});
|
|
const res = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(pinged).toBe(1);
|
|
expect(created).toBe(1);
|
|
});
|
|
|
|
test("POST /sessions skips the DWH precheck when the flag is off (default)", async () => {
|
|
let pinged = 0;
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
dbPing: async () => { pinged += 1; return { ok: false, detail: "should never run" }; },
|
|
sessionNew: async () => ({ id: "s1" }),
|
|
searchPack: async () => {},
|
|
sessionList: async () => [{ id: "s1" }],
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({ workspace: "w" }) as any,
|
|
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
|
|
});
|
|
const res = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(pinged).toBe(0); // probe never runs without the flag
|
|
});
|
|
|
|
test("POST /sessions configura Pi con il thinking globale selezionato", async () => {
|
|
let configured: any;
|
|
const bridge = { onClientEvent: () => {}, emitClientEvent: () => {} };
|
|
const runtime = { bridge } as any;
|
|
let current: any;
|
|
const mgr = {
|
|
get: () => current,
|
|
createFor: () => { current = runtime; return runtime; },
|
|
configure: async (_rt: any, options: any) => { configured = options; },
|
|
start: () => {},
|
|
teardownIfCurrent: (_id: string, expected: any) => {
|
|
if (current !== expected) return false;
|
|
current = undefined;
|
|
return true;
|
|
},
|
|
} as any;
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr,
|
|
thtRunner: {
|
|
ollamaEnsure: async () => ({ ok: true }),
|
|
searchPack: 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: { interactionLanguage: "en", question: "q" } });
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(configured.thinking).toBe("high");
|
|
});
|
|
|
|
test("POST /sessions/:id/resume configura Pi con il thinking persistito", async () => {
|
|
let configured: any;
|
|
const bridge = { onClientEvent: () => {}, emitClientEvent: () => {} };
|
|
const runtime = { bridge } as any;
|
|
let current: any;
|
|
const mgr = {
|
|
get: () => current,
|
|
createFor: () => { current = runtime; return runtime; },
|
|
configure: async (_rt: any, options: any) => { configured = options; },
|
|
start: () => {},
|
|
teardownIfCurrent: (_id: string, expected: any) => {
|
|
if (current !== expected) return false;
|
|
current = undefined;
|
|
return true;
|
|
},
|
|
} as any;
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr,
|
|
thtRunner: {
|
|
ollamaEnsure: async () => ({ ok: true }),
|
|
sessionShow: async () => ({
|
|
status: "open",
|
|
archived: false,
|
|
provider: "zai",
|
|
model: "glm-5.2",
|
|
thinking: "medium",
|
|
}),
|
|
reopenSession: async () => {},
|
|
} as any,
|
|
getSettings: () => ({ workspace: "psd", thinking: "low" }) as any,
|
|
});
|
|
|
|
await app.inject({ method: "POST", url: "/sessions/s-thinking/resume" });
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(configured.thinking).toBe("medium");
|
|
});
|
|
|
|
test("POST /sessions/:id/resume uses the manifest's retained workspace revision", async () => {
|
|
const reopenSession = vi.fn(async () => {});
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: {
|
|
get: () => undefined,
|
|
createFor: () => ({ bridge: { onClientEvent: () => {} } }),
|
|
configure: async () => {}, start: () => {},
|
|
} as any,
|
|
thtRunner: {
|
|
sessionShow: async () => ({
|
|
status: "open", archived: false, workspace_id: "psd-clinical", workspace_revision: "a".repeat(40),
|
|
}),
|
|
reopenSession,
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({ workspace: "legacy" }) as any,
|
|
workspaceRegistry: {
|
|
readPinned: vi.fn(async () => ({
|
|
workspace: operationalWorkspace("psd-clinical"),
|
|
revision: {
|
|
id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40),
|
|
snapshotPath: "/data/workspace-registry/snapshots/aaaaaaaa/psd-clinical.yaml",
|
|
},
|
|
})),
|
|
} as any,
|
|
});
|
|
|
|
const response = await app.inject({ method: "POST", url: "/sessions/pinned/resume" });
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(reopenSession).toHaveBeenCalledWith(
|
|
"pinned", "/data/workspace-registry/snapshots/aaaaaaaa/psd-clinical.yaml",
|
|
);
|
|
});
|
|
|
|
test.each([undefined, { provider: "local", model: "qwen" }])("resume uses the global model/default, not the historical manifest (%j)", async (payload) => {
|
|
const configure = vi.fn(async () => {});
|
|
const manifest = { status: "open", archived: false, provider: "retired", model: "old-model" };
|
|
const runtime = { bridge: { onClientEvent: () => {}, emitClientEvent: () => {} } };
|
|
let current: any;
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: {
|
|
get: () => current,
|
|
createFor: () => { current = runtime; return runtime; },
|
|
configure, start: () => {},
|
|
} as any,
|
|
thtRunner: { sessionShow: async () => manifest, reopenSession: async () => {} } as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
runtimeModelCatalog: sessionCatalog("zai/glm-5.2", ["zai/glm-5.2", "local/qwen"]),
|
|
getSettings: () => ({ workspace: "psd", thinking: "medium" }) as any,
|
|
});
|
|
const response = await app.inject({ method: "POST", url: "/sessions/model-resume/resume", ...(payload ? { payload } : {}) });
|
|
expect(response.statusCode).toBe(200);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
expect(configure).toHaveBeenCalledWith(expect.anything(), expect.objectContaining(payload ?? { provider: "zai", model: "glm-5.2" }));
|
|
expect(manifest).toMatchObject({ provider: "retired", model: "old-model" });
|
|
});
|
|
|
|
test("POST /sessions/:id/resume returns a sanitized error when its retained revision is unavailable", async () => {
|
|
const rawFailure = "cannot read /data/workspace-registry/snapshots/secret-revision";
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
sessionShow: async () => ({
|
|
status: "open", archived: false, workspace_id: "psd-clinical", workspace_revision: "a".repeat(40),
|
|
}),
|
|
} as any,
|
|
getSettings: () => ({ workspace: "legacy" }) as any,
|
|
workspaceRegistry: { readPinned: async () => { throw new Error(rawFailure); } } as any,
|
|
});
|
|
|
|
const response = await app.inject({ method: "POST", url: "/sessions/pinned/resume" });
|
|
|
|
expect(response.statusCode).toBe(409);
|
|
expect(response.body).not.toContain(rawFailure);
|
|
expect(response.json()).toMatchObject({ code: "workspace_revision_unavailable" });
|
|
});
|
|
|
|
test("POST /sessions/:id/resume rejects a pinned schema-v2 workspace before readiness or runtime", async () => {
|
|
const readiness = vi.fn(async () => ({ ok: true }));
|
|
const reopenSession = vi.fn(async () => {});
|
|
const acquireWorkspaceRuntime = vi.fn();
|
|
const createFor = vi.fn();
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
sessionShow: async () => ({
|
|
status: "open", archived: false,
|
|
workspace_id: "psd-clinical", workspace_revision: "a".repeat(40),
|
|
}),
|
|
reopenSession,
|
|
acquireWorkspaceRuntime,
|
|
} as any,
|
|
readiness: { ensure: readiness } as any,
|
|
mgr: { get: () => undefined, createFor } as any,
|
|
getSettings: () => ({ workspace: "legacy" }) as any,
|
|
workspaceRegistry: {
|
|
readPinned: vi.fn(async () => ({
|
|
workspace: {
|
|
workspace: { schema_version: 2, id: "psd-clinical", name: "PSD", language: "it" },
|
|
dwh: {
|
|
engine: "postgres", database: "warehouse", schema: "public",
|
|
supported_transports: ["rest_api"],
|
|
},
|
|
semantic_index: {
|
|
vector_store: {
|
|
engine: "pgvector", database: "warehouse", schema: "vectors",
|
|
collection: "documents", dimensions: 768, distance: "cosine",
|
|
supported_transports: ["rest_api"],
|
|
},
|
|
embedding: {
|
|
provider: "ollama_compatible", model: "nomic-embed-text", dimensions: 768,
|
|
},
|
|
},
|
|
llm_policy: { allowed: ["zai/glm-5.2"] },
|
|
},
|
|
workspaceConfigPath: `/data/workspace-registry/snapshots/${"a".repeat(40)}/psd-clinical.yaml`,
|
|
})),
|
|
} as any,
|
|
});
|
|
|
|
const response = await app.inject({ method: "POST", url: "/sessions/pinned-v2/resume" });
|
|
|
|
expect(response.statusCode).toBe(409);
|
|
expect(response.json()).toEqual({
|
|
code: "workspace_revision_unavailable",
|
|
error: "Session workspace configuration is unavailable. Check configuration and try again.",
|
|
});
|
|
expect(readiness).not.toHaveBeenCalled();
|
|
expect(reopenSession).not.toHaveBeenCalled();
|
|
expect(acquireWorkspaceRuntime).not.toHaveBeenCalled();
|
|
expect(createFor).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("a pruned pin blocks Resume but not active or mutation lifecycle routes", async () => {
|
|
const activePath = "/registry/snapshots/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/b-workspace.yaml";
|
|
const prunedError = "cannot read /registry/snapshots/secret-pruned-revision/b-workspace.yaml";
|
|
const calls: string[] = [];
|
|
const readPinned = vi.fn(async () => { throw new Error(prunedError); });
|
|
let active: any = { bridge: { respond: () => true } };
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
sessionShow: async (_id: string, workspace: string) => {
|
|
expect(workspace).toBe(activePath);
|
|
return {
|
|
id: "pruned", status: "open", archived: false,
|
|
workspace_id: "b-workspace", workspace_revision: "b".repeat(40),
|
|
};
|
|
},
|
|
closeSession: async (_id: string, workspace: string) => { calls.push(`close:${workspace}`); },
|
|
deleteSession: async (_id: string, workspace: string) => { calls.push(`delete:${workspace}`); },
|
|
} as any,
|
|
mgr: {
|
|
get: () => active,
|
|
teardownIfCurrent: (_id: string, expected: any) => {
|
|
if (active !== expected) return false;
|
|
active = undefined;
|
|
return true;
|
|
},
|
|
} as any,
|
|
workspaceRegistry: {
|
|
list: async () => [{
|
|
id: "b-workspace", commit: "a".repeat(40), blob: "a".repeat(40), snapshotPath: activePath,
|
|
}],
|
|
readPinned,
|
|
} as any,
|
|
});
|
|
|
|
expect((await app.inject({ method: "POST", url: "/sessions/pruned/response", payload: { ui_response: {} } })).statusCode)
|
|
.toBe(204);
|
|
expect((await app.inject({ method: "POST", url: "/sessions/pruned/close" })).statusCode).toBe(200);
|
|
expect((await app.inject({ method: "DELETE", url: "/sessions/pruned" })).statusCode).toBe(204);
|
|
expect(calls).toEqual([`close:${activePath}`, `delete:${activePath}`]);
|
|
expect(readPinned).not.toHaveBeenCalled();
|
|
|
|
const resume = await app.inject({ method: "POST", url: "/sessions/pruned/resume" });
|
|
expect(resume.statusCode).toBe(409);
|
|
expect(resume.body).not.toContain(prunedError);
|
|
expect(resume.json()).toMatchObject({ code: "workspace_revision_unavailable" });
|
|
expect(readPinned).toHaveBeenCalledWith("b-workspace", "b".repeat(40));
|
|
});
|
|
|
|
test("POST /sessions/:id/resume refuses a pinned finalized session before reading its snapshot", async () => {
|
|
const readPinned = vi.fn(async () => { throw new Error("must not resolve"); });
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
sessionShow: async () => ({
|
|
status: "finalized", archived: false, workspace_id: "psd-clinical", workspace_revision: "a".repeat(40),
|
|
}),
|
|
} as any,
|
|
getSettings: () => ({ workspace: "legacy" }) as any,
|
|
workspaceRegistry: { readPinned } as any,
|
|
});
|
|
|
|
const response = await app.inject({ method: "POST", url: "/sessions/pinned-final/resume" });
|
|
|
|
expect(response.statusCode).toBe(409);
|
|
expect(response.json()).toMatchObject({ error: expect.stringMatching(/sola lettura/i) });
|
|
expect(readPinned).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("GET /sessions/:id warns when a legacy manifest has no workspace revision", async () => {
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: {
|
|
get: () => undefined,
|
|
createFor: () => ({ bridge: { onClientEvent: () => {} } }),
|
|
configure: async () => {}, start: () => {},
|
|
} as any,
|
|
thtRunner: { sessionShow: async () => ({ status: "open", archived: false }), reopenSession: async () => {} } as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({ workspace: "legacy" }) as any,
|
|
});
|
|
|
|
const response = await app.inject({ method: "GET", url: "/sessions/legacy" });
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toMatchObject({ warning: expect.stringMatching(/legacy/i) });
|
|
});
|
|
|
|
test("POST /sessions/:id/resume usa il thinking globale se manca nel manifest", async () => {
|
|
let configured: any;
|
|
const bridge = { onClientEvent: () => {}, emitClientEvent: () => {} };
|
|
const runtime = { bridge } as any;
|
|
let current: any;
|
|
const mgr = {
|
|
get: () => current,
|
|
createFor: () => { current = runtime; return runtime; },
|
|
configure: async (_rt: any, options: any) => { configured = options; },
|
|
start: () => {},
|
|
teardownIfCurrent: (_id: string, expected: any) => {
|
|
if (current !== expected) return false;
|
|
current = undefined;
|
|
return true;
|
|
},
|
|
} as any;
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr,
|
|
thtRunner: {
|
|
ollamaEnsure: async () => ({ ok: true }),
|
|
sessionShow: async () => ({ status: "open", archived: false }),
|
|
reopenSession: async () => {},
|
|
} as any,
|
|
getSettings: () => ({ workspace: "psd", thinking: "low" }) as any,
|
|
});
|
|
|
|
await app.inject({ method: "POST", url: "/sessions/s-thinking/resume" });
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(configured.thinking).toBe("low");
|
|
});
|
|
|
|
test("resuming a different session stops the user's previous Pi runtime", async () => {
|
|
const runtimes = new Map<string, any>();
|
|
const mgr = {
|
|
get: (id: string) => runtimes.get(id),
|
|
teardownForPrincipal: () => {
|
|
const stopped = [...runtimes.keys()];
|
|
runtimes.clear();
|
|
return stopped;
|
|
},
|
|
createFor: (id: string) => {
|
|
if (runtimes.size >= 1) throw new Error("max Pi processes reached");
|
|
const runtime = { bridge: { onClientEvent: () => {}, turnState: () => "running" } };
|
|
runtimes.set(id, runtime);
|
|
return runtime;
|
|
},
|
|
configure: async () => {},
|
|
start: () => {},
|
|
teardownIfCurrent: (id: string, expected: any) => {
|
|
if (runtimes.get(id) !== expected) return false;
|
|
runtimes.delete(id);
|
|
return true;
|
|
},
|
|
} as any;
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr,
|
|
thtRunner: {
|
|
sessionNew: async () => ({ id: "s1" }),
|
|
sessionShow: async (id: string) => ({ id, status: "open", archived: false }),
|
|
reopenSession: async () => {},
|
|
searchPack: async () => {},
|
|
failSession: async () => {},
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({ workspace: "local" }) as any,
|
|
});
|
|
expect((await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "one" } })).statusCode)
|
|
.toBe(200);
|
|
|
|
const resumed = await app.inject({ method: "POST", url: "/sessions/s2/resume" });
|
|
|
|
expect(resumed.statusCode).toBe(200);
|
|
expect(resumed.json()).toEqual({ id: "s2", alreadyActive: false });
|
|
expect(runtimes.has("s1")).toBe(false);
|
|
expect(runtimes.has("s2")).toBe(true);
|
|
});
|
|
|
|
test.each(["running", "waiting"])(
|
|
"POST resume preserves a %s runtime",
|
|
async (state) => {
|
|
let tornDown = false;
|
|
let cleared = false;
|
|
const existing = { bridge: { turnState: () => state } } as any;
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: {
|
|
get: () => existing,
|
|
teardown: () => { tornDown = true; },
|
|
} as any,
|
|
hub: { clear: () => { cleared = true; } } as any,
|
|
thtRunner: {} as any,
|
|
getSettings: () => ({ workspace: "psd" }) as any,
|
|
});
|
|
const response = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
|
expect(response.json()).toEqual({ id: "s1", alreadyActive: true });
|
|
expect(tornDown).toBe(false);
|
|
expect(cleared).toBe(false);
|
|
},
|
|
);
|
|
|
|
test.each(["idle", "failed"])(
|
|
"POST resume replaces a %s runtime and starts the persisted session",
|
|
async (state) => {
|
|
const order: string[] = [];
|
|
const oldRuntime = { bridge: { turnState: () => state } } as any;
|
|
const newRuntime = { bridge: { onClientEvent: () => {}, emitClientEvent: () => {} } } as any;
|
|
let current: any = oldRuntime;
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: {
|
|
get: () => current,
|
|
teardownIfCurrent: (id: string, expected: any) => {
|
|
if (current !== expected) return false;
|
|
order.push(`teardown:${id}`);
|
|
current = undefined;
|
|
return true;
|
|
},
|
|
createFor: () => { order.push("create"); current = newRuntime; return newRuntime; },
|
|
configure: async () => {},
|
|
start: () => order.push("start"),
|
|
} as any,
|
|
hub: {
|
|
clear: (id: string) => order.push(`clear:${id}`),
|
|
publish: () => {},
|
|
} as any,
|
|
thtRunner: {
|
|
sessionShow: async () => ({
|
|
status: "open", archived: false,
|
|
provider: "local-qwen", model: "qwen3.6-35b-a3b", thinking: "low",
|
|
}),
|
|
reopenSession: async () => order.push("reopen"),
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({ workspace: "local", thinking: "medium" }) as any,
|
|
});
|
|
const response = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
expect(response.json()).toEqual({ id: "s1", alreadyActive: false });
|
|
expect(order).toEqual(["reopen", "teardown:s1", "create", "clear:s1", "start"]);
|
|
},
|
|
);
|
|
|
|
test("POST resume without a runtime clears stale SSE state before cold start", async () => {
|
|
const order: string[] = [];
|
|
let createOptions: any;
|
|
const newRuntime = { bridge: { onClientEvent: () => {}, emitClientEvent: () => {} } } as any;
|
|
let current: any;
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: {
|
|
get: () => current,
|
|
createFor: (_id: string, options: any) => {
|
|
createOptions = options;
|
|
order.push("create");
|
|
current = newRuntime;
|
|
return newRuntime;
|
|
},
|
|
configure: async () => {},
|
|
start: () => order.push("start"),
|
|
} as any,
|
|
hub: {
|
|
clear: (id: string) => order.push(`clear:${id}`),
|
|
publish: () => {},
|
|
} as any,
|
|
thtRunner: {
|
|
sessionShow: async () => ({
|
|
status: "open", archived: false,
|
|
provider: "local-qwen", model: "qwen3.6-35b-a3b", thinking: "low",
|
|
}),
|
|
reopenSession: async () => order.push("reopen"),
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({ workspace: "local", thinking: "medium" }) as any,
|
|
});
|
|
|
|
const response = await app.inject({ method: "POST", url: "/sessions/crashed/resume" });
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(response.json()).toEqual({ id: "crashed", alreadyActive: false });
|
|
expect(order).toEqual(["reopen", "create", "clear:crashed", "start"]);
|
|
expect(createOptions).toMatchObject({
|
|
provider: "local-qwen", model: "qwen3.6-35b-a3b", thinking: "low", mode: "resume",
|
|
});
|
|
});
|
|
|
|
test("POST resume keeps an idle runtime stream attached when the manifest is read-only", async () => {
|
|
let tornDown = false;
|
|
let cleared = false;
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: {
|
|
get: () => ({ bridge: { turnState: () => "idle" } }),
|
|
teardown: () => { tornDown = true; },
|
|
} as any,
|
|
hub: { clear: () => { cleared = true; } } as any,
|
|
thtRunner: {
|
|
sessionShow: async () => ({ status: "finalized", archived: false }),
|
|
} as any,
|
|
getSettings: () => ({ workspace: "local" }) as any,
|
|
});
|
|
|
|
const response = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
|
|
|
expect(response.statusCode).toBe(409);
|
|
expect(tornDown).toBe(false);
|
|
expect(cleared).toBe(false);
|
|
});
|
|
|
|
test("POST resume keeps a failed runtime stream attached when readiness fails", async () => {
|
|
let tornDown = false;
|
|
let cleared = false;
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: {
|
|
get: () => ({ bridge: { turnState: () => "failed" } }),
|
|
teardown: () => { tornDown = true; },
|
|
} as any,
|
|
hub: { clear: () => { cleared = true; } } as any,
|
|
thtRunner: {
|
|
sessionShow: async () => ({ status: "open", archived: false }),
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: false, error: "not ready" }) } as any,
|
|
getSettings: () => ({ workspace: "local" }) as any,
|
|
});
|
|
|
|
const response = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
|
|
|
expect(response.statusCode).toBe(503);
|
|
expect(tornDown).toBe(false);
|
|
expect(cleared).toBe(false);
|
|
});
|
|
|
|
test("POST resume sanitizes reopen failure and preserves the old hub attachment", async () => {
|
|
const delivered: string[] = [];
|
|
const actualHub = new SseHub();
|
|
let clearCalls = 0;
|
|
let tornDown = false;
|
|
const hub = {
|
|
subscribe: actualHub.subscribe.bind(actualHub),
|
|
publish: actualHub.publish.bind(actualHub),
|
|
clear: (id: string) => { clearCalls += 1; actualHub.clear(id); },
|
|
} as any;
|
|
hub.subscribe("s1", (_event: string, data: any) => delivered.push(data.text));
|
|
hub.publish("s1", "info", { text: "before" });
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: {
|
|
get: () => ({ bridge: { turnState: () => "idle" } }),
|
|
teardown: () => { tornDown = true; },
|
|
} as any,
|
|
hub,
|
|
thtRunner: {
|
|
sessionShow: async () => ({ status: "open", archived: false }),
|
|
reopenSession: async () => { throw new Error("REOPEN_SENTINEL"); },
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({ workspace: "local" }) as any,
|
|
});
|
|
|
|
const response = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
|
hub.publish("s1", "info", { text: "post-failure probe" });
|
|
|
|
expect(response.statusCode).toBe(503);
|
|
expect(response.json()).toEqual({
|
|
error: "Session could not be resumed. Check configuration and connectivity, then try again.",
|
|
});
|
|
expect(response.body).not.toContain("REOPEN_SENTINEL");
|
|
expect(clearCalls).toBe(0);
|
|
expect(tornDown).toBe(false);
|
|
expect(delivered).toEqual(["before", "post-failure probe"]);
|
|
});
|
|
|
|
test("POST resume sanitizes runtime creation failure and preserves the old hub attachment", async () => {
|
|
const delivered: string[] = [];
|
|
const actualHub = new SseHub();
|
|
let clearCalls = 0;
|
|
let createCalls = 0;
|
|
const hub = {
|
|
subscribe: actualHub.subscribe.bind(actualHub),
|
|
publish: actualHub.publish.bind(actualHub),
|
|
clear: (id: string) => { clearCalls += 1; actualHub.clear(id); },
|
|
} as any;
|
|
hub.subscribe("s1", (_event: string, data: any) => delivered.push(data.text));
|
|
hub.publish("s1", "info", { text: "before" });
|
|
let current: any = { bridge: { turnState: () => "failed" } };
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: {
|
|
get: () => current,
|
|
teardownIfCurrent: (_id: string, expected: any) => {
|
|
if (current !== expected) return false;
|
|
current = undefined;
|
|
return true;
|
|
},
|
|
createFor: () => { createCalls += 1; throw new Error("CREATE_SENTINEL"); },
|
|
} as any,
|
|
hub,
|
|
thtRunner: {
|
|
sessionShow: async () => ({ status: "open", archived: false }),
|
|
reopenSession: async () => {},
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({ workspace: "local" }) as any,
|
|
});
|
|
|
|
const response = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
|
hub.publish("s1", "info", { text: "post-failure probe" });
|
|
|
|
expect(response.statusCode).toBe(503);
|
|
expect(response.json()).toEqual({
|
|
error: "Session could not be resumed. Check configuration and connectivity, then try again.",
|
|
});
|
|
expect(response.body).not.toContain("CREATE_SENTINEL");
|
|
expect(createCalls).toBe(1);
|
|
expect(clearCalls).toBe(0);
|
|
expect(delivered).toEqual(["before", "post-failure probe"]);
|
|
});
|
|
|
|
test("concurrent cold Resume requests serialize and create one runtime", async () => {
|
|
let runtime: any;
|
|
let showCalls = 0;
|
|
let readinessCalls = 0;
|
|
let reopenCalls = 0;
|
|
let createCalls = 0;
|
|
let clearCalls = 0;
|
|
let releaseReopen!: () => void;
|
|
let markReopenStarted!: () => void;
|
|
const reopenStarted = new Promise<void>((resolve) => { markReopenStarted = resolve; });
|
|
const reopenReleased = new Promise<void>((resolve) => { releaseReopen = resolve; });
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: {
|
|
get: () => runtime,
|
|
createFor: () => {
|
|
createCalls += 1;
|
|
runtime = {
|
|
bridge: {
|
|
turnState: () => "running",
|
|
onClientEvent: () => {},
|
|
emitClientEvent: () => {},
|
|
},
|
|
};
|
|
return runtime;
|
|
},
|
|
configure: async () => {},
|
|
start: () => {},
|
|
teardown: () => {},
|
|
} as any,
|
|
hub: {
|
|
clear: () => { clearCalls += 1; },
|
|
publish: () => {},
|
|
} as any,
|
|
thtRunner: {
|
|
sessionShow: async () => {
|
|
showCalls += 1;
|
|
return { status: "open", archived: false };
|
|
},
|
|
reopenSession: async () => {
|
|
reopenCalls += 1;
|
|
markReopenStarted();
|
|
await reopenReleased;
|
|
},
|
|
} as any,
|
|
readiness: {
|
|
ensure: async () => { readinessCalls += 1; return { ok: true }; },
|
|
} as any,
|
|
getSettings: () => ({ workspace: "local" }) as any,
|
|
});
|
|
|
|
const first = app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
|
await reopenStarted;
|
|
const second = app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
releaseReopen();
|
|
const [firstResponse, secondResponse] = await Promise.all([first, second]);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(firstResponse.json()).toEqual({ id: "s1", alreadyActive: false });
|
|
expect(secondResponse.json()).toEqual({ id: "s1", alreadyActive: true });
|
|
expect({ showCalls, readinessCalls, reopenCalls, createCalls, clearCalls }).toEqual({
|
|
// Each caller is authorized against repository ownership, including the request which
|
|
// finds the runtime already active after waiting on the lifecycle lock.
|
|
showCalls: 2,
|
|
readinessCalls: 1,
|
|
reopenCalls: 1,
|
|
createCalls: 1,
|
|
clearCalls: 1,
|
|
});
|
|
});
|
|
|
|
test.each([
|
|
["close", "resolve"],
|
|
["close", "reject"],
|
|
["delete", "resolve"],
|
|
["delete", "reject"],
|
|
] as const)(
|
|
"a bootstrap that %s left behind cannot %s against a replacement runtime",
|
|
async (lifecycle, outcome) => {
|
|
const oldConfigure = deferred<void>();
|
|
const published: any[] = [];
|
|
const starts: string[] = [];
|
|
let failed = 0;
|
|
let configureCalls = 0;
|
|
let current: any;
|
|
const runtime = (name: string) => {
|
|
let listener: ((event: any) => void) | undefined;
|
|
return {
|
|
name,
|
|
bridge: {
|
|
turnState: () => "running",
|
|
onClientEvent: (next: (event: any) => void) => { listener = next; },
|
|
emitClientEvent: (event: any) => listener?.(event),
|
|
},
|
|
};
|
|
};
|
|
const oldRuntime = runtime("old");
|
|
const replacement = runtime("replacement");
|
|
const runtimes = [oldRuntime, replacement];
|
|
const mgr = {
|
|
get: () => current,
|
|
createFor: () => {
|
|
current = runtimes.shift();
|
|
return current;
|
|
},
|
|
configure: async () => {
|
|
configureCalls += 1;
|
|
if (configureCalls === 1) await oldConfigure.promise;
|
|
},
|
|
start: (_id: string, expected: any) => {
|
|
if (current !== expected) throw new Error("stale start");
|
|
starts.push(expected.name);
|
|
},
|
|
teardown: () => { current = undefined; },
|
|
teardownIfCurrent: (_id: string, expected: any) => {
|
|
if (current !== expected) return false;
|
|
current = undefined;
|
|
return true;
|
|
},
|
|
};
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: mgr as any,
|
|
hub: {
|
|
publish: (_id: string, event: string, data: object) => {
|
|
published.push({ event, data });
|
|
return published.length;
|
|
},
|
|
clear: () => {},
|
|
forget: () => {},
|
|
} as any,
|
|
thtRunner: {
|
|
sessionNew: async () => ({ id: "s1" }),
|
|
searchPack: async () => {},
|
|
sessionShow: async () => ({ status: "open", archived: false }),
|
|
reopenSession: async () => {},
|
|
closeSession: async () => {},
|
|
deleteSession: async () => {},
|
|
failSession: async () => { failed += 1; },
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({ workspace: "local" }) as any,
|
|
});
|
|
|
|
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "old" } });
|
|
if (lifecycle === "close") {
|
|
await app.inject({ method: "POST", url: "/sessions/s1/close" });
|
|
await app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
|
} else {
|
|
await app.inject({ method: "DELETE", url: "/sessions/s1" });
|
|
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "replacement" } });
|
|
}
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
expect(current).toBe(replacement);
|
|
published.length = 0;
|
|
starts.length = 0;
|
|
failed = 0;
|
|
|
|
if (outcome === "resolve") oldConfigure.resolve();
|
|
else oldConfigure.reject(new Error("old bootstrap failed"));
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(current).toBe(replacement);
|
|
expect(starts).toEqual([]);
|
|
expect(failed).toBe(0);
|
|
expect(published).toEqual([]);
|
|
},
|
|
);
|
|
|
|
test.each(["resolve", "reject"] as const)(
|
|
"a deleted session stays forgotten when its stale bootstrap later %s",
|
|
async (outcome) => {
|
|
const oldConfigure = deferred<void>();
|
|
const published: any[] = [];
|
|
let failed = 0;
|
|
let current: any;
|
|
let forgotten = false;
|
|
let listener: ((event: any) => void) | undefined;
|
|
const runtime = {
|
|
bridge: {
|
|
onClientEvent: (next: (event: any) => void) => { listener = next; },
|
|
emitClientEvent: (event: any) => listener?.(event),
|
|
},
|
|
};
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: {
|
|
get: () => current,
|
|
createFor: () => { current = runtime; return runtime; },
|
|
configure: async () => oldConfigure.promise,
|
|
start: () => { throw new Error("deleted runtime must not start"); },
|
|
teardown: () => { current = undefined; },
|
|
teardownIfCurrent: (_id: string, expected: any) => {
|
|
if (current !== expected) return false;
|
|
current = undefined;
|
|
return true;
|
|
},
|
|
} as any,
|
|
hub: {
|
|
publish: (_id: string, event: string, data: object) => {
|
|
published.push({ event, data });
|
|
return published.length;
|
|
},
|
|
forget: () => { forgotten = true; },
|
|
} as any,
|
|
thtRunner: {
|
|
sessionNew: async () => ({ id: "s1" }),
|
|
searchPack: async () => {},
|
|
deleteSession: async () => {},
|
|
failSession: async () => { failed += 1; },
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({ workspace: "local" }) as any,
|
|
});
|
|
|
|
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "old" } });
|
|
await app.inject({ method: "DELETE", url: "/sessions/s1" });
|
|
published.length = 0;
|
|
|
|
if (outcome === "resolve") oldConfigure.resolve();
|
|
else oldConfigure.reject(new Error("old bootstrap failed"));
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(current).toBeUndefined();
|
|
expect(forgotten).toBe(true);
|
|
expect(failed).toBe(0);
|
|
expect(published).toEqual([]);
|
|
},
|
|
);
|
|
|
|
test.each(["close", "delete"] as const)(
|
|
"Resume followed by %s leaves no runtime or post-removal bootstrap events",
|
|
async (lifecycle) => {
|
|
const reopen = deferred<void>();
|
|
const configure = deferred<void>();
|
|
let markReopenStarted!: () => void;
|
|
const reopenStarted = new Promise<void>((resolve) => { markReopenStarted = resolve; });
|
|
let current: any;
|
|
let removalSettled = false;
|
|
const published: any[] = [];
|
|
const removalEvents: string[] = [];
|
|
const replacement = {
|
|
bridge: {
|
|
turnState: () => "running",
|
|
onClientEvent: () => {},
|
|
emitClientEvent: () => {},
|
|
},
|
|
};
|
|
const teardown = (expected?: any) => {
|
|
if (expected && current !== expected) return false;
|
|
current = undefined;
|
|
return true;
|
|
};
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: {
|
|
get: () => current,
|
|
createFor: () => { current = replacement; return replacement; },
|
|
configure: async () => configure.promise,
|
|
start: () => { throw new Error("removed runtime must not start"); },
|
|
teardown: () => { teardown(); },
|
|
teardownIfCurrent: (_id: string, expected: any) => teardown(expected),
|
|
} as any,
|
|
hub: {
|
|
publish: (_id: string, event: string, data: object) => {
|
|
published.push({ event, data });
|
|
return published.length;
|
|
},
|
|
clear: () => { removalEvents.push("clear"); },
|
|
forget: () => { removalEvents.push("forget"); },
|
|
} as any,
|
|
thtRunner: {
|
|
sessionShow: async () => ({ status: "open", archived: false }),
|
|
reopenSession: async () => {
|
|
markReopenStarted();
|
|
await reopen.promise;
|
|
},
|
|
closeSession: async () => { removalEvents.push("close"); },
|
|
deleteSession: async () => { removalEvents.push("delete"); },
|
|
failSession: async () => {},
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({ workspace: "local" }) as any,
|
|
});
|
|
|
|
const resumeResponse = app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
|
await reopenStarted;
|
|
const removalResponse = app.inject({
|
|
method: lifecycle === "close" ? "POST" : "DELETE",
|
|
url: `/sessions/s1/${lifecycle === "close" ? "close" : ""}`.replace(/\/$/, ""),
|
|
}).then((response) => {
|
|
removalSettled = true;
|
|
return response;
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
expect(removalSettled).toBe(false);
|
|
|
|
reopen.resolve();
|
|
expect((await resumeResponse).json()).toEqual({ id: "s1", alreadyActive: false });
|
|
await removalResponse;
|
|
published.length = 0;
|
|
configure.resolve();
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(current).toBeUndefined();
|
|
expect(published).toEqual([]);
|
|
expect(removalEvents.at(-1)).toBe(lifecycle === "close" ? "clear" : "forget");
|
|
},
|
|
);
|
|
|
|
test("Close followed by Resume installs a fresh runtime only after Close finishes", async () => {
|
|
const close = deferred<void>();
|
|
let markCloseStarted!: () => void;
|
|
const closeStarted = new Promise<void>((resolve) => { markCloseStarted = resolve; });
|
|
const oldRuntime = { bridge: { turnState: () => "running" } };
|
|
const replacement = {
|
|
bridge: {
|
|
turnState: () => "running",
|
|
onClientEvent: () => {},
|
|
emitClientEvent: () => {},
|
|
},
|
|
};
|
|
let current: any = oldRuntime;
|
|
let resumeSettled = false;
|
|
const teardown = (expected?: any) => {
|
|
if (expected && current !== expected) return false;
|
|
current = undefined;
|
|
return true;
|
|
};
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: {
|
|
get: () => current,
|
|
createFor: () => { current = replacement; return replacement; },
|
|
configure: async () => {},
|
|
start: () => {},
|
|
teardown: () => { teardown(); },
|
|
teardownIfCurrent: (_id: string, expected: any) => teardown(expected),
|
|
} as any,
|
|
hub: { publish: () => 1, clear: () => {} } as any,
|
|
thtRunner: {
|
|
closeSession: async () => { markCloseStarted(); await close.promise; },
|
|
sessionShow: async () => ({ status: "open", archived: false }),
|
|
reopenSession: async () => {},
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({ workspace: "local" }) as any,
|
|
});
|
|
|
|
const closeResponse = app.inject({ method: "POST", url: "/sessions/s1/close" });
|
|
await closeStarted;
|
|
const resumeResponse = app.inject({ method: "POST", url: "/sessions/s1/resume" })
|
|
.then((response) => { resumeSettled = true; return response; });
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
expect(resumeSettled).toBe(false);
|
|
|
|
close.resolve();
|
|
await closeResponse;
|
|
const resumed = await resumeResponse;
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(resumed.json()).toEqual({ id: "s1", alreadyActive: false });
|
|
expect(current).toBe(replacement);
|
|
});
|
|
|
|
test("Close suppresses a bootstrap that settles while close persistence is pending", async () => {
|
|
const configure = deferred<void>();
|
|
const close = deferred<void>();
|
|
let markCloseStarted!: () => void;
|
|
const closeStarted = new Promise<void>((resolve) => { markCloseStarted = resolve; });
|
|
const published: any[] = [];
|
|
const starts: any[] = [];
|
|
let current: any;
|
|
let listener: ((event: any) => void) | undefined;
|
|
const runtime = {
|
|
bridge: {
|
|
onClientEvent: (next: (event: any) => void) => { listener = next; },
|
|
emitClientEvent: (event: any) => listener?.(event),
|
|
},
|
|
};
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: {
|
|
get: () => current,
|
|
createFor: () => { current = runtime; return runtime; },
|
|
configure: async () => configure.promise,
|
|
start: (_id: string, expected: any) => { starts.push(expected); },
|
|
teardownIfCurrent: (_id: string, expected: any) => {
|
|
if (current !== expected) return false;
|
|
current = undefined;
|
|
return true;
|
|
},
|
|
} as any,
|
|
hub: {
|
|
publish: (_id: string, event: string, data: object) => {
|
|
published.push({ event, data });
|
|
return published.length;
|
|
},
|
|
clear: () => {},
|
|
} as any,
|
|
thtRunner: {
|
|
sessionNew: async () => ({ id: "s1" }),
|
|
searchPack: async () => {},
|
|
closeSession: async () => { markCloseStarted(); await close.promise; },
|
|
failSession: async () => {},
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({ workspace: "local" }) as any,
|
|
});
|
|
|
|
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
|
|
const closeResponse = app.inject({ method: "POST", url: "/sessions/s1/close" });
|
|
await closeStarted;
|
|
published.length = 0;
|
|
configure.resolve();
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(starts).toEqual([]);
|
|
expect(published).toEqual([]);
|
|
|
|
close.resolve();
|
|
await closeResponse;
|
|
expect(current).toBeUndefined();
|
|
});
|
|
|
|
test("bootstrap failure persists once and keeps Resume serialized behind that persistence", async () => {
|
|
const oldConfigure = deferred<void>();
|
|
const failurePersistence = deferred<void>();
|
|
let markFailureStarted!: () => void;
|
|
const failureStarted = new Promise<void>((resolve) => { markFailureStarted = resolve; });
|
|
const oldRuntime = {
|
|
bridge: {
|
|
onClientEvent: undefined as ((next: (event: any) => void) => void) | undefined,
|
|
emitClientEvent: undefined as ((event: any) => void) | undefined,
|
|
},
|
|
} as any;
|
|
const replacement = {
|
|
bridge: {
|
|
turnState: () => "running",
|
|
onClientEvent: () => {},
|
|
emitClientEvent: () => {},
|
|
},
|
|
};
|
|
let oldListener: ((event: any) => void) | undefined;
|
|
oldRuntime.bridge.onClientEvent = (next: (event: any) => void) => { oldListener = next; };
|
|
oldRuntime.bridge.emitClientEvent = (event: any) => oldListener?.(event);
|
|
const runtimes = [oldRuntime, replacement];
|
|
let current: any;
|
|
let configureCalls = 0;
|
|
let failCalls = 0;
|
|
let resumeSettled = false;
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: {
|
|
get: () => current,
|
|
createFor: () => { current = runtimes.shift(); return current; },
|
|
configure: async () => {
|
|
configureCalls += 1;
|
|
if (configureCalls === 1) await oldConfigure.promise;
|
|
},
|
|
start: () => {},
|
|
teardownIfCurrent: (_id: string, expected: any) => {
|
|
if (current !== expected) return false;
|
|
current = undefined;
|
|
return true;
|
|
},
|
|
} as any,
|
|
hub: { publish: () => 1, clear: () => {} } as any,
|
|
thtRunner: {
|
|
sessionNew: async () => ({ id: "s1" }),
|
|
searchPack: async () => {},
|
|
sessionShow: async () => ({ status: "open", archived: false }),
|
|
reopenSession: async () => {},
|
|
failSession: async () => {
|
|
failCalls += 1;
|
|
if (failCalls === 1) {
|
|
markFailureStarted();
|
|
await failurePersistence.promise;
|
|
}
|
|
},
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({ workspace: "local" }) as any,
|
|
});
|
|
|
|
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
|
|
oldConfigure.reject(new Error("configure failed"));
|
|
await failureStarted;
|
|
const resumeResponse = app.inject({ method: "POST", url: "/sessions/s1/resume" })
|
|
.then((response) => { resumeSettled = true; return response; });
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
expect(resumeSettled).toBe(false);
|
|
|
|
failurePersistence.resolve();
|
|
expect((await resumeResponse).json()).toEqual({ id: "s1", alreadyActive: false });
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(failCalls).toBe(1);
|
|
expect(current).toBe(replacement);
|
|
});
|
|
|
|
test("Delete followed by Resume cannot resurrect the deleted session", async () => {
|
|
const deletion = deferred<void>();
|
|
let markDeleteStarted!: () => void;
|
|
const deleteStarted = new Promise<void>((resolve) => { markDeleteStarted = resolve; });
|
|
const oldRuntime = { bridge: { turnState: () => "running" } };
|
|
let current: any = oldRuntime;
|
|
let deleted = false;
|
|
let resumeSettled = false;
|
|
let createCalls = 0;
|
|
const teardown = (expected?: any) => {
|
|
if (expected && current !== expected) return false;
|
|
current = undefined;
|
|
return true;
|
|
};
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: {
|
|
get: () => current,
|
|
createFor: () => {
|
|
createCalls += 1;
|
|
current = { bridge: { onClientEvent: () => {}, emitClientEvent: () => {} } };
|
|
return current;
|
|
},
|
|
configure: async () => {},
|
|
start: () => {},
|
|
teardown: () => { teardown(); },
|
|
teardownIfCurrent: (_id: string, expected: any) => teardown(expected),
|
|
} as any,
|
|
hub: { publish: () => 1, clear: () => {}, forget: () => {} } as any,
|
|
thtRunner: {
|
|
deleteSession: async () => {
|
|
markDeleteStarted();
|
|
await deletion.promise;
|
|
deleted = true;
|
|
},
|
|
sessionShow: async () => {
|
|
if (deleted) throw new Error("session deleted");
|
|
return { status: "open", archived: false };
|
|
},
|
|
reopenSession: async () => {},
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({ workspace: "local" }) as any,
|
|
});
|
|
|
|
const deleteResponse = app.inject({ method: "DELETE", url: "/sessions/s1" });
|
|
await deleteStarted;
|
|
const resumeResponse = app.inject({ method: "POST", url: "/sessions/s1/resume" })
|
|
.then((response) => { resumeSettled = true; return response; });
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
expect(resumeSettled).toBe(false);
|
|
|
|
deletion.resolve();
|
|
await deleteResponse;
|
|
const resumed = await resumeResponse;
|
|
|
|
expect(resumed.statusCode).toBe(503);
|
|
expect(current).toBeUndefined();
|
|
expect(createCalls).toBe(0);
|
|
});
|
|
|
|
test("a replaced runtime cannot publish or fail the newly resumed session", async () => {
|
|
const controlledBridge = (initialState: string) => {
|
|
let state = initialState;
|
|
let listener: ((event: any) => void) | undefined;
|
|
return {
|
|
turnState: () => state,
|
|
setState: (next: string) => { state = next; },
|
|
onClientEvent: (next: (event: any) => void) => { listener = next; },
|
|
emitClientEvent: (event: any) => listener?.(event),
|
|
pendingWidget: () => null,
|
|
};
|
|
};
|
|
const oldBridge = controlledBridge("running");
|
|
const newBridge = controlledBridge("running");
|
|
const runtimes = [{ bridge: oldBridge }, { bridge: newBridge }];
|
|
let current: any;
|
|
let failed = 0;
|
|
const published: any[] = [];
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: {
|
|
get: () => current,
|
|
createFor: () => { current = runtimes.shift(); return current; },
|
|
configure: async () => {},
|
|
start: () => {},
|
|
teardownIfCurrent: (_id: string, expected: any) => {
|
|
if (current !== expected) return false;
|
|
current = undefined;
|
|
return true;
|
|
},
|
|
} as any,
|
|
hub: {
|
|
clear: () => {},
|
|
publish: (_id: string, event: string, data: object) => {
|
|
published.push({ event, data });
|
|
return published.length;
|
|
},
|
|
} as any,
|
|
thtRunner: {
|
|
sessionNew: async () => ({ id: "s1" }),
|
|
searchPack: async () => {},
|
|
sessionShow: async () => ({ status: "open", archived: false }),
|
|
reopenSession: async () => {},
|
|
failSession: async () => { failed += 1; },
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({ workspace: "local" }) as any,
|
|
});
|
|
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
oldBridge.setState("idle");
|
|
|
|
const response = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
|
published.length = 0;
|
|
// The manager removes an unexpectedly exited active runtime before its bridge publishes the
|
|
// public failure sequence. Binding identity, rather than mgr.get(), must still admit it.
|
|
current = undefined;
|
|
oldBridge.emitClientEvent({ type: "system_event", event: "session_failed" });
|
|
newBridge.emitClientEvent({ type: "info", text: "new runtime" });
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(response.json()).toEqual({ id: "s1", alreadyActive: false });
|
|
expect(failed).toBe(0);
|
|
expect(published).toEqual([{
|
|
event: "info",
|
|
data: { type: "info", text: "new runtime" },
|
|
}]);
|
|
});
|
|
|
|
test("a deleted runtime cannot repopulate or fail the forgotten session", async () => {
|
|
let listener: ((event: any) => void) | undefined;
|
|
const bridge = {
|
|
turnState: () => "running",
|
|
onClientEvent: (next: (event: any) => void) => { listener = next; },
|
|
emitClientEvent: (event: any) => listener?.(event),
|
|
};
|
|
const runtime = { bridge };
|
|
let current: any;
|
|
let failed = 0;
|
|
const published: any[] = [];
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: {
|
|
get: () => current,
|
|
createFor: () => { current = runtime; return runtime; },
|
|
configure: async () => {},
|
|
start: () => {},
|
|
teardownIfCurrent: (_id: string, expected: any) => {
|
|
if (current !== expected) return false;
|
|
current = undefined;
|
|
return true;
|
|
},
|
|
} as any,
|
|
hub: {
|
|
publish: (_id: string, event: string, data: object) => {
|
|
published.push({ event, data });
|
|
return published.length;
|
|
},
|
|
forget: () => {},
|
|
} as any,
|
|
thtRunner: {
|
|
sessionNew: async () => ({ id: "s1" }),
|
|
searchPack: async () => {},
|
|
deleteSession: async () => {},
|
|
failSession: async () => { failed += 1; },
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({ workspace: "local" }) as any,
|
|
});
|
|
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
const response = await app.inject({ method: "DELETE", url: "/sessions/s1" });
|
|
published.length = 0;
|
|
bridge.emitClientEvent({ type: "system_event", event: "session_failed" });
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(response.statusCode).toBe(204);
|
|
expect(failed).toBe(0);
|
|
expect(published).toEqual([]);
|
|
});
|
|
|
|
test("an unexpectedly exited runtime publishes its terminal sequence then releases its binding", async () => {
|
|
let listener: ((event: any) => void) | undefined;
|
|
const bridge = {
|
|
onClientEvent: (next: (event: any) => void) => { listener = next; },
|
|
emitClientEvent: (event: any) => listener?.(event),
|
|
};
|
|
const runtime = { bridge };
|
|
let current: any;
|
|
let failed = 0;
|
|
const published: any[] = [];
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: {
|
|
get: () => current,
|
|
createFor: () => { current = runtime; return runtime; },
|
|
configure: async () => {},
|
|
start: () => {},
|
|
teardown: () => { current = undefined; },
|
|
} as any,
|
|
hub: {
|
|
publish: (_id: string, event: string, data: object) => {
|
|
published.push({ event, data });
|
|
return published.length;
|
|
},
|
|
} as any,
|
|
thtRunner: {
|
|
sessionNew: async () => ({ id: "s1" }),
|
|
searchPack: async () => {},
|
|
failSession: async () => { failed += 1; },
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({ workspace: "local" }) as any,
|
|
});
|
|
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
published.length = 0;
|
|
|
|
// PiProcessManager deletes an unexpectedly exited runtime before emitting this sequence.
|
|
current = undefined;
|
|
bridge.emitClientEvent({ type: "info", level: "error", text: "public failure" });
|
|
bridge.emitClientEvent({ type: "system_event", event: "session_failed" });
|
|
bridge.emitClientEvent({ type: "system_event", event: "agent_end" });
|
|
bridge.emitClientEvent({ type: "text_delta", text: "too late" });
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(failed).toBe(1);
|
|
expect(published).toEqual([
|
|
{ event: "info", data: { type: "info", level: "error", text: "public failure" } },
|
|
{ event: "system_event", data: { type: "system_event", event: "session_failed" } },
|
|
{ event: "system_event", data: { type: "system_event", event: "agent_end" } },
|
|
]);
|
|
});
|
|
|
|
test("agent_end releases the Pi runtime after the session was finalized", async () => {
|
|
let listener: ((event: any) => void) | undefined;
|
|
const bridge = {
|
|
onClientEvent: (next: (event: any) => void) => { listener = next; },
|
|
emitClientEvent: (event: any) => listener?.(event),
|
|
};
|
|
const runtime = { bridge };
|
|
let current: any;
|
|
let teardownCalls = 0;
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: {
|
|
get: () => current,
|
|
createFor: () => { current = runtime; return runtime; },
|
|
configure: async () => {},
|
|
start: () => {},
|
|
teardownIfCurrent: (_id: string, expected: any) => {
|
|
if (current !== expected) return false;
|
|
teardownCalls += 1;
|
|
current = undefined;
|
|
return true;
|
|
},
|
|
} as any,
|
|
thtRunner: {
|
|
sessionNew: async () => ({ id: "s1" }),
|
|
sessionShow: async () => ({ status: "finalized" }),
|
|
searchPack: async () => {},
|
|
failSession: async () => {},
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({ workspace: "local" }) as any,
|
|
});
|
|
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
bridge.emitClientEvent({ type: "system_event", event: "agent_end" });
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(teardownCalls).toBe(1);
|
|
expect(current).toBeUndefined();
|
|
});
|
|
|
|
test("POST resume tears down a created runtime when bridge binding fails", async () => {
|
|
const delivered: string[] = [];
|
|
const actualHub = new SseHub();
|
|
let clearCalls = 0;
|
|
let teardownCalls = 0;
|
|
let current: any = { bridge: { turnState: () => "failed" } };
|
|
const hub = {
|
|
subscribe: actualHub.subscribe.bind(actualHub),
|
|
publish: actualHub.publish.bind(actualHub),
|
|
clear: (id: string) => { clearCalls += 1; actualHub.clear(id); },
|
|
} as any;
|
|
hub.subscribe("s1", (_event: string, data: any) => delivered.push(data.text));
|
|
hub.publish("s1", "info", { text: "before" });
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: {
|
|
get: () => current,
|
|
teardownIfCurrent: (_id: string, expected: any) => {
|
|
if (current !== expected) return false;
|
|
teardownCalls += 1;
|
|
current = undefined;
|
|
return true;
|
|
},
|
|
createFor: () => {
|
|
current = {
|
|
bridge: {
|
|
onClientEvent: () => { throw new Error("BIND_SENTINEL"); },
|
|
},
|
|
};
|
|
return current;
|
|
},
|
|
} as any,
|
|
hub,
|
|
thtRunner: {
|
|
sessionShow: async () => ({ status: "open", archived: false }),
|
|
reopenSession: async () => {},
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({ workspace: "local" }) as any,
|
|
});
|
|
|
|
const response = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
|
hub.publish("s1", "info", { text: "post-failure probe" });
|
|
|
|
expect(response.statusCode).toBe(503);
|
|
expect(response.body).not.toContain("BIND_SENTINEL");
|
|
expect(teardownCalls).toBe(2);
|
|
expect(current).toBeUndefined();
|
|
expect(clearCalls).toBe(0);
|
|
expect(delivered).toEqual(["before", "post-failure probe"]);
|
|
});
|
|
|
|
test("POST /sessions/:id/response senza gate pendente risponde 409 (risposta stantia)", async () => {
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
ollamaEnsure: async () => ({ ok: true }),
|
|
searchPack: async () => {},
|
|
sessionNew: async () => ({ id: "s1" }),
|
|
sessionList: async () => [],
|
|
} as any,
|
|
getSettings: () => ({ workspace: "w" }),
|
|
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
|
|
});
|
|
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
|
|
// The fake Pi never emitted a ui_request: the bridge has no pending descriptor, so a
|
|
// response (stale UI, double submit) must be rejected instead of forwarded to Pi.
|
|
const res = await app.inject({ method: "POST", url: "/sessions/s1/response",
|
|
payload: { ui_response: { id: "u1", choices: ["a"] } } });
|
|
expect(res.statusCode).toBe(409);
|
|
});
|
|
|
|
test("POST /sessions/:id/rename calls setName", async () => {
|
|
let arg: any;
|
|
const app = mutApp({ setName: async (id: string, name: string) => { arg = { id, name }; } });
|
|
const res = await app.inject({ method: "POST", url: "/sessions/s1/rename", payload: { name: "N" } });
|
|
expect(res.statusCode).toBe(204);
|
|
expect(arg).toEqual({ id: "s1", name: "N" });
|
|
});
|
|
|
|
test("rename authorizes and mutates through the same registry snapshot", async () => {
|
|
const workspaces: string[] = [];
|
|
const tenantPath = "/registry/snapshots/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/tenant-a.yaml";
|
|
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
withPrincipal: () => ({
|
|
sessionShow: async (_id: string, workspace: string) => { workspaces.push(`show:${workspace}`); return { id: "s1" }; },
|
|
setName: async (_id: string, _name: string, workspace: string) => { workspaces.push(`set:${workspace}`); },
|
|
}),
|
|
} as any,
|
|
workspaceRegistry: {
|
|
list: async () => [{
|
|
id: "tenant-a", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: tenantPath,
|
|
}],
|
|
} as any,
|
|
});
|
|
const res = await app.inject({
|
|
method: "POST", url: "/sessions/s1/rename", payload: { name: "N" },
|
|
headers: {
|
|
"x-thoth-principal-issuer": "portal", "x-thoth-principal-subject": "42", "x-thoth-is-admin": "false",
|
|
},
|
|
});
|
|
expect(res.statusCode).toBe(204);
|
|
expect(workspaces).toEqual([`show:${tenantPath}`, `set:${tenantPath}`]);
|
|
});
|
|
|
|
test("POST /sessions/:id/group calls setGroup", async () => {
|
|
let arg: any;
|
|
const app = mutApp({ setGroup: async (id: string, group: string) => { arg = { id, group }; } });
|
|
const res = await app.inject({ method: "POST", url: "/sessions/s1/group", payload: { group: "G" } });
|
|
expect(res.statusCode).toBe(204);
|
|
expect(arg).toEqual({ id: "s1", group: "G" });
|
|
});
|
|
|
|
test("POST archive / unarchive call the runner", async () => {
|
|
const seen: string[] = [];
|
|
const app = mutApp({
|
|
archive: async (id: string) => { seen.push(`a:${id}`); },
|
|
unarchive: async (id: string) => { seen.push(`u:${id}`); },
|
|
});
|
|
expect((await app.inject({ method: "POST", url: "/sessions/s1/archive" })).statusCode).toBe(204);
|
|
expect((await app.inject({ method: "POST", url: "/sessions/s1/unarchive" })).statusCode).toBe(204);
|
|
expect(seen).toEqual(["a:s1", "u:s1"]);
|
|
});
|
|
|
|
test("DELETE /sessions/:id calls deleteSession", async () => {
|
|
let deleted: string | null = null;
|
|
const app = mutApp({ deleteSession: async (id: string) => { deleted = id; } });
|
|
const res = await app.inject({ method: "DELETE", url: "/sessions/s1" });
|
|
expect(res.statusCode).toBe(204);
|
|
expect(deleted).toBe("s1");
|
|
});
|
|
|
|
test("DELETE /sessions/:id tears down the runtime before deleting on disk", async () => {
|
|
const order: string[] = [];
|
|
const runtime = {} as any;
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: { deleteSession: async (id: string) => { order.push(`del:${id}`); } } as any,
|
|
mgr: {
|
|
get: () => runtime,
|
|
teardownIfCurrent: (id: string, expected: any) => {
|
|
expect(expected).toBe(runtime);
|
|
order.push(`teardown:${id}`);
|
|
return true;
|
|
},
|
|
} as any,
|
|
hub: { forget: (id: string) => { order.push(`forget:${id}`); } } as any,
|
|
getSettings: () => ({ workspace: "w" }) as any,
|
|
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
|
|
});
|
|
const res = await app.inject({ method: "DELETE", url: "/sessions/s1" });
|
|
expect(res.statusCode).toBe(204);
|
|
expect(order).toEqual(["teardown:s1", "del:s1", "forget:s1"]);
|
|
});
|
|
|
|
test("POST /sessions/:id/close clears transient hub state without forgetting its id", async () => {
|
|
const calls: string[] = [];
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: { closeSession: async () => {} } as any,
|
|
mgr: { get: () => undefined } as any,
|
|
hub: {
|
|
clear: (id: string) => { calls.push(`clear:${id}`); },
|
|
forget: (id: string) => { calls.push(`forget:${id}`); },
|
|
} as any,
|
|
getSettings: () => ({ workspace: "w" }) as any,
|
|
});
|
|
|
|
const response = await app.inject({ method: "POST", url: "/sessions/s1/close" });
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(calls).toEqual(["clear:s1"]);
|
|
});
|
|
|
|
test("GET /sessions/:id/documents returns the runner output", async () => {
|
|
const app = mutApp({ documents: async () => [{ phase: "—", key: "question", title: "t", format: "text", content: "q" }] });
|
|
const res = await app.inject({ method: "GET", url: "/sessions/s1/documents" });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json()[0].key).toBe("question");
|
|
});
|
|
|
|
test("POST resume on a finalized session is refused with 409", async () => {
|
|
const app = mutApp({ sessionShow: async () => ({ status: "finalized", archived: false }) });
|
|
const res = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
|
expect(res.statusCode).toBe(409);
|
|
});
|
|
|
|
test("POST resume on an archived session is refused with 409", async () => {
|
|
const app = mutApp({ sessionShow: async () => ({ status: "open", archived: true }) });
|
|
const res = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
|
expect(res.statusCode).toBe(409);
|
|
});
|
|
|
|
test("POST /sessions readiness failure returns one fixed public message without raw diagnostics", async () => {
|
|
let createdCalled = false;
|
|
const rawFailure =
|
|
"connect https://secret.invalid/ready?token=DO_NOT_LEAK using /srv/private/model-key";
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
ollamaEnsure: async () => ({ ok: false, stage: "model", error: rawFailure }),
|
|
searchPack: async () => {},
|
|
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: { interactionLanguage: "en", question: "q" } });
|
|
expect(res.statusCode).toBe(503);
|
|
expect(res.json()).toEqual({
|
|
error: "Session services are not ready. Check configuration and connectivity, then try again.",
|
|
code: "workspace_not_activatable",
|
|
});
|
|
expect(res.body).not.toMatch(/secret\.invalid|DO_NOT_LEAK|\/srv\/private\/model-key/);
|
|
expect(createdCalled).toBe(false);
|
|
});
|
|
|
|
test.each(["semantic_index_incompatible", "workspace_not_activatable"] as const)(
|
|
"POST /sessions does not persist when Qdrant readiness returns %s",
|
|
async (code) => {
|
|
const sessionNew = vi.fn(async () => ({ id: "must-not-exist" }));
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: { sessionNew } as any,
|
|
readiness: { ensure: async () => ({ ok: false, code }) } as any,
|
|
getSettings: () => ({ workspace: "psd" }) as any,
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(503);
|
|
expect(response.json()).toEqual({
|
|
error: "Session services are not ready. Check configuration and connectivity, then try again.",
|
|
code,
|
|
});
|
|
expect(sessionNew).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
test("POST /sessions returns storage 503 before creating a Pi runtime when session persistence fails", async () => {
|
|
let piCreated = false;
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
ollamaEnsure: async () => ({ ok: true }),
|
|
sessionNew: async () => { throw new Error("database unavailable"); },
|
|
} as any,
|
|
getSettings: () => ({ workspace: "psd" }) as any,
|
|
mgr: { createFor: () => { piCreated = true; throw new Error("must not spawn"); } } as any,
|
|
});
|
|
|
|
const response = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
|
|
|
|
expect(response.statusCode).toBe(503);
|
|
expect(response.json()).toEqual({ error: "session storage is unavailable" });
|
|
expect(piCreated).toBe(false);
|
|
});
|
|
|
|
test("POST /sessions proceeds when ollamaEnsure succeeds", async () => {
|
|
let ensureWs: string | undefined;
|
|
const qdrantEnsure = vi.fn(async () => ({ ok: true }));
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
qdrantEnsure,
|
|
ollamaEnsure: async (ws: string) => { ensureWs = ws; return { ok: true }; },
|
|
searchPack: async () => {},
|
|
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: { interactionLanguage: "en", question: "q" } });
|
|
expect(res.json()).toEqual({ id: "s1" });
|
|
expect(qdrantEnsure).toHaveBeenCalledWith(operationalWorkspace("psd"), 60, "self_heal");
|
|
expect(ensureWs).toContain(`/snapshots/${"e".repeat(40)}/psd.yaml`);
|
|
});
|
|
|
|
test("POST /sessions rejects a stale requested model without silently using the default", async () => {
|
|
let created = 0;
|
|
let persisted: any;
|
|
const runtime = { bridge: { onClientEvent: () => {} } };
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
sessionNew: async (options: any) => { created += 1; persisted = options; return { id: "fallback" }; },
|
|
searchPack: async () => {},
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({ workspace: "psd", thinking: "medium" }) as any,
|
|
runtimeModelCatalog: sessionCatalog(),
|
|
listModels: async () => [
|
|
{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true },
|
|
],
|
|
mgr: {
|
|
teardownForPrincipal: () => [],
|
|
createFor: () => runtime,
|
|
get: () => runtime,
|
|
configure: async () => {},
|
|
start: () => {},
|
|
} as any,
|
|
});
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/sessions",
|
|
payload: { interactionLanguage: "en", question: "q", provider: "deepseek", model: "deepseek-v4-pro" },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(503);
|
|
expect(res.json()).toMatchObject({ code: "model_unavailable" });
|
|
expect(persisted).toBeUndefined();
|
|
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).toContain(`/snapshots/${"e".repeat(40)}/psd.yaml`);
|
|
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: { interactionLanguage: "en", 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.each([
|
|
[
|
|
"new",
|
|
"auth.json",
|
|
'{"unrelated":{"credential":{"nested":["!post-session-auth-command route-secret /private/route-auth"]}}}\n',
|
|
"Session startup failed. Check configuration and connectivity, then Resume the session.",
|
|
],
|
|
[
|
|
"resume",
|
|
"models.json",
|
|
'{"providers":{"local-qwen":{"models":[{"id":"qwen3.6-35b-a3b","headers":{"x":"!post-session-model-command route-secret /private/route-model"}}]}}}\n',
|
|
"Session could not be resumed. Check configuration and connectivity, then try again.",
|
|
],
|
|
] as const)(
|
|
"POST %s refuses executable %s changed after session persistence without spawning or leaking",
|
|
async (flow, changedFile, unsafeRaw, publicMessage) => {
|
|
const agentDir = mkdtempSync(path.join(tmpdir(), "tht-route-runtime-config-"));
|
|
const safeAuth = '{"deepseek":{"type":"api_key","key":"safe-token"}}\n';
|
|
const safeModels = '{"providers":{"local-qwen":{"baseUrl":"http://model.invalid/v1","models":[{"id":"qwen3.6-35b-a3b"}]}}}\n';
|
|
writeFileSync(path.join(agentDir, "auth.json"), safeAuth, { mode: 0o600 });
|
|
writeFileSync(path.join(agentDir, "models.json"), safeModels, { mode: 0o600 });
|
|
vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
|
|
const cfg = loadConfig({ THT_HARNESS_DIR: "../harness" });
|
|
let authResolutions = 0;
|
|
let spawns = 0;
|
|
const mgr = new PiProcessManager(cfg, {
|
|
authProviders: () => { authResolutions += 1; return new Set(); },
|
|
spawnFn: () => { spawns += 1; throw new Error("ROUTE_SPAWN_BOUNDARY_REACHED"); },
|
|
});
|
|
const hub = new SseHub();
|
|
const events: Array<{ event: string; data: object }> = [];
|
|
const sessionId = `post-persistence-${flow}`;
|
|
hub.subscribe(sessionId, (event, data) => events.push({ event, data }));
|
|
const failSession = vi.fn(async () => {});
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
const validateEarlierState = () => {
|
|
validateDeclarativePiConfig(readFileSync(path.join(agentDir, "auth.json"), "utf8"));
|
|
validateDeclarativePiConfig(readFileSync(path.join(agentDir, "models.json"), "utf8"));
|
|
};
|
|
|
|
if (flow === "resume") {
|
|
// This session was admitted and persisted while both mounted files were safe.
|
|
validateEarlierState();
|
|
writeFileSync(path.join(agentDir, changedFile), unsafeRaw, { mode: 0o600 });
|
|
}
|
|
|
|
const app = buildApp(cfg, {
|
|
mgr,
|
|
hub,
|
|
thtRunner: {
|
|
sessionNew: async () => {
|
|
// Model admission completed immediately above this persistence boundary.
|
|
writeFileSync(path.join(agentDir, changedFile), unsafeRaw, { mode: 0o600 });
|
|
return { id: sessionId };
|
|
},
|
|
failSession,
|
|
searchPack: async () => {},
|
|
sessionShow: async () => ({
|
|
id: sessionId,
|
|
status: "open",
|
|
archived: false,
|
|
provider: "local-qwen",
|
|
model: "qwen3.6-35b-a3b",
|
|
thinking: "low",
|
|
}),
|
|
reopenSession: async () => {},
|
|
} as any,
|
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
|
getSettings: () => ({
|
|
workspace: "w",
|
|
provider: "local-qwen",
|
|
model: "qwen3.6-35b-a3b",
|
|
thinking: "low",
|
|
}) as any,
|
|
listModels: async () => {
|
|
validateEarlierState();
|
|
return [{
|
|
provider: "local-qwen", id: "qwen3.6-35b-a3b", name: "Qwen", reasoning: true,
|
|
}];
|
|
},
|
|
});
|
|
|
|
try {
|
|
const response = flow === "new"
|
|
? await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } })
|
|
: await app.inject({ method: "POST", url: `/sessions/${sessionId}/resume` });
|
|
const logs = consoleError.mock.calls.flat().map(String).join(" ");
|
|
|
|
expect(response.statusCode).toBe(503);
|
|
expect(response.json()).toEqual({ error: publicMessage });
|
|
expect({ authResolutions, spawns, runtimes: mgr.count() }).toEqual({
|
|
authResolutions: 0, spawns: 0, runtimes: 0,
|
|
});
|
|
expect(failSession).toHaveBeenCalledTimes(flow === "new" ? 1 : 0);
|
|
expect(events).toEqual([]);
|
|
expect(`${response.body}\n${logs}`).not.toContain(unsafeRaw.trim());
|
|
expect(`${response.body}\n${logs}`).not.toMatch(/route-secret|post-session-(?:auth|model)-command|\/private\/route-|tht-route-runtime-config/);
|
|
} finally {
|
|
await app.close();
|
|
consoleError.mockRestore();
|
|
vi.unstubAllEnvs();
|
|
rmSync(agentDir, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
test("POST /sessions/:id/resume returns 409 for a read-only session without calling ollamaEnsure", async () => {
|
|
let ensureCalled = false;
|
|
const app = mutApp({
|
|
sessionShow: async () => ({ status: "finalized", archived: false }),
|
|
ollamaEnsure: async () => { ensureCalled = true; return { ok: false, error: "down" }; },
|
|
});
|
|
const res = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
|
expect(res.statusCode).toBe(409);
|
|
expect(ensureCalled).toBe(false);
|
|
});
|
|
|
|
test("POST /sessions/:id/resume readiness failure returns the same fixed public message", async () => {
|
|
const rawFailure =
|
|
"stderr https://secret.invalid/resume?api_key=DO_NOT_LEAK /srv/private/resume-key";
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
thtRunner: {
|
|
ollamaEnsure: async () => ({ ok: false, error: rawFailure }),
|
|
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);
|
|
expect(res.json()).toEqual({
|
|
error: "Session services are not ready. Check configuration and connectivity, then try again.",
|
|
code: "workspace_not_activatable",
|
|
});
|
|
expect(res.body).not.toMatch(/secret\.invalid|DO_NOT_LEAK|\/srv\/private\/resume-key/);
|
|
});
|
|
|
|
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;
|
|
let current: any;
|
|
const mgr = {
|
|
get: () => current,
|
|
createFor: () => { current = runtime; return runtime; },
|
|
configure: async () => {},
|
|
start: () => {
|
|
expect(bridgeAttached).toBe(true);
|
|
started = true;
|
|
},
|
|
teardownIfCurrent: (_id: string, expected: any) => {
|
|
if (current !== expected) return false;
|
|
current = undefined;
|
|
return true;
|
|
},
|
|
} 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: { interactionLanguage: "en", 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);
|
|
});
|
|
|
|
test("POST /sessions bootstrap failure emits only a fixed recovery message", async () => {
|
|
const published: Array<{ event: string; data: any }> = [];
|
|
let listener: ((event: any) => void) | undefined;
|
|
const bridge = {
|
|
onClientEvent: (callback: (event: any) => void) => { listener = callback; },
|
|
emitClientEvent: (event: any) => listener?.(event),
|
|
};
|
|
const runtime = { bridge } as any;
|
|
const rawFailure =
|
|
"connect https://secret.invalid/bootstrap?token=DO_NOT_LEAK using /srv/private/model-key";
|
|
let current: any;
|
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
|
mgr: {
|
|
get: () => current,
|
|
createFor: () => { current = runtime; return runtime; },
|
|
configure: async () => { throw new Error(rawFailure); },
|
|
start: () => {},
|
|
teardownIfCurrent: (_id: string, expected: any) => {
|
|
if (current !== expected) return false;
|
|
current = undefined;
|
|
return true;
|
|
},
|
|
} as any,
|
|
hub: {
|
|
publish: (_id: string, event: string, data: any) => published.push({ event, data }),
|
|
} as any,
|
|
thtRunner: {
|
|
ollamaEnsure: async () => ({ ok: true }),
|
|
sessionNew: async () => ({ id: "s-bootstrap" }),
|
|
searchPack: async () => {},
|
|
failSession: async () => {},
|
|
} as any,
|
|
getSettings: () => ({ workspace: "psd" }) as any,
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/sessions",
|
|
payload: { interactionLanguage: "en", question: "q" },
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(response.json()).toEqual({ id: "s-bootstrap" });
|
|
expect(published.map(({ data }) => data)).toContainEqual({
|
|
type: "info",
|
|
level: "error",
|
|
text: "Session startup failed. Check configuration and connectivity, then Resume the session.",
|
|
});
|
|
const clientOutput = JSON.stringify(published);
|
|
expect(clientOutput).not.toContain("secret.invalid");
|
|
expect(clientOutput).not.toContain("DO_NOT_LEAK");
|
|
expect(clientOutput).not.toContain("/srv/private/model-key");
|
|
});
|
|
|
|
|
|
test.each([
|
|
{ mode: "missing signed Evidence file", evidence: true, binding: "missing", expectedStatus: 409, reachesReadiness: false },
|
|
{ mode: "unsafe signed Evidence file", evidence: true, binding: "unsafe", expectedStatus: 409, reachesReadiness: false },
|
|
{ mode: "safe signed Evidence file", evidence: true, binding: "safe", expectedStatus: 503, reachesReadiness: true },
|
|
{ mode: "no Evidence descriptor", evidence: false, binding: "missing", expectedStatus: 503, reachesReadiness: true },
|
|
])("real buildApp admission handles $mode before Pi spawn", async ({
|
|
evidence, binding, expectedStatus, reachesReadiness,
|
|
}) => {
|
|
const root = mkdtempSync(path.join(tmpdir(), "thoth-evidence-admission-"));
|
|
const unsafeRoot = mkdtempSync(path.join(tmpdir(), "thoth-evidence-unsafe-"));
|
|
const signedFile = path.join(root, "signed-urls.json");
|
|
const unsafeFile = path.join(unsafeRoot, "signed-urls.json");
|
|
writeFileSync(signedFile, '["CANARY-SIGNED-QUERY"]');
|
|
writeFileSync(unsafeFile, '["CANARY-UNSAFE-SIGNED-QUERY"]');
|
|
const variable = "THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE";
|
|
const previous = {
|
|
transport: process.env.THT_WS_PSD_CLINICAL_DWH_TRANSPORT,
|
|
baseUrl: process.env.THT_WS_PSD_CLINICAL_DWH_BASE_URL,
|
|
signed: process.env[variable],
|
|
};
|
|
process.env.THT_WS_PSD_CLINICAL_DWH_TRANSPORT = "rest_api";
|
|
process.env.THT_WS_PSD_CLINICAL_DWH_BASE_URL = "https://dwh.example.test";
|
|
if (binding === "safe") process.env[variable] = signedFile;
|
|
else if (binding === "unsafe") process.env[variable] = unsafeFile;
|
|
else delete process.env[variable];
|
|
|
|
const descriptor = {
|
|
...operationalWorkspace("psd-clinical"),
|
|
dwh: {
|
|
...operationalWorkspace("psd-clinical").dwh,
|
|
supported_transports: ["rest_api"],
|
|
},
|
|
diagnostics: {
|
|
dwh_rest: {
|
|
method: "GET", path: "/health", auth: "none",
|
|
response: { database: "database", schema: "schema" },
|
|
},
|
|
},
|
|
...(evidence ? {
|
|
evidence: {
|
|
source: {
|
|
type: "http",
|
|
uris: ["https://evidence.example.test/guide.md"],
|
|
authentication: "signed_urls_file",
|
|
connect_timeout_ms: 5_000,
|
|
read_timeout_ms: 30_000,
|
|
max_bytes: 10 * 1024 * 1024,
|
|
max_redirects: 5,
|
|
allow_private_hosts: false,
|
|
max_cache_bytes: 64 * 1024 * 1024,
|
|
},
|
|
policy: { max_chunk_chars: 4_000, retain_published_generations: 3 },
|
|
},
|
|
} : {}),
|
|
} as any;
|
|
const canonicalBefore = JSON.stringify(descriptor);
|
|
const ensure = vi.fn(async () => ({ ok: false, code: "workspace_not_activatable" as const }));
|
|
const createFor = vi.fn();
|
|
const abort = vi.fn(async () => {});
|
|
const revision = {
|
|
id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40),
|
|
snapshotPath: `/data/workspace-registry/snapshots/${"a".repeat(40)}/psd-clinical.yaml`,
|
|
};
|
|
|
|
try {
|
|
const app = buildRealApp(loadConfig({
|
|
THT_HARNESS_DIR: "../harness",
|
|
THT_WORKSPACE_SECRET_ROOTS: root,
|
|
}), {
|
|
thtRunner: { sessionNew: vi.fn(), searchPack: async () => {} } as any,
|
|
readiness: { ensure } as any,
|
|
mgr: { get: () => undefined, createFor } as any,
|
|
getSettings: () => ({ workspace: "psd-clinical" }) as any,
|
|
workspaceRegistry: {
|
|
acquireSessionRevision: vi.fn(async () => ({
|
|
workspace: descriptor, revision, abort, markPersisted: vi.fn(async () => {}),
|
|
})),
|
|
} as any,
|
|
});
|
|
const response = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
|
|
|
|
expect(response.statusCode).toBe(expectedStatus);
|
|
expect(ensure).toHaveBeenCalledTimes(reachesReadiness ? 1 : 0);
|
|
expect(createFor).not.toHaveBeenCalled();
|
|
expect(JSON.stringify(descriptor)).toBe(canonicalBefore);
|
|
expect(revision.commit).toBe("a".repeat(40));
|
|
expect(response.body).not.toContain("CANARY-SIGNED-QUERY");
|
|
expect(response.body).not.toContain("CANARY-UNSAFE-SIGNED-QUERY");
|
|
} finally {
|
|
const restore = (name: string, value: string | undefined) => {
|
|
if (value === undefined) delete process.env[name];
|
|
else process.env[name] = value;
|
|
};
|
|
restore("THT_WS_PSD_CLINICAL_DWH_TRANSPORT", previous.transport);
|
|
restore("THT_WS_PSD_CLINICAL_DWH_BASE_URL", previous.baseUrl);
|
|
restore(variable, previous.signed);
|
|
rmSync(root, { recursive: true, force: true });
|
|
rmSync(unsafeRoot, { recursive: true, force: true });
|
|
}
|
|
});
|