feat: edit qdrant workspace collections

This commit is contained in:
2026-08-08 19:56:29 +02:00
parent af1e922a48
commit c3a5621737
15 changed files with 234 additions and 198 deletions
+12 -5
View File
@@ -24,7 +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" },
}])),
http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({
workspace: { llm_policy: { allowed: ["zai/glm-5.2"] } },
revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" },
})),
http.post("/api/sessions", async ({ request }) => {
body = await request.json();
return HttpResponse.json({ id: "s1" });
@@ -341,8 +346,9 @@ test("initial restored workspace waits for its delayed policy before creating a
}));
});
test("initial submit waits for delayed workspace summaries before allowing a confirmed legacy workspace", async () => {
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; });
@@ -356,8 +362,10 @@ test("initial submit waits for delayed workspace summaries before allowing a con
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 },
] })),
@@ -367,7 +375,7 @@ test("initial submit waits for delayed workspace summaries before allowing a con
}),
);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><ComposerFooter /><SteerInput sessionId={null} /></QueryClientProvider>);
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");
@@ -377,9 +385,8 @@ test("initial submit waits for delayed workspace summaries before allowing a con
expect(screen.getByRole("button", { name: /send/i })).toBeDisabled();
releaseSummaries();
await waitFor(() => expect(body).toEqual({
question: "q", workspaceId: "legacy-workspace", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
}));
await waitFor(() => expect(failure).toBe("Could not load selected workspace policy. Please retry."));
expect(body).toBeUndefined();
});
test("failed workspace summaries block creation and report a safe error", async () => {
+5 -2
View File
@@ -202,6 +202,8 @@ export function ComposerFooter() {
workspacePolicyGate.beginSummary(workspace);
} else if (workspaceSummariesError) {
workspacePolicyGate.rejectSummary(workspace);
} else if (selectedWorkspace?.revision?.state === "migration_required") {
workspacePolicyGate.rejectSummary(workspace);
} else if (selectedWorkspace?.revision) {
workspacePolicyGate.select(workspace);
} else {
@@ -239,7 +241,8 @@ export function ComposerFooter() {
function update(patch: WorkspacePreference) {
if (patch.workspaceId && patch.workspaceId !== workspace) {
const selected = workspaces.find((candidate) => candidate.id === patch.workspaceId);
if (selected?.revision) workspacePolicyGate.select(patch.workspaceId);
if (selected?.revision?.state === "migration_required") workspacePolicyGate.rejectSummary(patch.workspaceId);
else if (selected?.revision) workspacePolicyGate.select(patch.workspaceId);
else workspacePolicyGate.allowLegacy(patch.workspaceId);
}
const next = workspacePreferences.save({ ...preferences, ...patch });
@@ -264,7 +267,7 @@ export function ComposerFooter() {
{workspaces.length === 0 ? (
<option value="">— workspace —</option>
) : (
workspaces.map((w) => (
workspaces.filter((w) => w.revision?.state !== "migration_required").map((w) => (
<option key={w.name} value={w.name}>
{w.name}
</option>
+28 -13
View File
@@ -6,17 +6,16 @@ import type { WorkspaceDraft } from "../workspaces/drafts";
import { WorkspaceEditor } from "./WorkspaceEditor";
const workspace: CanonicalWorkspace = {
workspace: { schema_version: 2, id: "psd-clinical", name: "PSD Clinical", language: "en" },
workspace: { schema_version: 3, id: "psd-clinical", name: "PSD Clinical", language: "en" },
dwh: {
engine: "postgres", database: "clinical", schema: "datawarehouse", port: 5432,
supported_transports: ["postgres_direct"],
},
semantic_index: {
vector_store: {
engine: "pgvector", database: "vectors", schema: "public", collection: "clinical",
dimensions: 768, distance: "cosine", port: 5432, supported_transports: ["pgvector_direct"],
engine: "qdrant", collection: "clinical", dimensions: 1024, distance: "cosine",
},
embedding: { provider: "ollama_compatible", model: "nomic-embed-text", dimensions: 768 },
embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 },
},
llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] },
};
@@ -42,33 +41,49 @@ test("uses closed choices for transport and rejects an invalid free-form port be
expect(screen.getByLabelText("DWH port")).toHaveAttribute("aria-invalid", "true");
});
test("keeps vector dimensions and embedding dimensions atomic in a draft", async () => {
test("saves only the editable collection while preserving the fixed schema-v3 semantic architecture", async () => {
const user = userEvent.setup();
const onSaveDraft = vi.fn();
render(<WorkspaceEditor draft={draft} onSaveDraft={onSaveDraft} onPublish={vi.fn()} />);
await user.clear(screen.getByLabelText("Semantic index dimensions"));
await user.type(screen.getByLabelText("Semantic index dimensions"), "1024");
await user.clear(screen.getByLabelText("Vector collection"));
await user.type(screen.getByLabelText("Vector collection"), "research_docs");
await user.click(screen.getByRole("button", { name: "Save draft" }));
expect(onSaveDraft).toHaveBeenCalledWith(expect.objectContaining({
workspace: expect.objectContaining({
semantic_index: expect.objectContaining({
vector_store: expect.objectContaining({ dimensions: 1024 }),
embedding: expect.objectContaining({ dimensions: 1024 }),
vector_store: {
engine: "qdrant",
collection: "research_docs",
dimensions: 1024,
distance: "cosine",
},
embedding: {
provider: "ollama_internal",
model: "qwen3-embedding:0.6b",
dimensions: 1024,
},
}),
}),
}));
});
test("uses native closed selects for each workspace enum and embedding provider", () => {
test("shows fixed architecture values and no editable endpoint or credential controls", () => {
render(<WorkspaceEditor draft={draft} onSaveDraft={vi.fn()} onPublish={vi.fn()} />);
expect(screen.getByRole("combobox", { name: "Workspace language" })).toHaveValue("en");
expect(screen.getByRole("combobox", { name: "Vector distance" })).toHaveValue("cosine");
expect(screen.getByRole("combobox", { name: "Embedding provider" })).toHaveValue("ollama_compatible");
expect(screen.getByRole("listbox", { name: "DWH transport" })).toHaveProperty("multiple", true);
expect(screen.getByRole("listbox", { name: "Vector transport" })).toHaveProperty("multiple", true);
expect(screen.getByLabelText("Vector store engine")).toHaveValue("qdrant");
expect(screen.getByLabelText("Vector distance")).toHaveValue("cosine");
expect(screen.getByLabelText("Semantic index dimensions")).toHaveValue(1024);
expect(screen.getByLabelText("Embedding provider")).toHaveValue("ollama_internal");
expect(screen.getByLabelText("Embedding model")).toHaveValue("qwen3-embedding:0.6b");
expect(screen.queryByLabelText("Vector database")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Vector schema")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Vector port")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Vector transport")).not.toBeInTheDocument();
expect(screen.queryByLabelText(/api[- ]key|endpoint|base url/i)).not.toBeInTheDocument();
});
test("rejects a non-positive DWH timeout without saving a draft", async () => {
+16 -31
View File
@@ -17,14 +17,14 @@ const EMPTY_COMMIT = "0".repeat(40);
function emptyWorkspace(): CanonicalWorkspace {
return {
workspace: { schema_version: 2, id: "new-workspace", name: "New workspace", language: "en" },
workspace: { schema_version: 3, id: "new-workspace", name: "New workspace", language: "en" },
dwh: { engine: "postgres", database: "database", schema: "public", supported_transports: ["postgres_direct"] },
semantic_index: {
vector_store: {
engine: "pgvector", database: "vectors", schema: "public", collection: "documents",
dimensions: 768, distance: "cosine", supported_transports: ["pgvector_direct"],
engine: "qdrant", collection: "documents",
dimensions: 1024, distance: "cosine",
},
embedding: { provider: "ollama_compatible", model: "nomic-embed-text", dimensions: 768 },
embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 },
},
llm_policy: { allowed: ["zai/glm-5.2"] },
};
@@ -51,18 +51,15 @@ function validate(workspace: CanonicalWorkspace): FieldErrors {
if (dwhPort) errors["dwh.port"] = dwhPort;
const dwhTimeout = positiveInteger(workspace.dwh.timeout_ms, "DWH timeout");
if (dwhTimeout) errors["dwh.timeout"] = dwhTimeout;
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(workspace.semantic_index.vector_store.database)) errors["vector.database"] = "Use a database identifier";
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(workspace.semantic_index.vector_store.schema)) errors["vector.schema"] = "Use a schema identifier";
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(workspace.semantic_index.vector_store.collection)) errors["vector.collection"] = "Use a collection identifier";
if (!workspace.semantic_index.vector_store.supported_transports.length) errors["vector.transport"] = "Choose at least one vector transport";
const vectorPort = positiveInteger(workspace.semantic_index.vector_store.port, "Vector port", 65_535);
if (vectorPort) errors["vector.port"] = vectorPort;
const dimensions = positiveInteger(workspace.semantic_index.vector_store.dimensions, "Dimensions", 32_768);
if (dimensions) errors["semantic.dimensions"] = dimensions;
if (workspace.semantic_index.embedding.dimensions !== workspace.semantic_index.vector_store.dimensions) {
errors["semantic.dimensions"] = "Vector and embedding dimensions must match";
if (workspace.semantic_index.vector_store.engine !== "qdrant") errors["semantic.engine"] = "Vector store engine is fixed to qdrant";
if (workspace.semantic_index.vector_store.dimensions !== 1024) errors["semantic.dimensions"] = "Semantic index dimensions are fixed to 1024";
if (workspace.semantic_index.vector_store.distance !== "cosine") errors["semantic.distance"] = "Vector distance is fixed to cosine";
if (workspace.semantic_index.embedding.provider !== "ollama_internal") errors["embedding.provider"] = "Embedding provider is fixed to ollama_internal";
if (workspace.semantic_index.embedding.model !== "qwen3-embedding:0.6b") errors["embedding.model"] = "Embedding model is fixed to qwen3-embedding:0.6b";
if (workspace.semantic_index.embedding.dimensions !== 1024 || workspace.semantic_index.embedding.dimensions !== workspace.semantic_index.vector_store.dimensions) {
errors["semantic.dimensions"] = "Vector and embedding dimensions are fixed to 1024";
}
if (!workspace.semantic_index.embedding.model.trim()) errors["embedding.model"] = "Embedding model is required";
if (!workspace.llm_policy.allowed.length || workspace.llm_policy.allowed.some((model) => !/^[^/\s]+\/[^/\s]+$/.test(model))) {
errors["llm.allowed"] = "Use provider/model entries separated by commas";
}
@@ -195,34 +192,22 @@ export function WorkspaceEditor({ draft, onSaveDraft, onPublish, idLocked = Bool
<Section title="Semantic index">
<Field label="Vector store engine">
{({ id, describedBy, invalid }) => <select id={id} aria-label="Vector store engine" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value="pgvector" disabled><option value="pgvector">pgvector</option></select>}
</Field>
<Field label="Vector transport" error={errors["vector.transport"]}>
{({ id, describedBy, invalid }) => <select id={id} multiple aria-label="Vector transport" aria-describedby={describedBy} aria-invalid={invalid} className={`${fieldClass} h-24 py-1`} value={workspace.semantic_index.vector_store.supported_transports} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, supported_transports: selectedValues(event) as CanonicalWorkspace["semantic_index"]["vector_store"]["supported_transports"] } } }))}><option value="pgvector_direct">pgvector_direct</option><option value="rest_api">rest_api</option><option value="ssh_tunnel">ssh_tunnel</option></select>}
</Field>
<Field label="Vector database" error={errors["vector.database"]}>
{({ id, describedBy, invalid }) => <input id={id} aria-label="Vector database" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.vector_store.database} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, database: event.target.value } } }))} />}
</Field>
<Field label="Vector schema" error={errors["vector.schema"]}>
{({ id, describedBy, invalid }) => <input id={id} aria-label="Vector schema" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.vector_store.schema} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, schema: event.target.value } } }))} />}
{({ id, describedBy, invalid }) => <input id={id} aria-label="Vector store engine" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value="qdrant" readOnly />}
</Field>
<Field label="Vector collection" error={errors["vector.collection"]}>
{({ id, describedBy, invalid }) => <input id={id} aria-label="Vector collection" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.vector_store.collection} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, collection: event.target.value } } }))} />}
</Field>
<Field label="Vector port" error={errors["vector.port"]}>
{({ id, describedBy, invalid }) => <input id={id} type="number" min="1" max="65535" aria-label="Vector port" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.vector_store.port ?? ""} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, port: numberOrUndefined(event.target.value) } } }))} />}
</Field>
<Field label="Vector distance">
{({ id, describedBy, invalid }) => <select id={id} aria-label="Vector distance" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.vector_store.distance} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, distance: event.target.value as "cosine" | "l2" | "inner_product" } } }))}><option value="cosine">cosine</option><option value="l2">l2</option><option value="inner_product">inner_product</option></select>}
{({ id, describedBy, invalid }) => <input id={id} aria-label="Vector distance" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value="cosine" readOnly />}
</Field>
<Field label="Semantic index dimensions" error={errors["semantic.dimensions"]} hint="One value is shared by the vector collection and embedding model.">
{({ id, describedBy, invalid }) => <input id={id} type="number" min="1" max="32768" aria-label="Semantic index dimensions" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.vector_store.dimensions} onChange={(event) => update((value) => { const dimensions = numberOrUndefined(event.target.value) ?? 0; return { ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, dimensions }, embedding: { ...value.semantic_index.embedding, dimensions } } }; })} />}
{({ id, describedBy, invalid }) => <input id={id} type="number" min="1" max="32768" aria-label="Semantic index dimensions" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.vector_store.dimensions} readOnly />}
</Field>
<Field label="Embedding provider">
{({ id, describedBy, invalid }) => <select id={id} aria-label="Embedding provider" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.embedding.provider} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, embedding: { ...value.semantic_index.embedding, provider: event.target.value as "ollama_compatible" | "openai_compatible" } } }))}><option value="ollama_compatible">ollama_compatible</option><option value="openai_compatible">openai_compatible</option></select>}
{({ id, describedBy, invalid }) => <input id={id} aria-label="Embedding provider" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value="ollama_internal" readOnly />}
</Field>
<Field label="Embedding model" error={errors["embedding.model"]}>
{({ id, describedBy, invalid }) => <input id={id} aria-label="Embedding model" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.embedding.model} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, embedding: { ...value.semantic_index.embedding, model: event.target.value } } }))} />}
{({ id, describedBy, invalid }) => <input id={id} aria-label="Embedding model" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value="qwen3-embedding:0.6b" readOnly />}
</Field>
</Section>
+26 -7
View File
@@ -7,11 +7,11 @@ import { server } from "../test/msw";
import { WorkspaceManager } from "./WorkspaceManager";
const workspace = {
workspace: { schema_version: 2, id: "psd-clinical", name: "PSD Clinical", language: "en" },
workspace: { schema_version: 3, id: "psd-clinical", name: "PSD Clinical", language: "en" },
dwh: { engine: "postgres", database: "clinical", schema: "datawarehouse", port: 5432, supported_transports: ["postgres_direct"] },
semantic_index: {
vector_store: { engine: "pgvector", database: "vectors", schema: "public", collection: "clinical", dimensions: 768, distance: "cosine", supported_transports: ["pgvector_direct"] },
embedding: { provider: "ollama_compatible", model: "nomic-embed-text", dimensions: 768 },
vector_store: { engine: "qdrant", collection: "clinical", dimensions: 1024, distance: "cosine" },
embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 },
},
llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] },
} as const;
@@ -128,14 +128,14 @@ test("stages duplicate and delete operations without publishing", async () => {
test("saves resolved conflict choices as a rebased browser draft without publishing again", async () => {
const user = userEvent.setup();
let publishCalls = 0;
const local = { ...workspace, semantic_index: { ...workspace.semantic_index, embedding: { ...workspace.semantic_index.embedding, model: "local-model" } } };
const remote = { ...workspace, semantic_index: { ...workspace.semantic_index, embedding: { ...workspace.semantic_index.embedding, model: "remote-model" } } };
const local = { ...workspace, semantic_index: { ...workspace.semantic_index, vector_store: { ...workspace.semantic_index.vector_store, collection: "local_collection" } } };
const remote = { ...workspace, semantic_index: { ...workspace.semantic_index, vector_store: { ...workspace.semantic_index.vector_store, collection: "remote_collection" } } };
server.use(
http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace: local, contract: {} })),
http.post("/api/workspaces/publish", () => {
publishCalls += 1;
return HttpResponse.json({
code: "workspace_conflict", message: "Workspace changed in the registry.", fields: ["semantic_index.embedding.model"],
code: "workspace_conflict", message: "Workspace changed in the registry.", fields: ["semantic_index.vector_store.collection"],
expected: { commit: "a".repeat(40), blob: "b".repeat(40) }, actual: { commit: "c".repeat(40), blob: "d".repeat(40) },
base: workspace, local, remote,
}, { status: 409 });
@@ -148,7 +148,7 @@ test("saves resolved conflict choices as a rebased browser draft without publish
await user.click(screen.getByRole("button", { name: "Validate draft" }));
await user.click(await screen.findByRole("button", { name: "Publish" }));
await user.click(screen.getByRole("button", { name: "Confirm publish" }));
await user.click(await screen.findByRole("radio", { name: "Use your draft for semantic_index.embedding.model" }));
await user.click(await screen.findByRole("radio", { name: "Use your draft for semantic_index.vector_store.collection" }));
await user.click(screen.getByRole("button", { name: "Save revised draft" }));
expect(await screen.findByText("Revised draft saved with registry revision cccccccccccc. Validate it before publishing.")).toBeVisible();
@@ -213,6 +213,25 @@ 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 () => {
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();
});
test("shows an accessible retry instead of a loading status when the registry status query fails", async () => {
const user = userEvent.setup();
let calls = 0;
+14 -8
View File
@@ -16,11 +16,11 @@ const EMPTY_COMMIT = "0".repeat(40);
function newWorkspace(): CanonicalWorkspace {
return {
workspace: { schema_version: 2, id: "new-workspace", name: "New workspace", language: "en" },
workspace: { schema_version: 3, id: "new-workspace", name: "New workspace", language: "en" },
dwh: { engine: "postgres", database: "database", schema: "public", supported_transports: ["postgres_direct"] },
semantic_index: {
vector_store: { engine: "pgvector", database: "vectors", schema: "public", collection: "documents", dimensions: 768, distance: "cosine", supported_transports: ["pgvector_direct"] },
embedding: { provider: "ollama_compatible", model: "nomic-embed-text", dimensions: 768 },
vector_store: { engine: "qdrant", collection: "documents", dimensions: 1024, distance: "cosine" },
embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 },
},
llm_policy: { allowed: ["zai/glm-5.2"] },
};
@@ -61,13 +61,15 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
const [transferring, setTransferring] = useState(false);
const statusQuery = useQuery({ queryKey: ["workspace-registry-status"], queryFn: getWorkspaceRegistryStatus, enabled: open });
const workspacesQuery = useQuery({ queryKey: ["workspaces"], queryFn: listWorkspaces, enabled: open });
const workspaces = workspacesQuery.data ?? [];
const selectedSummary = useMemo(() => workspaces.find((workspace) => workspace.id === selectedId), [selectedId, workspaces]);
const selectedIsLegacy = selectedSummary?.revision.state === "migration_required";
const detailQuery = useQuery({
queryKey: ["workspace", selectedId],
queryFn: () => getWorkspace(selectedId!),
enabled: Boolean(open && selectedId && !localDraft),
enabled: Boolean(open && selectedId && !localDraft && !selectedIsLegacy),
});
const status = statusQuery.data;
const workspaces = workspacesQuery.data ?? [];
const record = detailQuery.data;
const savedDraft = selectedId && !localDraft ? workspaceDrafts.load(selectedId) : undefined;
@@ -75,7 +77,6 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
const currentDraft = localDraft ?? savedDraft ?? (record ? draftFromRecord(record) : undefined);
const activeDeletionDraft = deletionDraft ?? savedDeletionDraft;
const canTest = Boolean(record && currentDraft?.workspaceId === record.workspace.workspace.id);
const selectedSummary = useMemo(() => workspaces.find((workspace) => workspace.id === selectedId), [selectedId, workspaces]);
function selectWorkspace(id: string) {
setSelectedId(id);
@@ -310,8 +311,13 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
</nav>
<div className="min-w-0 overflow-y-auto px-5 py-5">
{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 && (currentDraft || detailQuery.isLoading) && (
{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>}
{!selectedIsLegacy && !detailQuery.isError && (currentDraft || detailQuery.isLoading) && (
<>
{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">
@@ -7,11 +7,11 @@ import { server } from "../test/msw";
import { WorkspacePublishDialog } from "./WorkspacePublishDialog";
const workspace: CanonicalWorkspace = {
workspace: { schema_version: 2, id: "psd-clinical", name: "PSD Clinical", language: "en" },
workspace: { schema_version: 3, id: "psd-clinical", name: "PSD Clinical", language: "en" },
dwh: { engine: "postgres", database: "clinical", schema: "datawarehouse", supported_transports: ["postgres_direct"] },
semantic_index: {
vector_store: { engine: "pgvector", database: "vectors", schema: "public", collection: "clinical", dimensions: 768, distance: "cosine", supported_transports: ["pgvector_direct"] },
embedding: { provider: "ollama_compatible", model: "nomic-embed-text", dimensions: 768 },
vector_store: { engine: "qdrant", collection: "clinical", dimensions: 1024, distance: "cosine" },
embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 },
},
llm_policy: { allowed: ["zai/glm-5.2"] },
};
@@ -22,12 +22,12 @@ const request: PublishWorkspaceRequest = {
const conflict: WorkspaceConflict = {
code: "workspace_conflict",
fields: ["semantic_index.embedding.model"],
fields: ["semantic_index.vector_store.collection"],
expected: { commit: "a".repeat(40), blob: "b".repeat(40) },
actual: { commit: "c".repeat(40), blob: "d".repeat(40) },
base: workspace,
local: { ...workspace, semantic_index: { ...workspace.semantic_index, embedding: { ...workspace.semantic_index.embedding, model: "local-model" } } },
remote: { ...workspace, semantic_index: { ...workspace.semantic_index, embedding: { ...workspace.semantic_index.embedding, model: "remote-model" } } },
local: { ...workspace, semantic_index: { ...workspace.semantic_index, vector_store: { ...workspace.semantic_index.vector_store, collection: "local_collection" } } },
remote: { ...workspace, semantic_index: { ...workspace.semantic_index, vector_store: { ...workspace.semantic_index.vector_store, collection: "remote_collection" } } },
};
const diagnosticsBranchConflict: WorkspaceConflict = {
@@ -81,11 +81,11 @@ test("shows a field-level conflict and never overwrites the remote workspace", a
await user.click(await screen.findByRole("button", { name: "Publish" }));
await user.click(screen.getByRole("button", { name: "Confirm publish" }));
expect(await screen.findByText("semantic_index.embedding.model")).toBeVisible();
expect(screen.getByText("local-model")).toBeVisible();
expect(screen.getByText("remote-model")).toBeVisible();
expect(screen.getByRole("radio", { name: "Use your draft for semantic_index.embedding.model" })).toBeVisible();
expect(screen.getByRole("radio", { name: "Use registry value for semantic_index.embedding.model" })).toBeVisible();
expect(await screen.findByText("semantic_index.vector_store.collection")).toBeVisible();
expect(screen.getByText("local_collection")).toBeVisible();
expect(screen.getByText("remote_collection")).toBeVisible();
expect(screen.getByRole("radio", { name: "Use your draft for semantic_index.vector_store.collection" })).toBeVisible();
expect(screen.getByRole("radio", { name: "Use registry value for semantic_index.vector_store.collection" })).toBeVisible();
expect(screen.getByRole("button", { name: "Save revised draft" })).toBeDisabled();
expect(published).toBe(true);
});
@@ -103,12 +103,12 @@ test("saves explicit local choices as a rebased draft and does not republish it"
await user.click(screen.getByRole("button", { name: "Validate draft" }));
await user.click(await screen.findByRole("button", { name: "Publish" }));
await user.click(screen.getByRole("button", { name: "Confirm publish" }));
await user.click(await screen.findByRole("radio", { name: "Use your draft for semantic_index.embedding.model" }));
await user.click(await screen.findByRole("radio", { name: "Use your draft for semantic_index.vector_store.collection" }));
await user.click(screen.getByRole("button", { name: "Save revised draft" }));
expect(saved).toHaveBeenCalledWith(expect.objectContaining({
baseCommit: "c".repeat(40), baseBlob: "d".repeat(40),
workspace: expect.objectContaining({ semantic_index: expect.objectContaining({ embedding: expect.objectContaining({ model: "local-model" }) }) }),
workspace: expect.objectContaining({ semantic_index: expect.objectContaining({ vector_store: expect.objectContaining({ collection: "local_collection" }) }) }),
}));
expect(publishCalls).toBe(1);
});