diff --git a/frontend/src/api/sessions.test.ts b/frontend/src/api/sessions.test.ts index 60a20c78..355ce770 100644 --- a/frontend/src/api/sessions.test.ts +++ b/frontend/src/api/sessions.test.ts @@ -15,12 +15,12 @@ test("createSession migrates legacy selections and POSTs browser preferences", a workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low", })), http.get("/api/workspaces", () => HttpResponse.json([{ - id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", - revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" }, + id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", language: "en", + revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot" }, }])), 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", state: "operational" }, + revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot" }, })), http.post("/api/sessions", async ({ request }) => { body = await request.json(); @@ -36,6 +36,93 @@ test("createSession migrates legacy selections and POSTs browser preferences", a }); }); +test("createSession does not POST when a selected summary aliases another workspace name", async () => { + localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({ + workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low", + })); + let posted = false; + server.use( + http.get("/api/workspaces", () => HttpResponse.json([{ + id: "psd-clinical", name: "other-workspace", file: "psd-clinical.yaml", + displayName: "PSD Clinical", language: "en", + revision: { + id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", + }, + }])), + 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", () => { + posted = true; + return HttpResponse.json({ id: "s1" }); + }), + ); + + await expect(createSession({ question: "q" })).rejects.toMatchObject({ + message: "Could not load workspace registry. Please retry.", + }); + expect(posted).toBe(false); +}); + +test.each([ + ["historical state", { state: "operational" }], + ["an unknown revision field", { generation: 1 }], + ["a malformed revision", { commit: "not-a-commit" }], +])("createSession does not POST when the selected summary revision has %s", async (_case, revisionPatch) => { + localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({ + workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low", + })); + const revision = { + id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), + snapshotPath: "/snapshot", ...revisionPatch, + }; + let posted = false; + server.use( + http.get("/api/workspaces", () => HttpResponse.json([{ + id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", + displayName: "PSD Clinical", language: "en", revision, + }])), + 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", () => { + posted = true; + return HttpResponse.json({ id: "s1" }); + }), + ); + + await expect(createSession({ question: "q" })).rejects.toMatchObject({ + message: "Could not load workspace registry. Please retry.", + }); + expect(posted).toBe(false); +}); + +test("createSession preserves allowLegacy for a selected session workspace absent from registry summaries", async () => { + localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({ + workspaceId: "retained-session-workspace", provider: "zai", model: "glm-5.2", thinking: "low", + })); + let body: unknown; + server.use( + http.get("/api/workspaces", () => HttpResponse.json([])), + 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: "retained-session-workspace", + provider: "zai", model: "glm-5.2", thinking: "low", + }); +}); + test("createSession rejects a workspace summary that omits the canonical revision", async () => { localStorage.clear(); let posted = false; @@ -44,7 +131,7 @@ test("createSession rejects a workspace summary that omits the canonical revisio workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low", })), http.get("/api/workspaces", () => HttpResponse.json([{ - id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", + id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", language: "en", }])), http.post("/api/sessions", async () => { posted = true; diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index 4b255632..fa490ba6 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -63,10 +63,6 @@ async function ensureWorkspaceSelectionPolicy(): Promise { workspacePolicyGate.reject(workspaceId); throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR); } - if (workspace?.revision?.state === "migration_required") { - workspacePolicyGate.rejectSummary(workspaceId); - throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR); - } if (!workspace?.revision) { workspacePolicyGate.allowLegacy(workspaceId); return workspacePreferences.load(); diff --git a/frontend/src/api/workspaces.test.ts b/frontend/src/api/workspaces.test.ts index 8d199778..fb748c67 100644 --- a/frontend/src/api/workspaces.test.ts +++ b/frontend/src/api/workspaces.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "vitest"; import { http, HttpResponse } from "msw"; import { server } from "../test/msw"; import { - asWorkspaceConflict, getWorkspace, importWorkspace, publishWorkspace, validateWorkspace, + asWorkspaceConflict, getWorkspace, importWorkspace, listWorkspaces, publishWorkspace, validateWorkspace, type CanonicalWorkspace, } from "./workspaces"; @@ -34,9 +34,74 @@ const revision = { commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "workspaces/psd-clinical.yaml", - state: "operational" as const, }; +const summary = { + id: "psd-clinical", + name: "psd-clinical", + file: "psd-clinical.yaml", + displayName: "PSD Clinical", + description: "Clinical workspace", + language: "en" as const, + revision, +}; + +test("decodes and normalizes state-free workspace summaries without passing through backend-only fields", async () => { + server.use(http.get("/api/workspaces", () => HttpResponse.json([{ + ...summary, workspace, backendOnly: "ignored", + }]))); + + await expect(listWorkspaces()).resolves.toEqual([summary]); +}); + +test("accepts internally multiline display names and descriptions using backend trim semantics", async () => { + const multiline = { + ...summary, + displayName: "PSD\nClinical", + description: "First line\n\tSecond line", + }; + server.use(http.get("/api/workspaces", () => HttpResponse.json([multiline]))); + + await expect(listWorkspaces()).resolves.toEqual([multiline]); +}); + +test.each([ + ["an id/name alias", { ...summary, name: "other-workspace" }], + ["a non-canonical selector file", { ...summary, file: "workspaces/psd-clinical.yaml" }], +])("rejects workspace summaries with %s", async (_case, malformedSummary) => { + server.use(http.get("/api/workspaces", () => HttpResponse.json([malformedSummary]))); + + await expect(listWorkspaces()).rejects.toThrow("invalid workspace summary"); +}); + +test.each([ + ["historical state", { ...revision, state: "operational" }], + ["unknown revision field", { ...revision, generation: 1 }], + ["malformed revision", { ...revision, commit: "not-a-commit" }], +])("rejects workspace summaries with %s", async (_case, malformedRevision) => { + server.use(http.get("/api/workspaces", () => HttpResponse.json([{ + ...summary, revision: malformedRevision, + }]))); + + await expect(listWorkspaces()).rejects.toThrow("invalid workspace summary"); +}); + +test.each([ + ["a non-array response", { ...summary }], + ["a malformed selector field", [{ ...summary, language: "fr" }]], +])("rejects %s from the workspace summary API", async (_case, response) => { + server.use(http.get("/api/workspaces", () => HttpResponse.json(response))); + + await expect(listWorkspaces()).rejects.toThrow("invalid workspace summary"); +}); + +test("preserves a present summary without revision so callers can distinguish it from an absent workspace", async () => { + const { revision: _revision, ...incomplete } = summary; + server.use(http.get("/api/workspaces", () => HttpResponse.json([incomplete]))); + + await expect(listWorkspaces()).resolves.toEqual([incomplete]); +}); + test("uploads a workspace bundle without JSON content type", async () => { let contentType: string | null = null; server.use(http.post("/api/workspaces/import", ({ request }) => { @@ -76,6 +141,35 @@ test("rejects imported Evidence with a secret-shaped field", async () => { .rejects.toThrow("invalid imported workspace draft"); }); +test("accepts the atomic schema-v3 workspace revision contract without historical state", async () => { + const stateFreeRevision = { + id: "psd-clinical", + commit: "a".repeat(40), + blob: "b".repeat(40), + snapshotPath: "workspaces/psd-clinical.yaml", + }; + server.use(http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ + workspace, revision: stateFreeRevision, + }))); + + await expect(getWorkspace("psd-clinical")).resolves.toEqual({ workspace, revision: stateFreeRevision }); +}); + +test("rejects the removed historical workspace revision state as an extra API key", async () => { + server.use(http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ + workspace, + revision: { + id: "psd-clinical", + commit: "a".repeat(40), + blob: "b".repeat(40), + snapshotPath: "workspaces/psd-clinical.yaml", + state: "operational", + }, + }))); + + await expect(getWorkspace("psd-clinical")).rejects.toThrow("invalid workspace revision"); +}); + test("rejects read responses with a missing or inconsistent revision", async () => { server.use(http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace: evidenceWorkspace, diff --git a/frontend/src/api/workspaces.ts b/frontend/src/api/workspaces.ts index 7861217c..bb8c7aca 100644 --- a/frontend/src/api/workspaces.ts +++ b/frontend/src/api/workspaces.ts @@ -99,7 +99,6 @@ export interface WorkspaceRevision { commit: string; blob: string; snapshotPath: string; - state: "operational" | "migration_required"; } export interface WorkspaceSummary { @@ -110,7 +109,7 @@ export interface WorkspaceSummary { displayName: string; description?: string; language: "en" | "it"; - revision: WorkspaceRevision; + revision?: WorkspaceRevision; } export interface WorkspaceRecord { @@ -206,9 +205,10 @@ function exactObject(value: unknown, keys: readonly string[]): Record typeof candidate === "string" + && candidate.length > 0 + && candidate.trim() === candidate; + if ( + typeof id !== "string" || !/^[a-z][a-z0-9-]{2,62}$/.test(id) + || name !== id || file !== `${id}.yaml` + || !validText(displayName) + || (description !== undefined && !validText(description)) + || (language !== "en" && language !== "it") + ) return undefined; + const revision = source.revision === undefined + ? undefined + : workspaceRevision(source.revision, id); + if (source.revision !== undefined && !revision) return undefined; + return { + id, + name: name as string, + file: file as string, + displayName: displayName as string, + ...(description === undefined ? {} : { description: description as string }), + language, + ...(revision ? { revision } : {}), + }; } function requireWorkspaceRevision(value: unknown, expectedId: string): WorkspaceRevision { @@ -280,7 +310,15 @@ function requireCanonicalWorkspace(value: unknown): CanonicalWorkspace { return workspace; } -export const listWorkspaces = () => apiFetch("/workspaces"); +export const listWorkspaces = async (): Promise => { + const response = await apiFetch("/workspaces"); + if (!Array.isArray(response)) throw new Error("Workspace API returned an invalid workspace summary"); + const summaries = response.map(workspaceSummary); + if (summaries.some((summary) => !summary)) { + throw new Error("Workspace API returned an invalid workspace summary"); + } + return summaries as WorkspaceSummary[]; +}; export const getWorkspace = async (id: string): Promise => { const response = await apiFetch(`/workspaces/${encodeURIComponent(id)}`); const source = object(response); diff --git a/frontend/src/shell/NewSessionDialog.test.tsx b/frontend/src/shell/NewSessionDialog.test.tsx index 8b7dd617..7d9a76ce 100644 --- a/frontend/src/shell/NewSessionDialog.test.tsx +++ b/frontend/src/shell/NewSessionDialog.test.tsx @@ -36,8 +36,8 @@ test("submitting includes browser-local migrated preferences and calls onCreated workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low", })), http.get("/api/workspaces", () => HttpResponse.json([{ - id: "default", name: "default", file: "default.yaml", displayName: "Default", - revision: { state: "operational" }, + id: "default", name: "default", file: "default.yaml", displayName: "Default", language: "en", + revision: workspaceRevisionFixture("default"), }])), http.get("/api/workspaces/default", () => HttpResponse.json({ workspace: canonicalWorkspaceFixture("default", ["zai/glm-5.2"], "zai/glm-5.2"), @@ -67,7 +67,7 @@ test("first-run direct dialog creation waits for registry policy without a mount let policyRequestStarted = false; const policyMayFinish = new Promise((resolve) => { releasePolicy = resolve; }); const revision = { - id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" as const, + id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", }; localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({ workspaceId: "psd-clinical", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", @@ -76,7 +76,7 @@ test("first-run direct dialog creation waits for registry policy without a mount http.get("/api/workspaces", () => { summaryRequestStarted = true; return HttpResponse.json([{ - id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", + id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", language: "en", revision, }]); }), diff --git a/frontend/src/shell/SteerInput.test.tsx b/frontend/src/shell/SteerInput.test.tsx index f7ad4d0e..6b55ea09 100644 --- a/frontend/src/shell/SteerInput.test.tsx +++ b/frontend/src/shell/SteerInput.test.tsx @@ -24,12 +24,12 @@ test("new sessions send the browser-selected workspace, model, provider, and thi })); server.use( http.get("/api/workspaces", () => HttpResponse.json([{ - id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", - revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" }, + id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", language: "en", + revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot" }, }])), 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", state: "operational" }, + revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot" }, })), http.post("/api/sessions", async ({ request }) => { body = await request.json(); @@ -135,10 +135,13 @@ test("footer shows cumulative k-token counters after workspace and context gauge http.get("/api/settings", () => HttpResponse.json({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium", })), - http.get("/api/workspaces", () => HttpResponse.json([{ name: "psd" }])), + http.get("/api/workspaces", () => HttpResponse.json([{ + id: "psd", name: "psd", file: "psd.yaml", displayName: "PSD", language: "en", + revision: { id: "psd", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot" }, + }])), http.get("/api/workspaces/psd", () => HttpResponse.json({ workspace: canonicalWorkspaceFixture("psd"), - revision: { id: "psd", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" }, + revision: { id: "psd", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot" }, })), http.get("/api/models", () => HttpResponse.json({ models: [{ provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }], @@ -172,12 +175,12 @@ test("footer limits model choices to the selected workspace policy", async () => workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium", })), http.get("/api/workspaces", () => HttpResponse.json([{ - id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", - revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" }, + id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", language: "en", + revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot" }, }])), 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", state: "operational" }, + revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot" }, })), http.get("/api/models", () => HttpResponse.json({ models: [ { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, @@ -198,15 +201,15 @@ test("switching workspaces replaces an out-of-policy model before session creati workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", })); const revision = (id: string) => ({ - id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" as const, + id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", }); server.use( http.get("/api/settings", () => HttpResponse.json({ workspace: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", })), http.get("/api/workspaces", () => HttpResponse.json([ - { id: "research", name: "research", file: "research.yaml", displayName: "Research", revision: revision("research") }, - { id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", revision: revision("psd-clinical") }, + { id: "research", name: "research", file: "research.yaml", displayName: "Research", language: "en", revision: revision("research") }, + { id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", language: "en", revision: revision("psd-clinical") }, ])), http.get("/api/workspaces/research", () => HttpResponse.json({ workspace: canonicalWorkspaceFixture("research", ["deepseek/deepseek-v4-pro"]), @@ -251,13 +254,13 @@ test("immediate submit waits for a switched workspace policy before creating a s workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", })); const revision = (id: string) => ({ - id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" as const, + id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", }); server.use( http.get("/api/settings", () => HttpResponse.json({ workspace: "research" })), http.get("/api/workspaces", () => HttpResponse.json([ - { id: "research", name: "research", file: "research.yaml", displayName: "Research", revision: revision("research") }, - { id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", revision: revision("psd-clinical") }, + { id: "research", name: "research", file: "research.yaml", displayName: "Research", language: "en", revision: revision("research") }, + { id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", language: "en", revision: revision("psd-clinical") }, ])), http.get("/api/workspaces/research", () => HttpResponse.json({ workspace: canonicalWorkspaceFixture("research", ["deepseek/deepseek-v4-pro"]), revision: revision("research"), @@ -307,12 +310,12 @@ test("initial restored workspace waits for its delayed policy before creating a workspaceId: "psd-clinical", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", })); const revision = (id: string) => ({ - id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" as const, + id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", }); server.use( http.get("/api/settings", () => HttpResponse.json({ workspace: "psd-clinical" })), http.get("/api/workspaces", () => HttpResponse.json([ - { id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", revision: revision("psd-clinical") }, + { id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", language: "en", revision: revision("psd-clinical") }, ])), http.get("/api/workspaces/psd-clinical", async () => { policyRequestStarted = true; @@ -347,49 +350,6 @@ test("initial restored workspace waits for its delayed policy before creating a })); }); -test("initial submit rejects a migration-required workspace after summaries load", async () => { - let body: unknown; - let failure: string | undefined; - let releaseSummaries!: () => void; - let summaryRequestStarted = false; - const summariesMayFinish = new Promise((resolve) => { releaseSummaries = resolve; }); - localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({ - workspaceId: "legacy-workspace", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", - })); - server.use( - http.get("/api/settings", () => HttpResponse.json({ workspace: "legacy-workspace" })), - http.get("/api/workspaces", async () => { - summaryRequestStarted = true; - await summariesMayFinish; - return HttpResponse.json([{ - id: "legacy-workspace", name: "legacy-workspace", file: "legacy-workspace.yaml", displayName: "Legacy workspace", - revision: { id: "legacy-workspace", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "migration_required" }, - }]); - }), - http.get("/api/workspaces/legacy-workspace", () => new HttpResponse(null, { status: 409 })), - http.get("/api/models", () => HttpResponse.json({ models: [ - { provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true }, - ] })), - http.post("/api/sessions", async ({ request }) => { - body = await request.json(); - return HttpResponse.json({ id: "s1" }); - }), - ); - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - render( { failure = message; }} />); - - await waitFor(() => expect(summaryRequestStarted).toBe(true)); - await userEvent.type(screen.getByRole("textbox", { name: /new question/i }), "q"); - await userEvent.click(screen.getByRole("button", { name: /send/i })); - - expect(body).toBeUndefined(); - expect(screen.getByRole("button", { name: /send/i })).toBeDisabled(); - releaseSummaries(); - - await waitFor(() => expect(failure).toBe("Could not load selected workspace policy. Please retry.")); - expect(body).toBeUndefined(); -}); - test("initial submit rejects a workspace summary that omits the canonical revision", async () => { let body: unknown; let failure: string | undefined; @@ -399,7 +359,7 @@ test("initial submit rejects a workspace summary that omits the canonical revisi server.use( http.get("/api/settings", () => HttpResponse.json({ workspace: "broken-workspace" })), http.get("/api/workspaces", () => HttpResponse.json([{ - id: "broken-workspace", name: "broken-workspace", file: "broken-workspace.yaml", displayName: "Broken workspace", + id: "broken-workspace", name: "broken-workspace", file: "broken-workspace.yaml", displayName: "Broken workspace", language: "en", }])), http.get("/api/models", () => HttpResponse.json({ models: [ { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, @@ -467,14 +427,14 @@ test("submit follows a rapid workspace switch instead of waiting for an abandone workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", })); const revision = (id: string) => ({ - id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" as const, + id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", }); server.use( http.get("/api/settings", () => HttpResponse.json({ workspace: "research" })), http.get("/api/workspaces", () => HttpResponse.json([ - { id: "research", name: "research", file: "research.yaml", displayName: "Research", revision: revision("research") }, - { id: "workspace-b", name: "workspace-b", file: "workspace-b.yaml", displayName: "Workspace B", revision: revision("workspace-b") }, - { id: "workspace-c", name: "workspace-c", file: "workspace-c.yaml", displayName: "Workspace C", revision: revision("workspace-c") }, + { id: "research", name: "research", file: "research.yaml", displayName: "Research", language: "en", revision: revision("research") }, + { id: "workspace-b", name: "workspace-b", file: "workspace-b.yaml", displayName: "Workspace B", language: "en", revision: revision("workspace-b") }, + { id: "workspace-c", name: "workspace-c", file: "workspace-c.yaml", displayName: "Workspace C", language: "en", revision: revision("workspace-c") }, ])), http.get("/api/workspaces/research", () => HttpResponse.json({ workspace: canonicalWorkspaceFixture("research", ["deepseek/deepseek-v4-pro"]), revision: revision("research"), diff --git a/frontend/src/shell/SteerInput.tsx b/frontend/src/shell/SteerInput.tsx index 46c9e704..59194b92 100644 --- a/frontend/src/shell/SteerInput.tsx +++ b/frontend/src/shell/SteerInput.tsx @@ -203,8 +203,6 @@ export function ComposerFooter() { workspacePolicyGate.rejectSummary(workspace); } else if (selectedWorkspace && !selectedWorkspaceHasRevision) { workspacePolicyGate.rejectSummary(workspace); - } else if (selectedWorkspace?.revision?.state === "migration_required") { - workspacePolicyGate.rejectSummary(workspace); } else if (selectedWorkspace?.revision) { workspacePolicyGate.select(workspace); } else { @@ -243,7 +241,6 @@ export function ComposerFooter() { if (patch.workspaceId && patch.workspaceId !== workspace) { const selected = workspaces.find((candidate) => candidate.id === patch.workspaceId); if (selected && !selected.revision) workspacePolicyGate.rejectSummary(patch.workspaceId); - else if (selected?.revision?.state === "migration_required") workspacePolicyGate.rejectSummary(patch.workspaceId); else if (selected?.revision) workspacePolicyGate.select(patch.workspaceId); else workspacePolicyGate.allowLegacy(patch.workspaceId); } @@ -269,8 +266,8 @@ export function ComposerFooter() { {workspaces.length === 0 ? ( ) : ( - workspaces.filter((w) => w.revision && w.revision.state !== "migration_required").map((w) => ( - )) diff --git a/frontend/src/shell/WorkspaceManager.test.tsx b/frontend/src/shell/WorkspaceManager.test.tsx index 88c88f75..7bc2927e 100644 --- a/frontend/src/shell/WorkspaceManager.test.tsx +++ b/frontend/src/shell/WorkspaceManager.test.tsx @@ -27,13 +27,13 @@ beforeEach(() => { http.get("/api/workspace-registry/status", () => HttpResponse.json({ branch: "main", head: "a".repeat(40), ahead: 0, behind: 0, degraded: false })), http.get("/api/workspaces", () => HttpResponse.json([{ - id: "psd-clinical", name: "PSD Clinical", displayName: "PSD Clinical", description: "Clinical data", - language: "en", file: "workspaces/psd-clinical.yaml", - revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd", state: "operational" }, + id: "psd-clinical", name: "psd-clinical", displayName: "PSD Clinical", description: "Clinical data", + language: "en", file: "psd-clinical.yaml", + revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd" }, }])), http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace, - revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd", state: "operational" }, + revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd" }, })), ); }); @@ -163,12 +163,12 @@ test("proposes a different valid ID when duplicating a 63-character workspace ID const maxWorkspace = { ...workspace, workspace: { ...workspace.workspace, id: maxId } }; server.use( http.get("/api/workspaces", () => HttpResponse.json([{ - id: maxId, name: "Maximum", displayName: "Maximum", language: "en", file: `workspaces/${maxId}.yaml`, - revision: { id: maxId, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/maximum", state: "operational" }, + id: maxId, name: maxId, displayName: "Maximum", language: "en", file: `${maxId}.yaml`, + revision: { id: maxId, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/maximum" }, }])), http.get(`/api/workspaces/${maxId}`, () => HttpResponse.json({ workspace: maxWorkspace, - revision: { id: maxId, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/maximum", state: "operational" }, + revision: { id: maxId, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/maximum" }, })), ); renderManager(); @@ -213,23 +213,15 @@ test("runs validation and installation test with only sanitized messages", async expect(within(screen.getByTestId("workspace-diagnostics")).queryByText(/password|token|secret/i)).not.toBeInTheDocument(); }); -test("shows a migration banner for legacy descriptors and does not load editor details for them", async () => { +test("loads a state-free registry revision and displays only its commit", async () => { const user = userEvent.setup(); - server.use( - http.get("/api/workspaces", () => HttpResponse.json([ - { - id: "psd-clinical", name: "PSD Clinical", displayName: "PSD Clinical", description: "Clinical data", - language: "en", file: "workspaces/psd-clinical.yaml", - revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd", state: "migration_required" }, - }, - ])), - ); renderManager(); await user.click(await screen.findByRole("button", { name: "PSD Clinical" })); - expect(await screen.findByText("This workspace uses a legacy descriptor and must be migrated to schema v3 before new sessions or publication.")).toBeVisible(); - expect(screen.queryByLabelText("Vector collection")).not.toBeInTheDocument(); + expect(await screen.findByLabelText("Vector collection")).toBeVisible(); + expect(screen.getByText(`Revision ${"a".repeat(12)}`)).toBeVisible(); + expect(screen.queryByText(/migration required/i)).not.toBeInTheDocument(); }); test("shows an actionable unavailable message when a workspace summary omits its canonical revision", async () => { @@ -237,8 +229,8 @@ test("shows an actionable unavailable message when a workspace summary omits its server.use( http.get("/api/workspaces", () => HttpResponse.json([ { - id: "broken-workspace", name: "Broken workspace", displayName: "Broken workspace", description: "Broken data", - language: "en", file: "workspaces/broken-workspace.yaml", + id: "broken-workspace", name: "broken-workspace", displayName: "Broken workspace", description: "Broken data", + language: "en", file: "broken-workspace.yaml", }, ])), ); @@ -288,7 +280,7 @@ test("shows an accessible retry when the selected workspace detail query fails", calls += 1; return calls === 1 ? new HttpResponse(null, { status: 503 }) : HttpResponse.json({ workspace, - revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd", state: "operational" }, + revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd" }, }); })); renderManager(); diff --git a/frontend/src/shell/WorkspaceManager.tsx b/frontend/src/shell/WorkspaceManager.tsx index f2a075fc..403b915e 100644 --- a/frontend/src/shell/WorkspaceManager.tsx +++ b/frontend/src/shell/WorkspaceManager.tsx @@ -64,11 +64,10 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: () const workspaces = workspacesQuery.data ?? []; const selectedSummary = useMemo(() => workspaces.find((workspace) => workspace.id === selectedId), [selectedId, workspaces]); const selectedSummaryIncomplete = Boolean(selectedSummary && !selectedSummary.revision); - const selectedIsLegacy = selectedSummary?.revision?.state === "migration_required"; const detailQuery = useQuery({ queryKey: ["workspace", selectedId], queryFn: () => getWorkspace(selectedId!), - enabled: Boolean(open && selectedId && !localDraft && !selectedSummaryIncomplete && !selectedIsLegacy), + enabled: Boolean(open && selectedId && !localDraft && !selectedSummaryIncomplete), }); const status = statusQuery.data; const record = detailQuery.data; @@ -317,13 +316,8 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()

Workspace summary unavailable

This workspace summary is incomplete. Refresh the registry or choose another workspace before creating sessions or editing drafts.

- ) : selectedIsLegacy ? ( -
-

Migration required

-

This workspace uses a legacy descriptor and must be migrated to schema v3 before new sessions or publication.

-
) : detailQuery.isError && selectedId && !localDraft ? { void detailQuery.refetch(); }} /> : !currentDraft && !detailQuery.isLoading &&

Select a workspace

Review an existing definition or start a browser-only draft.

} - {!selectedSummaryIncomplete && !selectedIsLegacy && !detailQuery.isError && (currentDraft || detailQuery.isLoading) && ( + {!selectedSummaryIncomplete && !detailQuery.isError && (currentDraft || detailQuery.isLoading) && ( <> {detailQuery.isLoading && !currentDraft ?

Loading workspace definition…

: currentDraft && <>
@@ -347,7 +341,7 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()

Git status & history

{status?.degraded ? "Using the last valid local snapshot." : "Registry checkout is current."}

- {record &&

Revision {record.revision.commit.slice(0, 12)} · {record.revision.state}

} + {record &&

Revision {record.revision.commit.slice(0, 12)}

}
} diff --git a/frontend/src/shell/WorkspacePublishDialog.test.tsx b/frontend/src/shell/WorkspacePublishDialog.test.tsx index 33130635..3de944cc 100644 --- a/frontend/src/shell/WorkspacePublishDialog.test.tsx +++ b/frontend/src/shell/WorkspacePublishDialog.test.tsx @@ -52,7 +52,7 @@ test("validates a draft and requires a separate confirmation before publishing", let publishCalls = 0; server.use(http.post("/api/workspaces/publish", () => { publishCalls += 1; - return HttpResponse.json({ revision: { id: "psd-clinical", commit: "c".repeat(40), blob: "d".repeat(40), snapshotPath: "/safe", state: "operational" } }); + return HttpResponse.json({ revision: { id: "psd-clinical", commit: "c".repeat(40), blob: "d".repeat(40), snapshotPath: "/safe" } }); })); render(); diff --git a/frontend/src/shell/f1-loop.test.tsx b/frontend/src/shell/f1-loop.test.tsx index 8b03df88..4b3b43e3 100644 --- a/frontend/src/shell/f1-loop.test.tsx +++ b/frontend/src/shell/f1-loop.test.tsx @@ -23,9 +23,7 @@ test("F1: create session -> widget via SSE -> respond -> POST /response", async http.get("/api/settings", () => HttpResponse.json({ workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low", })), - http.get("/api/workspaces", () => - HttpResponse.json([{ name: "default", file: "default.db" }]), - ), + http.get("/api/workspaces", () => HttpResponse.json([])), http.get("/api/models", () => HttpResponse.json({ models: [] })), http.post("/api/sessions/s1/response", async ({ request }) => { responded = await request.json(); diff --git a/frontend/src/test/workspace-fixtures.ts b/frontend/src/test/workspace-fixtures.ts index cc6f9ecd..e3817440 100644 --- a/frontend/src/test/workspace-fixtures.ts +++ b/frontend/src/test/workspace-fixtures.ts @@ -23,12 +23,9 @@ export function canonicalWorkspaceFixture( }; } -export function workspaceRevisionFixture( - id: string, - state: WorkspaceRevision["state"] = "operational", -): WorkspaceRevision { +export function workspaceRevisionFixture(id: string): WorkspaceRevision { return { id, commit: "a".repeat(40), blob: "b".repeat(40), - snapshotPath: `/snapshots/${"a".repeat(40)}/${id}.yaml`, state, + snapshotPath: `/snapshots/${"a".repeat(40)}/${id}.yaml`, }; }