feat: pin sessions to workspace revisions
This commit is contained in:
@@ -207,29 +207,34 @@ export function sessionRoutes(
|
||||
let s: Settings;
|
||||
try { s = await d.getSettings(principal); } catch { return storageFailure(reply); }
|
||||
const runner = runnerFor(principal);
|
||||
let workspaceConfigPath = s.workspace;
|
||||
const requestedWorkspaceId = b.workspaceId ?? s.workspace;
|
||||
if (!requestedWorkspaceId) {
|
||||
return reply.code(409).send({
|
||||
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
|
||||
code: "workspace_revision_unavailable",
|
||||
});
|
||||
}
|
||||
let workspaceConfigPath: string | undefined;
|
||||
let workspaceId: string | undefined;
|
||||
let workspaceRevision: string | undefined;
|
||||
let allowedModels: readonly string[] | undefined;
|
||||
if (b.workspaceId) {
|
||||
try {
|
||||
const resolved = await d.workspaceRegistry.read(b.workspaceId);
|
||||
if (resolved.revision.state !== "operational") {
|
||||
return reply.code(409).send({
|
||||
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
|
||||
code: "workspace_revision_unavailable",
|
||||
});
|
||||
}
|
||||
workspaceConfigPath = resolved.revision.snapshotPath;
|
||||
workspaceId = resolved.revision.id;
|
||||
workspaceRevision = resolved.revision.commit;
|
||||
allowedModels = resolved.workspace.llm_policy.allowed;
|
||||
} catch {
|
||||
try {
|
||||
const resolved = await d.workspaceRegistry.read(requestedWorkspaceId);
|
||||
if (resolved.revision.state !== "operational") {
|
||||
return reply.code(409).send({
|
||||
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
|
||||
code: "workspace_revision_unavailable",
|
||||
});
|
||||
}
|
||||
workspaceConfigPath = resolved.revision.snapshotPath;
|
||||
workspaceId = resolved.revision.id;
|
||||
workspaceRevision = resolved.revision.commit;
|
||||
allowedModels = resolved.workspace.llm_policy.allowed;
|
||||
} catch {
|
||||
return reply.code(409).send({
|
||||
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
|
||||
code: "workspace_revision_unavailable",
|
||||
});
|
||||
}
|
||||
const provider = b.provider ?? s.provider;
|
||||
const model = b.model ?? s.model;
|
||||
@@ -378,6 +383,11 @@ export function sessionRoutes(
|
||||
} catch { return storageFailure(reply); }
|
||||
if (!manifest) return reply.code(404).send({ error: "session not found" });
|
||||
const runner = runnerFor(principal);
|
||||
// Read-only contract FIRST: finalized or archived sessions never attempt compatibility
|
||||
// resolution, even when their historical snapshot was subsequently pruned.
|
||||
if (manifest?.status === "finalized" || manifest?.archived) {
|
||||
return reply.code(409).send({ error: "sessione in sola lettura (finalizzata o archiviata)" });
|
||||
}
|
||||
const saved = manifest as {
|
||||
provider?: string; model?: string; thinking?: string;
|
||||
workspace_id?: string; workspace_revision?: string;
|
||||
@@ -397,11 +407,6 @@ export function sessionRoutes(
|
||||
});
|
||||
}
|
||||
}
|
||||
// Read-only contract FIRST: a finalized/archived session must refuse resume even
|
||||
// when a lingering runtime still looks active — the manifest is the truth.
|
||||
if (manifest?.status === "finalized" || manifest?.archived) {
|
||||
return reply.code(409).send({ error: "sessione in sola lettura (finalizzata o archiviata)" });
|
||||
}
|
||||
// This check belongs inside the per-session lock: a preceding cold Resume may have
|
||||
// installed a running runtime while this request was waiting.
|
||||
const existing = d.mgr.get(id);
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { PrincipalContext } from "../auth/principal.js";
|
||||
export function effectiveSettings(cfg: AppConfig, stored: Settings): Settings {
|
||||
const workspaces = listWorkspaces(cfg.harnessDir);
|
||||
return {
|
||||
workspace: workspaces[0]?.name ?? stored.workspace,
|
||||
workspace: stored.workspace ?? workspaces[0]?.name,
|
||||
provider: cfg.defaults.provider ?? stored.provider,
|
||||
model: cfg.defaults.model ?? stored.model,
|
||||
thinking: cfg.defaults.thinking ?? stored.thinking,
|
||||
|
||||
@@ -3,13 +3,31 @@ import { spawn as nodeSpawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { chmodSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { buildApp as buildRealApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { SseHub } from "../src/sse/sse-hub.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");
|
||||
|
||||
const defaultWorkspaceRegistry = {
|
||||
read: vi.fn(async (id: string) => ({
|
||||
workspace: {
|
||||
llm_policy: {
|
||||
allowed: ["zai/glm-5.2", "deepseek/deepseek-v4-pro", "local-qwen/qwen3.6-35b-a3b"],
|
||||
},
|
||||
},
|
||||
revision: {
|
||||
id, commit: "e".repeat(40), blob: "f".repeat(40),
|
||||
snapshotPath: `/data/workspace-registry/snapshots/${"e".repeat(40)}/${id}.yaml`, state: "operational",
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
function buildApp(config: Parameters<typeof buildRealApp>[0], deps: Record<string, unknown> = {}) {
|
||||
return buildRealApp(config, { workspaceRegistry: defaultWorkspaceRegistry as any, ...deps } as any);
|
||||
}
|
||||
|
||||
function mutApp(thtRunner: any) {
|
||||
return buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||
thtRunner: { ollamaEnsure: async () => ({ ok: true }), ...thtRunner },
|
||||
@@ -186,6 +204,35 @@ test("creates a session from the active immutable workspace revision", async ()
|
||||
}));
|
||||
});
|
||||
|
||||
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: { llm_policy: { allowed: ["zai/glm-5.2"] } },
|
||||
revision: {
|
||||
id, commit: "c".repeat(40), blob: "d".repeat(40),
|
||||
snapshotPath: `/data/workspace-registry/snapshots/${"c".repeat(40)}/${id}.yaml`, state: "operational",
|
||||
},
|
||||
})),
|
||||
};
|
||||
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: { 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("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+avvia", async () => {
|
||||
const modelKey = path.join(os.tmpdir(), `thoth-model-key-${process.pid}`);
|
||||
writeFileSync(modelKey, "test-model-key", { mode: 0o600 });
|
||||
@@ -209,7 +256,7 @@ test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+a
|
||||
});
|
||||
const created = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
|
||||
expect(created.json()).toEqual({ id: "s1" });
|
||||
expect(sessionNewArg.workspaceConfigPath).toBe("w");
|
||||
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");
|
||||
@@ -465,6 +512,25 @@ test("POST /sessions/:id/resume returns a sanitized error when its retained revi
|
||||
expect(response.json()).toMatchObject({ code: "workspace_revision_unavailable" });
|
||||
});
|
||||
|
||||
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: {
|
||||
@@ -1801,7 +1867,7 @@ test("POST /sessions proceeds when ollamaEnsure succeeds", async () => {
|
||||
});
|
||||
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
|
||||
expect(res.json()).toEqual({ id: "s1" });
|
||||
expect(ensureWs).toBe("psd");
|
||||
expect(ensureWs).toContain(`/snapshots/${"e".repeat(40)}/psd.yaml`);
|
||||
});
|
||||
|
||||
test("POST /sessions rejects an unavailable saved model before persisting a session", async () => {
|
||||
@@ -1842,7 +1908,7 @@ test("POST /sessions marks a persisted session failed when runtime construction
|
||||
sessionNew: async () => ({ id: "s-runtime-failure" }),
|
||||
failSession: async (id: string, workspace: string) => {
|
||||
expect(id).toBe("s-runtime-failure");
|
||||
expect(workspace).toBe("psd");
|
||||
expect(workspace).toContain(`/snapshots/${"e".repeat(40)}/psd.yaml`);
|
||||
failed += 1;
|
||||
},
|
||||
} as any,
|
||||
|
||||
@@ -31,6 +31,19 @@ test("GET /settings returns effective defaults (env provider/model/thinking, fir
|
||||
}
|
||||
});
|
||||
|
||||
test("GET /settings uses the stored installation workspace default before the harness fallback", async () => {
|
||||
const { app, dir } = appWithTmpSettings({});
|
||||
try {
|
||||
writeFileSync(join(dir, "settings.json"), JSON.stringify({ workspace: "psd-clinical" }));
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/settings" });
|
||||
|
||||
expect(response.json()).toMatchObject({ workspace: "psd-clinical" });
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("PUT /settings does not persist personal workspace or LLM choices", async () => {
|
||||
const { app, dir } = appWithTmpSettings({ PI_PROVIDER: "zai", PI_MODEL: "glm-5.2", PI_THINKING: "medium" }, {
|
||||
listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }],
|
||||
|
||||
Reference in New Issue
Block a user