refactor: remove legacy workspace UI state

This commit is contained in:
2026-08-10 22:56:00 +02:00
parent fc19b0d67c
commit 90684c3280
12 changed files with 283 additions and 130 deletions
+91 -4
View File
@@ -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;
-4
View File
@@ -63,10 +63,6 @@ async function ensureWorkspaceSelectionPolicy(): Promise<WorkspacePreference> {
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();
+96 -2
View File
@@ -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,
+45 -7
View File
@@ -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<string, un
}
function workspaceRevision(value: unknown, expectedId: string): WorkspaceRevision | undefined {
const source = exactObject(value, ["id", "commit", "blob", "snapshotPath", "state"]);
// Frontend and core deploy atomically: accept only the schema-v3, state-free contract.
const source = exactObject(value, ["id", "commit", "blob", "snapshotPath"]);
if (!source) return undefined;
const { id, commit, blob, snapshotPath, state } = source;
const { id, commit, blob, snapshotPath } = source;
if (
id !== expectedId
|| typeof id !== "string" || !/^[a-z][a-z0-9-]{2,62}$/.test(id)
@@ -216,9 +216,39 @@ function workspaceRevision(value: unknown, expectedId: string): WorkspaceRevisio
|| typeof blob !== "string" || !/^[0-9a-f]{40}$/.test(blob)
|| typeof snapshotPath !== "string" || snapshotPath.length === 0
|| snapshotPath.trim() !== snapshotPath || /[\u0000-\u001f\u007f]/u.test(snapshotPath)
|| (state !== "operational" && state !== "migration_required")
) return undefined;
return { id, commit, blob, snapshotPath, state };
return { id, commit, blob, snapshotPath };
}
function workspaceSummary(value: unknown): WorkspaceSummary | undefined {
const source = object(value);
if (!source) return undefined;
const { id, name, file, displayName, description, language } = source;
// Summary prose follows the producer's z.string().trim().min(1) contract;
// path-like revision fields retain their stricter control-character checks above.
const validText = (candidate: unknown) => 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<WorkspaceSummary[]>("/workspaces");
export const listWorkspaces = async (): Promise<WorkspaceSummary[]> => {
const response = await apiFetch<unknown>("/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<WorkspaceRecord> => {
const response = await apiFetch<unknown>(`/workspaces/${encodeURIComponent(id)}`);
const source = object(response);