feat: edit qdrant workspace collections
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
Status: completed on August 8, 2026.
|
||||
|
||||
Summary:
|
||||
- Updated the frontend workspace contract from schema v2 editing to schema v3 publishing.
|
||||
- Kept only `semantic_index.vector_store.collection` editable; rendered qdrant / internal Ollama semantic values as fixed read-only architecture values.
|
||||
- Removed external vector transport / endpoint / credential / embedding diagnostics branches from frontend draft sanitization, conflict parsing, and editor UI.
|
||||
- Added a migration-required banner in workspace management and blocked `migration_required` workspaces from new-session selection.
|
||||
- Aligned the example workspace YAML comments with the fixed internal qdrant/Ollama architecture.
|
||||
|
||||
Files changed:
|
||||
- `frontend/src/api/workspaces.ts`
|
||||
- `frontend/src/api/workspaces.test.ts`
|
||||
- `frontend/src/workspaces/drafts.ts`
|
||||
- `frontend/src/workspaces/drafts.test.ts`
|
||||
- `frontend/src/shell/WorkspaceEditor.tsx`
|
||||
- `frontend/src/shell/WorkspaceEditor.test.tsx`
|
||||
- `frontend/src/shell/WorkspaceManager.tsx`
|
||||
- `frontend/src/shell/WorkspaceManager.test.tsx`
|
||||
- `frontend/src/shell/WorkspacePublishDialog.test.tsx`
|
||||
- `frontend/src/api/sessions.ts`
|
||||
- `frontend/src/shell/SteerInput.tsx`
|
||||
- `frontend/src/shell/SteerInput.test.tsx`
|
||||
- `deploy/workspaces/example.yaml`
|
||||
- `deploy/workspaces/psd.yaml.example`
|
||||
|
||||
Verification:
|
||||
- `cd frontend && npx vitest run src/shell/SteerInput.test.tsx src/shell/WorkspaceEditor.test.tsx src/shell/WorkspaceManager.test.tsx src/shell/WorkspacePublishDialog.test.tsx src/workspaces/drafts.test.ts src/api/workspaces.test.ts`
|
||||
- Result: 6 files passed, 59 tests passed.
|
||||
- `cd frontend && npx tsc -b`
|
||||
- Result: passed.
|
||||
- `git diff --check`
|
||||
- Result: passed.
|
||||
|
||||
Self-review:
|
||||
- The frontend now publishes the exact schema v3 semantic shape and no longer persists legacy semantic transport/credential branches.
|
||||
- Migration-required workspaces are visible in management with an explicit banner and are excluded from the composer workspace selector.
|
||||
- One dependent test file outside the original brief list (`WorkspacePublishDialog.test.tsx`) and the composer/session-selection path (`api/sessions.ts`, `SteerInput.tsx`, related test) were updated because they were directly coupled to the old v2 semantic/edit-selection behavior.
|
||||
|
||||
Concerns:
|
||||
- The composer still retains backward-compatible behavior for summaries that omit `revision` entirely; only explicit `revision.state === "migration_required"` is blocked. That matches the current mixed-test environment, but once summary responses are guaranteed to include `revision`, that fallback may be removable.
|
||||
@@ -37,10 +37,13 @@ evidence:
|
||||
|
||||
resources:
|
||||
vector:
|
||||
# Schema-v3 descriptors publish only the collection identity; the runtime renderer
|
||||
# supplies this fixed internal Qdrant architecture.
|
||||
engine: qdrant
|
||||
base_url: http://qdrant:6333
|
||||
collection: example
|
||||
embeddings:
|
||||
# Embeddings are fixed to the internal Ollama service for schema-v3 descriptors.
|
||||
provider: ollama_internal
|
||||
base_url: http://embedding:11434
|
||||
model: qwen3-embedding:0.6b
|
||||
|
||||
@@ -21,10 +21,13 @@ evidence:
|
||||
|
||||
resources:
|
||||
vector:
|
||||
# Schema-v3 descriptors publish only the collection identity; the runtime renderer
|
||||
# supplies this fixed internal Qdrant architecture.
|
||||
engine: qdrant
|
||||
base_url: http://qdrant:6333
|
||||
collection: generic
|
||||
embeddings:
|
||||
# Embeddings are fixed to the internal Ollama service for schema-v3 descriptors.
|
||||
provider: ollama_internal
|
||||
base_url: http://embedding:11434
|
||||
model: qwen3-embedding:0.6b
|
||||
|
||||
@@ -59,6 +59,10 @@ async function ensureWorkspaceSelectionPolicy(): Promise<WorkspacePreference> {
|
||||
throw new WorkspaceSelectionError(WORKSPACE_SUMMARY_ERROR);
|
||||
}
|
||||
if (workspacePreferences.load().workspaceId !== workspaceId) continue;
|
||||
if (workspace?.revision.state === "migration_required") {
|
||||
workspacePolicyGate.rejectSummary(workspaceId);
|
||||
throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR);
|
||||
}
|
||||
if (!workspace?.revision) {
|
||||
workspacePolicyGate.allowLegacy(workspaceId);
|
||||
return workspacePreferences.load();
|
||||
|
||||
@@ -4,11 +4,11 @@ import { server } from "../test/msw";
|
||||
import { asWorkspaceConflict, importWorkspace, publishWorkspace, type CanonicalWorkspace } from "./workspaces";
|
||||
|
||||
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"] },
|
||||
};
|
||||
@@ -42,33 +42,18 @@ test("rejects a conflict payload that attempts to surface a secret field", async
|
||||
const diagnosticConflictFields = [
|
||||
"diagnostics.dwh_rest.method", "diagnostics.dwh_rest.path", "diagnostics.dwh_rest.auth",
|
||||
"diagnostics.dwh_rest.response.database", "diagnostics.dwh_rest.response.schema",
|
||||
"diagnostics.vector_rest.metadata.method", "diagnostics.vector_rest.metadata.path", "diagnostics.vector_rest.metadata.auth",
|
||||
"diagnostics.vector_rest.metadata.response.collection", "diagnostics.vector_rest.metadata.response.dimensions",
|
||||
"diagnostics.vector_rest.metadata.response.distance", "diagnostics.vector_rest.reversible_probe.method",
|
||||
"diagnostics.vector_rest.reversible_probe.path", "diagnostics.vector_rest.reversible_probe.auth",
|
||||
"diagnostics.vector_rest.reversible_probe.response.operation", "diagnostics.embedding.method", "diagnostics.embedding.path",
|
||||
"diagnostics.embedding.auth", "diagnostics.embedding.response.model", "diagnostics.embedding.response.dimensions",
|
||||
] as const;
|
||||
|
||||
const optionalDiagnosticsConflictFields = [
|
||||
"diagnostics",
|
||||
"diagnostics.dwh_rest",
|
||||
"diagnostics.vector_rest",
|
||||
"diagnostics.vector_rest.reversible_probe",
|
||||
"diagnostics.embedding",
|
||||
] as const;
|
||||
|
||||
const diagnosticsWorkspace: CanonicalWorkspace = {
|
||||
...workspace,
|
||||
dwh: { ...workspace.dwh, supported_transports: ["postgres_direct", "rest_api"] },
|
||||
semantic_index: { ...workspace.semantic_index, vector_store: { ...workspace.semantic_index.vector_store, supported_transports: ["pgvector_direct", "rest_api"] } },
|
||||
diagnostics: {
|
||||
dwh_rest: { method: "POST", path: "/rpc/ping", auth: "bearer", response: { database: "database", schema: "schema" } },
|
||||
vector_rest: {
|
||||
metadata: { method: "GET", path: "/vector/metadata", auth: "bearer", response: { collection: "collection", dimensions: "dimensions", distance: "distance" } },
|
||||
reversible_probe: { method: "POST", path: "/vector/probe", auth: "x-api-key", response: { operation: "operation" } },
|
||||
},
|
||||
embedding: { method: "GET", path: "/models", auth: "none", response: { model: "model", dimensions: "dimensions" } },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -89,14 +74,8 @@ test.each(diagnosticConflictFields)("accepts canonical diagnostic conflict leaf
|
||||
const diagnosticsWorkspace: CanonicalWorkspace = {
|
||||
...workspace,
|
||||
dwh: { ...workspace.dwh, supported_transports: ["postgres_direct", "rest_api"] },
|
||||
semantic_index: { ...workspace.semantic_index, vector_store: { ...workspace.semantic_index.vector_store, supported_transports: ["pgvector_direct", "rest_api"] } },
|
||||
diagnostics: {
|
||||
dwh_rest: { method: "POST", path: "/rpc/ping", auth: "bearer", response: { database: "database", schema: "schema" } },
|
||||
vector_rest: {
|
||||
metadata: { method: "GET", path: "/vector/metadata", auth: "bearer", response: { collection: "collection", dimensions: "dimensions", distance: "distance" } },
|
||||
reversible_probe: { method: "POST", path: "/vector/probe", auth: "x-api-key", response: { operation: "operation" } },
|
||||
},
|
||||
embedding: { method: "GET", path: "/models", auth: "none", response: { model: "model", dimensions: "dimensions" } },
|
||||
},
|
||||
};
|
||||
server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({
|
||||
@@ -111,3 +90,20 @@ test.each(diagnosticConflictFields)("accepts canonical diagnostic conflict leaf
|
||||
|
||||
expect(asWorkspaceConflict(error)).toMatchObject({ actual: { commit: "c".repeat(40), blob: "d".repeat(40) }, fields: [field] });
|
||||
});
|
||||
|
||||
test("rejects a conflict payload that attempts to surface removed vector transport and credential branches", async () => {
|
||||
server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({
|
||||
code: "workspace_conflict",
|
||||
message: "Workspace changed in the registry.",
|
||||
fields: ["diagnostics.vector_rest.reversible_probe.auth"],
|
||||
expected: { commit: "a".repeat(40), blob: "b".repeat(40) },
|
||||
actual: { commit: "c".repeat(40), blob: "d".repeat(40) },
|
||||
base: workspace,
|
||||
local: workspace,
|
||||
remote: workspace,
|
||||
}, { status: 409 })));
|
||||
|
||||
const error = await publishWorkspace({ action: "update", workspace, baseCommit: "a".repeat(40), baseBlob: "b".repeat(40) }).catch((cause: unknown) => cause);
|
||||
|
||||
expect(asWorkspaceConflict(error)).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -15,16 +15,11 @@ export interface RestDiagnosticRequest {
|
||||
|
||||
export interface CanonicalDiagnostics {
|
||||
dwh_rest?: RestDiagnosticRequest & { response: { database: string; schema: string } };
|
||||
vector_rest?: {
|
||||
metadata: RestDiagnosticRequest & { response: { collection: string; dimensions: string; distance: string } };
|
||||
reversible_probe?: RestDiagnosticRequest & { method: "POST"; auth: "bearer" | "x-api-key"; response: { operation: string } };
|
||||
};
|
||||
embedding?: RestDiagnosticRequest & { response: { model: string; dimensions: string } };
|
||||
}
|
||||
|
||||
export interface CanonicalWorkspace {
|
||||
workspace: {
|
||||
schema_version: 2;
|
||||
schema_version: 3;
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
@@ -40,22 +35,15 @@ export interface CanonicalWorkspace {
|
||||
};
|
||||
semantic_index: {
|
||||
vector_store: {
|
||||
engine: "pgvector";
|
||||
database: string;
|
||||
schema: string;
|
||||
engine: "qdrant";
|
||||
collection: string;
|
||||
dimensions: number;
|
||||
distance: "cosine" | "l2" | "inner_product";
|
||||
port?: number;
|
||||
timeout_ms?: number;
|
||||
supported_transports: ("pgvector_direct" | "rest_api" | "ssh_tunnel")[];
|
||||
};
|
||||
vector_writer?: Record<string, never>;
|
||||
dimensions: 1024;
|
||||
distance: "cosine";
|
||||
}
|
||||
embedding: {
|
||||
provider: "ollama_compatible" | "openai_compatible";
|
||||
model: string;
|
||||
dimensions: number;
|
||||
timeout_ms?: number;
|
||||
provider: "ollama_internal";
|
||||
model: "qwen3-embedding:0.6b";
|
||||
dimensions: 1024;
|
||||
};
|
||||
};
|
||||
llm_policy: { default?: `${string}/${string}`; allowed: `${string}/${string}`[] };
|
||||
@@ -137,20 +125,13 @@ export interface WorkspaceApiError {
|
||||
const conflictFields = new Set([
|
||||
"workspace.schema_version", "workspace.id", "workspace.name", "workspace.description", "workspace.language",
|
||||
"dwh.engine", "dwh.database", "dwh.schema", "dwh.port", "dwh.timeout_ms", "dwh.supported_transports",
|
||||
"semantic_index.vector_store.engine", "semantic_index.vector_store.database", "semantic_index.vector_store.schema", "semantic_index.vector_store.collection",
|
||||
"semantic_index.vector_store.dimensions", "semantic_index.vector_store.distance", "semantic_index.vector_store.port",
|
||||
"semantic_index.vector_store.timeout_ms", "semantic_index.vector_store.supported_transports",
|
||||
"semantic_index.vector_store.engine", "semantic_index.vector_store.collection",
|
||||
"semantic_index.vector_store.dimensions", "semantic_index.vector_store.distance",
|
||||
"semantic_index.embedding.provider", "semantic_index.embedding.model", "semantic_index.embedding.dimensions",
|
||||
"semantic_index.embedding.timeout_ms", "semantic_index.vector_writer", "llm_policy.default", "llm_policy.allowed",
|
||||
"diagnostics", "diagnostics.dwh_rest", "diagnostics.vector_rest", "diagnostics.vector_rest.reversible_probe", "diagnostics.embedding",
|
||||
"llm_policy.default", "llm_policy.allowed",
|
||||
"diagnostics", "diagnostics.dwh_rest",
|
||||
"diagnostics.dwh_rest.method", "diagnostics.dwh_rest.path", "diagnostics.dwh_rest.auth",
|
||||
"diagnostics.dwh_rest.response.database", "diagnostics.dwh_rest.response.schema",
|
||||
"diagnostics.vector_rest.metadata.method", "diagnostics.vector_rest.metadata.path", "diagnostics.vector_rest.metadata.auth",
|
||||
"diagnostics.vector_rest.metadata.response.collection", "diagnostics.vector_rest.metadata.response.dimensions",
|
||||
"diagnostics.vector_rest.metadata.response.distance", "diagnostics.vector_rest.reversible_probe.method",
|
||||
"diagnostics.vector_rest.reversible_probe.path", "diagnostics.vector_rest.reversible_probe.auth",
|
||||
"diagnostics.vector_rest.reversible_probe.response.operation", "diagnostics.embedding.method", "diagnostics.embedding.path",
|
||||
"diagnostics.embedding.auth", "diagnostics.embedding.response.model", "diagnostics.embedding.response.dimensions",
|
||||
]);
|
||||
|
||||
const workspaceErrorCodes = new Set<WorkspaceErrorCode>([
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -3,17 +3,16 @@ import type { CanonicalWorkspace } from "../api/workspaces";
|
||||
import { workspaceDeletionDrafts, workspaceDrafts, workspacePreferences } from "./drafts";
|
||||
|
||||
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"],
|
||||
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: { allowed: ["zai/glm-5.2"] },
|
||||
};
|
||||
@@ -126,3 +125,24 @@ test.each([
|
||||
expect(workspaceDrafts.load("psd-clinical")).toBeUndefined();
|
||||
expect(localStorage.getItem("thothii.workspace-registry.v1.draft.psd-clinical")).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects a draft that tries to persist removed external semantic configuration fields", () => {
|
||||
workspaceDrafts.save({
|
||||
workspaceId: "psd-clinical",
|
||||
baseCommit: "a".repeat(40),
|
||||
workspace: {
|
||||
...workspace,
|
||||
semantic_index: {
|
||||
vector_store: {
|
||||
...workspace.semantic_index.vector_store,
|
||||
database: "vectors",
|
||||
},
|
||||
embedding: workspace.semantic_index.embedding,
|
||||
},
|
||||
} as CanonicalWorkspace,
|
||||
updatedAt: "2026-08-04T10:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(workspaceDrafts.load("psd-clinical")).toBeUndefined();
|
||||
expect(localStorage.getItem("thothii.workspace-registry.v1.draft.psd-clinical")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -200,7 +200,7 @@ function copyRequest(value: unknown, extraKeys: readonly string[] = []): RestDia
|
||||
}
|
||||
|
||||
function copyDiagnostics(value: unknown): CanonicalDiagnostics | undefined {
|
||||
const source = exactRecord(value, ["dwh_rest", "vector_rest", "embedding"]);
|
||||
const source = exactRecord(value, ["dwh_rest"]);
|
||||
if (!source) return undefined;
|
||||
const diagnostics: CanonicalDiagnostics = {};
|
||||
if (source.dwh_rest !== undefined) {
|
||||
@@ -212,41 +212,6 @@ function copyDiagnostics(value: unknown): CanonicalDiagnostics | undefined {
|
||||
if (!request || !database || !schema) return undefined;
|
||||
diagnostics.dwh_rest = { ...request, response: { database, schema } };
|
||||
}
|
||||
if (source.vector_rest !== undefined) {
|
||||
const vector = exactRecord(source.vector_rest, ["metadata", "reversible_probe"]);
|
||||
const request = copyRequest(vector?.metadata, ["response"]);
|
||||
const rawMetadata = exactRecord(vector?.metadata, ["method", "path", "auth", "response"]);
|
||||
const response = exactRecord(rawMetadata?.response, ["collection", "dimensions", "distance"]);
|
||||
const collection = identifier(response?.collection);
|
||||
const dimensions = identifier(response?.dimensions);
|
||||
const distance = identifier(response?.distance);
|
||||
if (!vector || !request || !collection || !dimensions || !distance) return undefined;
|
||||
const metadata = { ...request, response: { collection, dimensions, distance } };
|
||||
let reversibleProbe: NonNullable<CanonicalDiagnostics["vector_rest"]>["reversible_probe"] | undefined;
|
||||
if (vector.reversible_probe !== undefined) {
|
||||
const probe = copyRequest(vector.reversible_probe, ["response"]);
|
||||
const rawProbe = exactRecord(vector.reversible_probe, ["method", "path", "auth", "response"]);
|
||||
const probeResponse = exactRecord(rawProbe?.response, ["operation"]);
|
||||
const operation = identifier(probeResponse?.operation);
|
||||
if (!probe || probe.method !== "POST" || probe.auth === "none" || !operation) return undefined;
|
||||
reversibleProbe = {
|
||||
method: "POST",
|
||||
path: probe.path,
|
||||
auth: probe.auth as "bearer" | "x-api-key",
|
||||
response: { operation },
|
||||
};
|
||||
}
|
||||
diagnostics.vector_rest = { metadata, ...(reversibleProbe ? { reversible_probe: reversibleProbe } : {}) };
|
||||
}
|
||||
if (source.embedding !== undefined) {
|
||||
const request = copyRequest(source.embedding, ["response"]);
|
||||
const raw = exactRecord(source.embedding, ["method", "path", "auth", "response"]);
|
||||
const response = exactRecord(raw?.response, ["model", "dimensions"]);
|
||||
const model = identifier(response?.model);
|
||||
const dimensions = identifier(response?.dimensions);
|
||||
if (!request || !model || !dimensions) return undefined;
|
||||
diagnostics.embedding = { ...request, response: { model, dimensions } };
|
||||
}
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
@@ -255,9 +220,9 @@ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace |
|
||||
const source = exactRecord(value, ["workspace", "dwh", "semantic_index", "llm_policy", "diagnostics"]);
|
||||
const metadata = exactRecord(source?.workspace, ["schema_version", "id", "name", "description", "language"]);
|
||||
const dwh = exactRecord(source?.dwh, ["engine", "database", "schema", "port", "timeout_ms", "supported_transports"]);
|
||||
const semanticIndex = exactRecord(source?.semantic_index, ["vector_store", "vector_writer", "embedding"]);
|
||||
const vectorStore = exactRecord(semanticIndex?.vector_store, ["engine", "database", "schema", "collection", "dimensions", "distance", "port", "timeout_ms", "supported_transports"]);
|
||||
const embedding = exactRecord(semanticIndex?.embedding, ["provider", "model", "dimensions", "timeout_ms"]);
|
||||
const semanticIndex = exactRecord(source?.semantic_index, ["vector_store", "embedding"]);
|
||||
const vectorStore = exactRecord(semanticIndex?.vector_store, ["engine", "collection", "dimensions", "distance"]);
|
||||
const embedding = exactRecord(semanticIndex?.embedding, ["provider", "model", "dimensions"]);
|
||||
const policy = exactRecord(source?.llm_policy, ["default", "allowed"]);
|
||||
const diagnostics = source?.diagnostics === undefined ? undefined : copyDiagnostics(source.diagnostics);
|
||||
if (!metadata || !dwh || !semanticIndex || !vectorStore || !embedding || !policy) return undefined;
|
||||
@@ -270,34 +235,28 @@ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace |
|
||||
const dwhPort = dwh.port === undefined ? undefined : positiveInteger(dwh.port, 65_535);
|
||||
const dwhTimeout = dwh.timeout_ms === undefined ? undefined : positiveInteger(dwh.timeout_ms);
|
||||
const dwhTransports = uniqueChoices(dwh.supported_transports, ["postgres_direct", "rest_api", "ssh_tunnel"] as const);
|
||||
const vectorDatabase = identifier(vectorStore.database);
|
||||
const vectorSchema = identifier(vectorStore.schema);
|
||||
const collection = identifier(vectorStore.collection);
|
||||
const vectorDimensions = positiveInteger(vectorStore.dimensions, 32_768);
|
||||
const distance = oneOf(vectorStore.distance, ["cosine", "l2", "inner_product"] as const);
|
||||
const vectorPort = vectorStore.port === undefined ? undefined : positiveInteger(vectorStore.port, 65_535);
|
||||
const vectorTimeout = vectorStore.timeout_ms === undefined ? undefined : positiveInteger(vectorStore.timeout_ms);
|
||||
const vectorTransports = uniqueChoices(vectorStore.supported_transports, ["pgvector_direct", "rest_api", "ssh_tunnel"] as const);
|
||||
const embeddingProvider = oneOf(embedding.provider, ["ollama_compatible", "openai_compatible"] as const);
|
||||
const distance = oneOf(vectorStore.distance, ["cosine"] as const);
|
||||
const embeddingProvider = oneOf(embedding.provider, ["ollama_internal"] as const);
|
||||
const embeddingModel = text(embedding.model);
|
||||
const embeddingDimensions = positiveInteger(embedding.dimensions, 32_768);
|
||||
const embeddingTimeout = embedding.timeout_ms === undefined ? undefined : positiveInteger(embedding.timeout_ms);
|
||||
const allowedModels = uniqueModels(policy.allowed);
|
||||
const defaultModel = policy.default === undefined ? undefined : modelReference(policy.default);
|
||||
if (
|
||||
metadata.schema_version !== 2 || !id || !name || !language || (metadata.description !== undefined && !description)
|
||||
metadata.schema_version !== 3 || !id || !name || !language || (metadata.description !== undefined && !description)
|
||||
|| dwh.engine !== "postgres" || !database || !schema || (dwh.port !== undefined && !dwhPort) || (dwh.timeout_ms !== undefined && !dwhTimeout) || !dwhTransports
|
||||
|| vectorStore.engine !== "pgvector" || !vectorDatabase || !vectorSchema || !collection || !vectorDimensions || !distance || (vectorStore.port !== undefined && !vectorPort) || (vectorStore.timeout_ms !== undefined && !vectorTimeout) || !vectorTransports
|
||||
|| !embeddingProvider || !embeddingModel || !embeddingDimensions || (embedding.timeout_ms !== undefined && !embeddingTimeout) || !allowedModels
|
||||
|| vectorStore.engine !== "qdrant" || !collection || !vectorDimensions || !distance
|
||||
|| !embeddingProvider || !embeddingModel || !embeddingDimensions || !allowedModels
|
||||
|| (defaultModel !== undefined && !allowedModels.includes(defaultModel)) || vectorDimensions !== embeddingDimensions
|
||||
|| (semanticIndex.vector_writer !== undefined && !exactRecord(semanticIndex.vector_writer, []))
|
||||
|| vectorDimensions !== 1024 || embeddingDimensions !== 1024
|
||||
|| embeddingModel !== "qwen3-embedding:0.6b"
|
||||
) return undefined;
|
||||
if (source?.diagnostics !== undefined && !diagnostics) return undefined;
|
||||
if (diagnostics?.dwh_rest && !dwhTransports.includes("rest_api")) return undefined;
|
||||
if (diagnostics?.vector_rest && !vectorTransports.includes("rest_api")) return undefined;
|
||||
return {
|
||||
workspace: {
|
||||
schema_version: 2,
|
||||
schema_version: 3,
|
||||
id,
|
||||
name,
|
||||
...(description ? { description } : {}),
|
||||
@@ -311,15 +270,10 @@ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace |
|
||||
},
|
||||
semantic_index: {
|
||||
vector_store: {
|
||||
engine: "pgvector", database: vectorDatabase, schema: vectorSchema, collection, dimensions: vectorDimensions, distance,
|
||||
...(vectorPort ? { port: vectorPort } : {}),
|
||||
...(vectorTimeout ? { timeout_ms: vectorTimeout } : {}),
|
||||
supported_transports: vectorTransports,
|
||||
engine: "qdrant", collection, dimensions: 1024, distance: "cosine",
|
||||
},
|
||||
...(semanticIndex.vector_writer ? { vector_writer: {} } : {}),
|
||||
embedding: {
|
||||
provider: embeddingProvider, model: embeddingModel, dimensions: embeddingDimensions,
|
||||
...(embeddingTimeout ? { timeout_ms: embeddingTimeout } : {}),
|
||||
provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024,
|
||||
},
|
||||
},
|
||||
llm_policy: {
|
||||
|
||||
Reference in New Issue
Block a user