refactor: remove legacy workspace UI state
This commit is contained in:
@@ -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",
|
workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||||
})),
|
})),
|
||||||
http.get("/api/workspaces", () => HttpResponse.json([{
|
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",
|
||||||
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/workspaces/psd-clinical", () => HttpResponse.json({
|
http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({
|
||||||
workspace: canonicalWorkspaceFixture("psd-clinical"),
|
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 }) => {
|
http.post("/api/sessions", async ({ request }) => {
|
||||||
body = await request.json();
|
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 () => {
|
test("createSession rejects a workspace summary that omits the canonical revision", async () => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
let posted = false;
|
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",
|
workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||||
})),
|
})),
|
||||||
http.get("/api/workspaces", () => HttpResponse.json([{
|
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 () => {
|
http.post("/api/sessions", async () => {
|
||||||
posted = true;
|
posted = true;
|
||||||
|
|||||||
@@ -63,10 +63,6 @@ async function ensureWorkspaceSelectionPolicy(): Promise<WorkspacePreference> {
|
|||||||
workspacePolicyGate.reject(workspaceId);
|
workspacePolicyGate.reject(workspaceId);
|
||||||
throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR);
|
throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR);
|
||||||
}
|
}
|
||||||
if (workspace?.revision?.state === "migration_required") {
|
|
||||||
workspacePolicyGate.rejectSummary(workspaceId);
|
|
||||||
throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR);
|
|
||||||
}
|
|
||||||
if (!workspace?.revision) {
|
if (!workspace?.revision) {
|
||||||
workspacePolicyGate.allowLegacy(workspaceId);
|
workspacePolicyGate.allowLegacy(workspaceId);
|
||||||
return workspacePreferences.load();
|
return workspacePreferences.load();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { expect, test } from "vitest";
|
|||||||
import { http, HttpResponse } from "msw";
|
import { http, HttpResponse } from "msw";
|
||||||
import { server } from "../test/msw";
|
import { server } from "../test/msw";
|
||||||
import {
|
import {
|
||||||
asWorkspaceConflict, getWorkspace, importWorkspace, publishWorkspace, validateWorkspace,
|
asWorkspaceConflict, getWorkspace, importWorkspace, listWorkspaces, publishWorkspace, validateWorkspace,
|
||||||
type CanonicalWorkspace,
|
type CanonicalWorkspace,
|
||||||
} from "./workspaces";
|
} from "./workspaces";
|
||||||
|
|
||||||
@@ -34,9 +34,74 @@ const revision = {
|
|||||||
commit: "a".repeat(40),
|
commit: "a".repeat(40),
|
||||||
blob: "b".repeat(40),
|
blob: "b".repeat(40),
|
||||||
snapshotPath: "workspaces/psd-clinical.yaml",
|
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 () => {
|
test("uploads a workspace bundle without JSON content type", async () => {
|
||||||
let contentType: string | null = null;
|
let contentType: string | null = null;
|
||||||
server.use(http.post("/api/workspaces/import", ({ request }) => {
|
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");
|
.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 () => {
|
test("rejects read responses with a missing or inconsistent revision", async () => {
|
||||||
server.use(http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({
|
server.use(http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({
|
||||||
workspace: evidenceWorkspace,
|
workspace: evidenceWorkspace,
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ export interface WorkspaceRevision {
|
|||||||
commit: string;
|
commit: string;
|
||||||
blob: string;
|
blob: string;
|
||||||
snapshotPath: string;
|
snapshotPath: string;
|
||||||
state: "operational" | "migration_required";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceSummary {
|
export interface WorkspaceSummary {
|
||||||
@@ -110,7 +109,7 @@ export interface WorkspaceSummary {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
language: "en" | "it";
|
language: "en" | "it";
|
||||||
revision: WorkspaceRevision;
|
revision?: WorkspaceRevision;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceRecord {
|
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 {
|
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;
|
if (!source) return undefined;
|
||||||
const { id, commit, blob, snapshotPath, state } = source;
|
const { id, commit, blob, snapshotPath } = source;
|
||||||
if (
|
if (
|
||||||
id !== expectedId
|
id !== expectedId
|
||||||
|| typeof id !== "string" || !/^[a-z][a-z0-9-]{2,62}$/.test(id)
|
|| 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 blob !== "string" || !/^[0-9a-f]{40}$/.test(blob)
|
||||||
|| typeof snapshotPath !== "string" || snapshotPath.length === 0
|
|| typeof snapshotPath !== "string" || snapshotPath.length === 0
|
||||||
|| snapshotPath.trim() !== snapshotPath || /[\u0000-\u001f\u007f]/u.test(snapshotPath)
|
|| snapshotPath.trim() !== snapshotPath || /[\u0000-\u001f\u007f]/u.test(snapshotPath)
|
||||||
|| (state !== "operational" && state !== "migration_required")
|
|
||||||
) return undefined;
|
) 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 {
|
function requireWorkspaceRevision(value: unknown, expectedId: string): WorkspaceRevision {
|
||||||
@@ -280,7 +310,15 @@ function requireCanonicalWorkspace(value: unknown): CanonicalWorkspace {
|
|||||||
return workspace;
|
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> => {
|
export const getWorkspace = async (id: string): Promise<WorkspaceRecord> => {
|
||||||
const response = await apiFetch<unknown>(`/workspaces/${encodeURIComponent(id)}`);
|
const response = await apiFetch<unknown>(`/workspaces/${encodeURIComponent(id)}`);
|
||||||
const source = object(response);
|
const source = object(response);
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ test("submitting includes browser-local migrated preferences and calls onCreated
|
|||||||
workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low",
|
workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||||
})),
|
})),
|
||||||
http.get("/api/workspaces", () => HttpResponse.json([{
|
http.get("/api/workspaces", () => HttpResponse.json([{
|
||||||
id: "default", name: "default", file: "default.yaml", displayName: "Default",
|
id: "default", name: "default", file: "default.yaml", displayName: "Default", language: "en",
|
||||||
revision: { state: "operational" },
|
revision: workspaceRevisionFixture("default"),
|
||||||
}])),
|
}])),
|
||||||
http.get("/api/workspaces/default", () => HttpResponse.json({
|
http.get("/api/workspaces/default", () => HttpResponse.json({
|
||||||
workspace: canonicalWorkspaceFixture("default", ["zai/glm-5.2"], "zai/glm-5.2"),
|
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;
|
let policyRequestStarted = false;
|
||||||
const policyMayFinish = new Promise<void>((resolve) => { releasePolicy = resolve; });
|
const policyMayFinish = new Promise<void>((resolve) => { releasePolicy = resolve; });
|
||||||
const revision = {
|
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({
|
localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({
|
||||||
workspaceId: "psd-clinical", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
|
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", () => {
|
http.get("/api/workspaces", () => {
|
||||||
summaryRequestStarted = true;
|
summaryRequestStarted = true;
|
||||||
return HttpResponse.json([{
|
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,
|
revision,
|
||||||
}]);
|
}]);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ test("new sessions send the browser-selected workspace, model, provider, and thi
|
|||||||
}));
|
}));
|
||||||
server.use(
|
server.use(
|
||||||
http.get("/api/workspaces", () => HttpResponse.json([{
|
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",
|
||||||
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/workspaces/psd-clinical", () => HttpResponse.json({
|
http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({
|
||||||
workspace: canonicalWorkspaceFixture("psd-clinical"),
|
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 }) => {
|
http.post("/api/sessions", async ({ request }) => {
|
||||||
body = await request.json();
|
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({
|
http.get("/api/settings", () => HttpResponse.json({
|
||||||
workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium",
|
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({
|
http.get("/api/workspaces/psd", () => HttpResponse.json({
|
||||||
workspace: canonicalWorkspaceFixture("psd"),
|
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({
|
http.get("/api/models", () => HttpResponse.json({
|
||||||
models: [{ provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }],
|
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",
|
workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium",
|
||||||
})),
|
})),
|
||||||
http.get("/api/workspaces", () => HttpResponse.json([{
|
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",
|
||||||
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/workspaces/psd-clinical", () => HttpResponse.json({
|
http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({
|
||||||
workspace: canonicalWorkspaceFixture("psd-clinical"),
|
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: [
|
http.get("/api/models", () => HttpResponse.json({ models: [
|
||||||
{ provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true },
|
{ 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",
|
workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
|
||||||
}));
|
}));
|
||||||
const revision = (id: string) => ({
|
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(
|
server.use(
|
||||||
http.get("/api/settings", () => HttpResponse.json({
|
http.get("/api/settings", () => HttpResponse.json({
|
||||||
workspace: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
|
workspace: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
|
||||||
})),
|
})),
|
||||||
http.get("/api/workspaces", () => HttpResponse.json([
|
http.get("/api/workspaces", () => HttpResponse.json([
|
||||||
{ id: "research", name: "research", file: "research.yaml", displayName: "Research", revision: revision("research") },
|
{ 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", 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/research", () => HttpResponse.json({
|
http.get("/api/workspaces/research", () => HttpResponse.json({
|
||||||
workspace: canonicalWorkspaceFixture("research", ["deepseek/deepseek-v4-pro"]),
|
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",
|
workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
|
||||||
}));
|
}));
|
||||||
const revision = (id: string) => ({
|
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(
|
server.use(
|
||||||
http.get("/api/settings", () => HttpResponse.json({ workspace: "research" })),
|
http.get("/api/settings", () => HttpResponse.json({ workspace: "research" })),
|
||||||
http.get("/api/workspaces", () => HttpResponse.json([
|
http.get("/api/workspaces", () => HttpResponse.json([
|
||||||
{ id: "research", name: "research", file: "research.yaml", displayName: "Research", revision: revision("research") },
|
{ 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", 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/research", () => HttpResponse.json({
|
http.get("/api/workspaces/research", () => HttpResponse.json({
|
||||||
workspace: canonicalWorkspaceFixture("research", ["deepseek/deepseek-v4-pro"]), revision: revision("research"),
|
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",
|
workspaceId: "psd-clinical", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
|
||||||
}));
|
}));
|
||||||
const revision = (id: string) => ({
|
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(
|
server.use(
|
||||||
http.get("/api/settings", () => HttpResponse.json({ workspace: "psd-clinical" })),
|
http.get("/api/settings", () => HttpResponse.json({ workspace: "psd-clinical" })),
|
||||||
http.get("/api/workspaces", () => HttpResponse.json([
|
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 () => {
|
http.get("/api/workspaces/psd-clinical", async () => {
|
||||||
policyRequestStarted = true;
|
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<void>((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(<QueryClientProvider client={client}><ComposerFooter /><SteerInput sessionId={null} onSessionCreateFailed={(message) => { failure = message; }} /></QueryClientProvider>);
|
|
||||||
|
|
||||||
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 () => {
|
test("initial submit rejects a workspace summary that omits the canonical revision", async () => {
|
||||||
let body: unknown;
|
let body: unknown;
|
||||||
let failure: string | undefined;
|
let failure: string | undefined;
|
||||||
@@ -399,7 +359,7 @@ test("initial submit rejects a workspace summary that omits the canonical revisi
|
|||||||
server.use(
|
server.use(
|
||||||
http.get("/api/settings", () => HttpResponse.json({ workspace: "broken-workspace" })),
|
http.get("/api/settings", () => HttpResponse.json({ workspace: "broken-workspace" })),
|
||||||
http.get("/api/workspaces", () => HttpResponse.json([{
|
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: [
|
http.get("/api/models", () => HttpResponse.json({ models: [
|
||||||
{ provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true },
|
{ 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",
|
workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
|
||||||
}));
|
}));
|
||||||
const revision = (id: string) => ({
|
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(
|
server.use(
|
||||||
http.get("/api/settings", () => HttpResponse.json({ workspace: "research" })),
|
http.get("/api/settings", () => HttpResponse.json({ workspace: "research" })),
|
||||||
http.get("/api/workspaces", () => HttpResponse.json([
|
http.get("/api/workspaces", () => HttpResponse.json([
|
||||||
{ id: "research", name: "research", file: "research.yaml", displayName: "Research", revision: revision("research") },
|
{ 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", revision: revision("workspace-b") },
|
{ 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", revision: revision("workspace-c") },
|
{ 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({
|
http.get("/api/workspaces/research", () => HttpResponse.json({
|
||||||
workspace: canonicalWorkspaceFixture("research", ["deepseek/deepseek-v4-pro"]), revision: revision("research"),
|
workspace: canonicalWorkspaceFixture("research", ["deepseek/deepseek-v4-pro"]), revision: revision("research"),
|
||||||
|
|||||||
@@ -203,8 +203,6 @@ export function ComposerFooter() {
|
|||||||
workspacePolicyGate.rejectSummary(workspace);
|
workspacePolicyGate.rejectSummary(workspace);
|
||||||
} else if (selectedWorkspace && !selectedWorkspaceHasRevision) {
|
} else if (selectedWorkspace && !selectedWorkspaceHasRevision) {
|
||||||
workspacePolicyGate.rejectSummary(workspace);
|
workspacePolicyGate.rejectSummary(workspace);
|
||||||
} else if (selectedWorkspace?.revision?.state === "migration_required") {
|
|
||||||
workspacePolicyGate.rejectSummary(workspace);
|
|
||||||
} else if (selectedWorkspace?.revision) {
|
} else if (selectedWorkspace?.revision) {
|
||||||
workspacePolicyGate.select(workspace);
|
workspacePolicyGate.select(workspace);
|
||||||
} else {
|
} else {
|
||||||
@@ -243,7 +241,6 @@ export function ComposerFooter() {
|
|||||||
if (patch.workspaceId && patch.workspaceId !== workspace) {
|
if (patch.workspaceId && patch.workspaceId !== workspace) {
|
||||||
const selected = workspaces.find((candidate) => candidate.id === patch.workspaceId);
|
const selected = workspaces.find((candidate) => candidate.id === patch.workspaceId);
|
||||||
if (selected && !selected.revision) workspacePolicyGate.rejectSummary(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 if (selected?.revision) workspacePolicyGate.select(patch.workspaceId);
|
||||||
else workspacePolicyGate.allowLegacy(patch.workspaceId);
|
else workspacePolicyGate.allowLegacy(patch.workspaceId);
|
||||||
}
|
}
|
||||||
@@ -269,8 +266,8 @@ export function ComposerFooter() {
|
|||||||
{workspaces.length === 0 ? (
|
{workspaces.length === 0 ? (
|
||||||
<option value="">— workspace —</option>
|
<option value="">— workspace —</option>
|
||||||
) : (
|
) : (
|
||||||
workspaces.filter((w) => w.revision && w.revision.state !== "migration_required").map((w) => (
|
workspaces.filter((w) => w.revision).map((w) => (
|
||||||
<option key={w.name} value={w.name}>
|
<option key={w.id} value={w.id}>
|
||||||
{w.name}
|
{w.name}
|
||||||
</option>
|
</option>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -27,13 +27,13 @@ beforeEach(() => {
|
|||||||
http.get("/api/workspace-registry/status", () =>
|
http.get("/api/workspace-registry/status", () =>
|
||||||
HttpResponse.json({ branch: "main", head: "a".repeat(40), ahead: 0, behind: 0, degraded: false })),
|
HttpResponse.json({ branch: "main", head: "a".repeat(40), ahead: 0, behind: 0, degraded: false })),
|
||||||
http.get("/api/workspaces", () => HttpResponse.json([{
|
http.get("/api/workspaces", () => HttpResponse.json([{
|
||||||
id: "psd-clinical", name: "PSD Clinical", displayName: "PSD Clinical", description: "Clinical data",
|
id: "psd-clinical", name: "psd-clinical", displayName: "PSD Clinical", description: "Clinical data",
|
||||||
language: "en", file: "workspaces/psd-clinical.yaml",
|
language: "en", file: "psd-clinical.yaml",
|
||||||
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" },
|
||||||
}])),
|
}])),
|
||||||
http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({
|
http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({
|
||||||
workspace,
|
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 } };
|
const maxWorkspace = { ...workspace, workspace: { ...workspace.workspace, id: maxId } };
|
||||||
server.use(
|
server.use(
|
||||||
http.get("/api/workspaces", () => HttpResponse.json([{
|
http.get("/api/workspaces", () => HttpResponse.json([{
|
||||||
id: maxId, name: "Maximum", displayName: "Maximum", language: "en", file: `workspaces/${maxId}.yaml`,
|
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", state: "operational" },
|
revision: { id: maxId, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/maximum" },
|
||||||
}])),
|
}])),
|
||||||
http.get(`/api/workspaces/${maxId}`, () => HttpResponse.json({
|
http.get(`/api/workspaces/${maxId}`, () => HttpResponse.json({
|
||||||
workspace: maxWorkspace,
|
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();
|
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();
|
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();
|
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();
|
renderManager();
|
||||||
|
|
||||||
await user.click(await screen.findByRole("button", { name: "PSD Clinical" }));
|
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(await screen.findByLabelText("Vector collection")).toBeVisible();
|
||||||
expect(screen.queryByLabelText("Vector collection")).not.toBeInTheDocument();
|
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 () => {
|
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(
|
server.use(
|
||||||
http.get("/api/workspaces", () => HttpResponse.json([
|
http.get("/api/workspaces", () => HttpResponse.json([
|
||||||
{
|
{
|
||||||
id: "broken-workspace", name: "Broken workspace", displayName: "Broken workspace", description: "Broken data",
|
id: "broken-workspace", name: "broken-workspace", displayName: "Broken workspace", description: "Broken data",
|
||||||
language: "en", file: "workspaces/broken-workspace.yaml",
|
language: "en", file: "broken-workspace.yaml",
|
||||||
},
|
},
|
||||||
])),
|
])),
|
||||||
);
|
);
|
||||||
@@ -288,7 +280,7 @@ test("shows an accessible retry when the selected workspace detail query fails",
|
|||||||
calls += 1;
|
calls += 1;
|
||||||
return calls === 1 ? new HttpResponse(null, { status: 503 }) : HttpResponse.json({
|
return calls === 1 ? new HttpResponse(null, { status: 503 }) : HttpResponse.json({
|
||||||
workspace,
|
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();
|
renderManager();
|
||||||
|
|||||||
@@ -64,11 +64,10 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
|
|||||||
const workspaces = workspacesQuery.data ?? [];
|
const workspaces = workspacesQuery.data ?? [];
|
||||||
const selectedSummary = useMemo(() => workspaces.find((workspace) => workspace.id === selectedId), [selectedId, workspaces]);
|
const selectedSummary = useMemo(() => workspaces.find((workspace) => workspace.id === selectedId), [selectedId, workspaces]);
|
||||||
const selectedSummaryIncomplete = Boolean(selectedSummary && !selectedSummary.revision);
|
const selectedSummaryIncomplete = Boolean(selectedSummary && !selectedSummary.revision);
|
||||||
const selectedIsLegacy = selectedSummary?.revision?.state === "migration_required";
|
|
||||||
const detailQuery = useQuery({
|
const detailQuery = useQuery({
|
||||||
queryKey: ["workspace", selectedId],
|
queryKey: ["workspace", selectedId],
|
||||||
queryFn: () => getWorkspace(selectedId!),
|
queryFn: () => getWorkspace(selectedId!),
|
||||||
enabled: Boolean(open && selectedId && !localDraft && !selectedSummaryIncomplete && !selectedIsLegacy),
|
enabled: Boolean(open && selectedId && !localDraft && !selectedSummaryIncomplete),
|
||||||
});
|
});
|
||||||
const status = statusQuery.data;
|
const status = statusQuery.data;
|
||||||
const record = detailQuery.data;
|
const record = detailQuery.data;
|
||||||
@@ -317,13 +316,8 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
|
|||||||
<p className="font-semibold">Workspace summary unavailable</p>
|
<p className="font-semibold">Workspace summary unavailable</p>
|
||||||
<p className="mt-1">This workspace summary is incomplete. Refresh the registry or choose another workspace before creating sessions or editing drafts.</p>
|
<p className="mt-1">This workspace summary is incomplete. Refresh the registry or choose another workspace before creating sessions or editing drafts.</p>
|
||||||
</div>
|
</div>
|
||||||
) : selectedIsLegacy ? (
|
|
||||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm">
|
|
||||||
<p className="font-semibold">Migration required</p>
|
|
||||||
<p className="mt-1">This workspace uses a legacy descriptor and must be migrated to schema v3 before new sessions or publication.</p>
|
|
||||||
</div>
|
|
||||||
) : detailQuery.isError && selectedId && !localDraft ? <QueryError name="Workspace details failed" message="Could not load workspace details." retryLabel="Retry workspace details" onRetry={() => { void detailQuery.refetch(); }} /> : !currentDraft && !detailQuery.isLoading && <div className="grid min-h-64 place-items-center text-center"><div><h3 className="font-heading font-semibold">Select a workspace</h3><p className="mt-1 text-sm text-muted-foreground">Review an existing definition or start a browser-only draft.</p></div></div>}
|
) : detailQuery.isError && selectedId && !localDraft ? <QueryError name="Workspace details failed" message="Could not load workspace details." retryLabel="Retry workspace details" onRetry={() => { void detailQuery.refetch(); }} /> : !currentDraft && !detailQuery.isLoading && <div className="grid min-h-64 place-items-center text-center"><div><h3 className="font-heading font-semibold">Select a workspace</h3><p className="mt-1 text-sm text-muted-foreground">Review an existing definition or start a browser-only draft.</p></div></div>}
|
||||||
{!selectedSummaryIncomplete && !selectedIsLegacy && !detailQuery.isError && (currentDraft || detailQuery.isLoading) && (
|
{!selectedSummaryIncomplete && !detailQuery.isError && (currentDraft || detailQuery.isLoading) && (
|
||||||
<>
|
<>
|
||||||
{detailQuery.isLoading && !currentDraft ? <p className="text-sm text-muted-foreground">Loading workspace definition…</p> : currentDraft && <>
|
{detailQuery.isLoading && !currentDraft ? <p className="text-sm text-muted-foreground">Loading workspace definition…</p> : currentDraft && <>
|
||||||
<div className="mb-5 flex flex-wrap items-start justify-between gap-3 border-b border-border/70 pb-4">
|
<div className="mb-5 flex flex-wrap items-start justify-between gap-3 border-b border-border/70 pb-4">
|
||||||
@@ -347,7 +341,7 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
|
|||||||
<section className="mt-6 border-t border-border/70 pt-5" aria-label="Git status and history">
|
<section className="mt-6 border-t border-border/70 pt-5" aria-label="Git status and history">
|
||||||
<h3 className="font-heading text-sm font-semibold">Git status & history</h3>
|
<h3 className="font-heading text-sm font-semibold">Git status & history</h3>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">{status?.degraded ? "Using the last valid local snapshot." : "Registry checkout is current."}</p>
|
<p className="mt-2 text-sm text-muted-foreground">{status?.degraded ? "Using the last valid local snapshot." : "Registry checkout is current."}</p>
|
||||||
{record && <p className="mt-1 font-mono text-xs text-muted-foreground">Revision {record.revision.commit.slice(0, 12)} · {record.revision.state}</p>}
|
{record && <p className="mt-1 font-mono text-xs text-muted-foreground">Revision {record.revision.commit.slice(0, 12)}</p>}
|
||||||
</section>
|
</section>
|
||||||
</>}
|
</>}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ test("validates a draft and requires a separate confirmation before publishing",
|
|||||||
let publishCalls = 0;
|
let publishCalls = 0;
|
||||||
server.use(http.post("/api/workspaces/publish", () => {
|
server.use(http.post("/api/workspaces/publish", () => {
|
||||||
publishCalls += 1;
|
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(<WorkspacePublishDialog open request={request} onOpenChange={vi.fn()} onPublished={published} onResolved={vi.fn()} onPull={vi.fn()} onReload={vi.fn()} />);
|
render(<WorkspacePublishDialog open request={request} onOpenChange={vi.fn()} onPublished={published} onResolved={vi.fn()} onPull={vi.fn()} onReload={vi.fn()} />);
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ test("F1: create session -> widget via SSE -> respond -> POST /response", async
|
|||||||
http.get("/api/settings", () => HttpResponse.json({
|
http.get("/api/settings", () => HttpResponse.json({
|
||||||
workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low",
|
workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||||
})),
|
})),
|
||||||
http.get("/api/workspaces", () =>
|
http.get("/api/workspaces", () => HttpResponse.json([])),
|
||||||
HttpResponse.json([{ name: "default", file: "default.db" }]),
|
|
||||||
),
|
|
||||||
http.get("/api/models", () => HttpResponse.json({ models: [] })),
|
http.get("/api/models", () => HttpResponse.json({ models: [] })),
|
||||||
http.post("/api/sessions/s1/response", async ({ request }) => {
|
http.post("/api/sessions/s1/response", async ({ request }) => {
|
||||||
responded = await request.json();
|
responded = await request.json();
|
||||||
|
|||||||
@@ -23,12 +23,9 @@ export function canonicalWorkspaceFixture(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function workspaceRevisionFixture(
|
export function workspaceRevisionFixture(id: string): WorkspaceRevision {
|
||||||
id: string,
|
|
||||||
state: WorkspaceRevision["state"] = "operational",
|
|
||||||
): WorkspaceRevision {
|
|
||||||
return {
|
return {
|
||||||
id, commit: "a".repeat(40), blob: "b".repeat(40),
|
id, commit: "a".repeat(40), blob: "b".repeat(40),
|
||||||
snapshotPath: `/snapshots/${"a".repeat(40)}/${id}.yaml`, state,
|
snapshotPath: `/snapshots/${"a".repeat(40)}/${id}.yaml`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user