From 25b2835e73ac2a14b4f36fd42dcba9667e54b3c4 Mon Sep 17 00:00:00 2001 From: mptyl Date: Thu, 13 Aug 2026 23:24:45 +0200 Subject: [PATCH] Fix workspace policy loading --- frontend/src/api/sessions.test.ts | 41 ++++++++++++++++++-- frontend/src/api/sessions.ts | 35 +++++++++++++---- frontend/src/api/workspaces.test.ts | 19 +++++++++ frontend/src/shell/WorkspaceEditor.test.tsx | 4 +- frontend/src/shell/WorkspaceEditor.tsx | 2 +- frontend/src/shell/WorkspaceManager.test.tsx | 4 +- frontend/src/test/workspace-fixtures.ts | 2 +- frontend/src/workspaces/drafts.ts | 2 +- 8 files changed, 92 insertions(+), 17 deletions(-) diff --git a/frontend/src/api/sessions.test.ts b/frontend/src/api/sessions.test.ts index d7d1df0a..a79e8249 100644 --- a/frontend/src/api/sessions.test.ts +++ b/frontend/src/api/sessions.test.ts @@ -107,9 +107,44 @@ test.each([ expect(posted).toBe(false); }); -test("createSession preserves allowLegacy for a selected session workspace absent from registry summaries", async () => { +test("createSession replaces a stale browser workspace with the current installation default", async () => { localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({ - workspaceId: "retained-session-workspace", provider: "zai", model: "glm-5.2", thinking: "low", + workspaceId: "retired-workspace", provider: "zai", model: "glm-5.2", thinking: "low", + })); + let body: unknown; + server.use( + http.get("/api/settings", () => HttpResponse.json({ workspace: "psd-clinical" })), + http.get("/api/workspaces", () => HttpResponse.json([{ + ...workspaceSummaryFixture("psd-clinical", { + displayName: "PSD Clinical", + revision: workspaceRevisionFixture("psd-clinical"), + }), + }])), + http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ + workspace: canonicalWorkspaceFixture("psd-clinical"), + revision: { + id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", + }, + })), + http.post("/api/sessions", async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ id: "s1" }); + }), + ); + + await expect(createSession({ question: "q" })).resolves.toEqual({ id: "s1" }); + expect(body).toEqual({ + question: "q", workspaceId: "psd-clinical", + provider: "zai", model: "glm-5.2", thinking: "low", + }); + expect(JSON.parse(localStorage.getItem("thothii.workspace-registry.v1.preferences")!)).toEqual({ + workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low", + }); +}); + +test("createSession preserves a local legacy selection when the registry is empty", async () => { + localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({ + workspaceId: "legacy-workspace", provider: "zai", model: "glm-5.2", thinking: "low", })); let body: unknown; server.use( @@ -122,7 +157,7 @@ test("createSession preserves allowLegacy for a selected session workspace absen await expect(createSession({ question: "q" })).resolves.toEqual({ id: "s1" }); expect(body).toEqual({ - question: "q", workspaceId: "retained-session-workspace", + question: "q", workspaceId: "legacy-workspace", provider: "zai", model: "glm-5.2", thinking: "low", }); }); diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index fa490ba6..508f5d94 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -50,23 +50,44 @@ async function ensureWorkspaceSelectionPolicy(): Promise { const workspaceId = preferences.workspaceId; if (!workspaceId) return preferences; workspacePolicyGate.beginSummary(workspaceId); - let workspace; + let workspaces: Awaited>; try { - workspace = (await listWorkspaces()).find((candidate) => candidate.id === workspaceId); + workspaces = await listWorkspaces(); } catch { if (workspacePreferences.load().workspaceId !== workspaceId) continue; workspacePolicyGate.rejectSummary(workspaceId); throw new WorkspaceSelectionError(WORKSPACE_SUMMARY_ERROR); } if (workspacePreferences.load().workspaceId !== workspaceId) continue; - if (workspace && !workspace.revision) { + const workspace = workspaces.find((candidate) => candidate.id === workspaceId); + if (!workspace) { + if (workspaces.length === 0) { + workspacePolicyGate.allowLegacy(workspaceId); + return workspacePreferences.load(); + } + let installationDefault: string | undefined; + try { + installationDefault = (await getSettings()).workspace; + } catch { + if (workspacePreferences.load().workspaceId !== workspaceId) continue; + workspacePolicyGate.rejectSummary(workspaceId); + throw new WorkspaceSelectionError(WORKSPACE_SUMMARY_ERROR); + } + if (workspacePreferences.load().workspaceId !== workspaceId) continue; + const replacement = workspaces.find( + (candidate) => candidate.id === installationDefault && candidate.revision, + ) ?? workspaces.find((candidate) => candidate.revision); + if (!replacement) { + workspacePolicyGate.reject(workspaceId); + throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR); + } + workspacePreferences.save({ ...preferences, workspaceId: replacement.id }); + continue; + } + if (!workspace.revision) { workspacePolicyGate.reject(workspaceId); throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR); } - if (!workspace?.revision) { - workspacePolicyGate.allowLegacy(workspaceId); - return workspacePreferences.load(); - } workspacePolicyGate.select(workspaceId); const outcome = await Promise.race([ getWorkspace(workspaceId).then( diff --git a/frontend/src/api/workspaces.test.ts b/frontend/src/api/workspaces.test.ts index 8cacb0b7..bfe65754 100644 --- a/frontend/src/api/workspaces.test.ts +++ b/frontend/src/api/workspaces.test.ts @@ -121,6 +121,25 @@ test("accepts the atomic schema-v3 workspace revision contract without historica await expect(getWorkspace("psd-clinical")).resolves.toEqual({ workspace, revision }); }); +test("accepts a Qdrant collection using the canonical hyphenated workspace name", async () => { + const hyphenatedCollection = { + ...workspace, + semantic_index: { + ...workspace.semantic_index, + vector_store: { ...workspace.semantic_index.vector_store, collection: "psd-clinical" }, + }, + } satisfies CanonicalWorkspace; + server.use(http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ + workspace: hyphenatedCollection, + revision, + }))); + + await expect(getWorkspace("psd-clinical")).resolves.toEqual({ + workspace: hyphenatedCollection, + revision, + }); +}); + test("sanitizes read and validate responses while preserving directory-based Evidence", async () => { server.use( http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace: evidenceWorkspace, revision })), diff --git a/frontend/src/shell/WorkspaceEditor.test.tsx b/frontend/src/shell/WorkspaceEditor.test.tsx index d72b4fd8..d577dbc5 100644 --- a/frontend/src/shell/WorkspaceEditor.test.tsx +++ b/frontend/src/shell/WorkspaceEditor.test.tsx @@ -67,7 +67,7 @@ test("bootstrap mode locks catalog metadata and saves only the local bootstrap d expect(screen.getByLabelText("Description")).toBeDisabled(); await user.clear(screen.getByLabelText("Vector collection")); - await user.type(screen.getByLabelText("Vector collection"), "research_docs"); + await user.type(screen.getByLabelText("Vector collection"), "research-docs"); await user.click(screen.getByRole("button", { name: "Save draft" })); expect(onSaveDraft).toHaveBeenCalledWith(expect.objectContaining({ @@ -75,7 +75,7 @@ test("bootstrap mode locks catalog metadata and saves only the local bootstrap d workspaceId: "bootstrap-slot", workspace: expect.objectContaining({ semantic_index: expect.objectContaining({ - vector_store: expect.objectContaining({ collection: "research_docs" }), + vector_store: expect.objectContaining({ collection: "research-docs" }), }), }), })); diff --git a/frontend/src/shell/WorkspaceEditor.tsx b/frontend/src/shell/WorkspaceEditor.tsx index d2e56f0a..510b6d53 100644 --- a/frontend/src/shell/WorkspaceEditor.tsx +++ b/frontend/src/shell/WorkspaceEditor.tsx @@ -47,7 +47,7 @@ function validate(workspace: CanonicalWorkspace): FieldErrors { if (dwhPort) errors["dwh.port"] = dwhPort; const dwhTimeout = positiveInteger(workspace.dwh.timeout_ms, "DWH timeout"); if (dwhTimeout) errors["dwh.timeout"] = dwhTimeout; - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(workspace.semantic_index.vector_store.collection)) errors["vector.collection"] = "Use a collection identifier"; + if (!/^[a-z][a-z0-9-]{2,62}$/.test(workspace.semantic_index.vector_store.collection)) errors["vector.collection"] = "Use a canonical collection name"; if (!workspace.llm_policy.allowed.length || workspace.llm_policy.allowed.some((model) => !/^[^/\s]+\/[^/\s]+$/.test(model))) { errors["llm.allowed"] = "Use provider/model entries separated by commas"; } diff --git a/frontend/src/shell/WorkspaceManager.test.tsx b/frontend/src/shell/WorkspaceManager.test.tsx index aa67a3d0..c8febed1 100644 --- a/frontend/src/shell/WorkspaceManager.test.tsx +++ b/frontend/src/shell/WorkspaceManager.test.tsx @@ -48,7 +48,7 @@ const bootstrapWorkspace = { }, semantic_index: { ...readyWorkspace.semantic_index, - vector_store: { ...readyWorkspace.semantic_index.vector_store, collection: "bootstrap_slot" }, + vector_store: { ...readyWorkspace.semantic_index.vector_store, collection: "bootstrap-slot" }, }, }; @@ -107,7 +107,7 @@ test("saves a bootstrap draft locally for a configuration_required slot", async await user.click(await screen.findByRole("button", { name: "Bootstrap slot" })); await user.clear(screen.getByLabelText("Vector collection")); - await user.type(screen.getByLabelText("Vector collection"), "bootstrap_docs"); + await user.type(screen.getByLabelText("Vector collection"), "bootstrap-docs"); await user.click(screen.getByRole("button", { name: "Save draft" })); await waitFor(() => expect(screen.getByText("Draft saved in this browser.")).toBeVisible()); diff --git a/frontend/src/test/workspace-fixtures.ts b/frontend/src/test/workspace-fixtures.ts index b368b8bb..1519e409 100644 --- a/frontend/src/test/workspace-fixtures.ts +++ b/frontend/src/test/workspace-fixtures.ts @@ -13,7 +13,7 @@ export function canonicalWorkspaceFixture( }, semantic_index: { vector_store: { - engine: "qdrant", collection: id.replaceAll("-", "_"), dimensions: 1024, distance: "cosine", + engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine", }, embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024, diff --git a/frontend/src/workspaces/drafts.ts b/frontend/src/workspaces/drafts.ts index f6cc262c..0e701025 100644 --- a/frontend/src/workspaces/drafts.ts +++ b/frontend/src/workspaces/drafts.ts @@ -424,7 +424,7 @@ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace | const dwhPort = dwh.port === undefined ? undefined : positiveInteger(dwh.port, 65_535); const dwhTimeout = dwh.timeout_ms === undefined ? undefined : positiveInteger(dwh.timeout_ms); const dwhTransports = uniqueChoices(dwh.supported_transports, ["postgres_direct", "rest_api", "ssh_tunnel"] as const); - const collection = identifier(vectorStore.collection); + const collection = workspaceId(vectorStore.collection); const vectorDimensions = positiveInteger(vectorStore.dimensions, 32_768); const distance = oneOf(vectorStore.distance, ["cosine"] as const); const embeddingProvider = oneOf(embedding.provider, ["ollama_internal"] as const);