From c3a56217373759356fc2fa3dc781d095498204ed Mon Sep 17 00:00:00 2001 From: mptyl Date: Sat, 8 Aug 2026 19:56:29 +0200 Subject: [PATCH] feat: edit qdrant workspace collections --- .../task-9-report.md | 40 ++++++++++ deploy/workspaces/example.yaml | 3 + deploy/workspaces/psd.yaml.example | 3 + frontend/src/api/sessions.ts | 4 + frontend/src/api/workspaces.test.ts | 44 +++++------ frontend/src/api/workspaces.ts | 43 +++-------- frontend/src/shell/SteerInput.test.tsx | 17 +++-- frontend/src/shell/SteerInput.tsx | 7 +- frontend/src/shell/WorkspaceEditor.test.tsx | 41 ++++++---- frontend/src/shell/WorkspaceEditor.tsx | 47 ++++-------- frontend/src/shell/WorkspaceManager.test.tsx | 33 +++++++-- frontend/src/shell/WorkspaceManager.tsx | 22 ++++-- .../src/shell/WorkspacePublishDialog.test.tsx | 26 +++---- frontend/src/workspaces/drafts.test.ts | 28 ++++++- frontend/src/workspaces/drafts.ts | 74 ++++--------------- 15 files changed, 234 insertions(+), 198 deletions(-) create mode 100644 .superpowers/sdd/2026-08-08-internal-qdrant-ollama/task-9-report.md diff --git a/.superpowers/sdd/2026-08-08-internal-qdrant-ollama/task-9-report.md b/.superpowers/sdd/2026-08-08-internal-qdrant-ollama/task-9-report.md new file mode 100644 index 00000000..21f2b07e --- /dev/null +++ b/.superpowers/sdd/2026-08-08-internal-qdrant-ollama/task-9-report.md @@ -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. diff --git a/deploy/workspaces/example.yaml b/deploy/workspaces/example.yaml index a8306e65..ef27e5ba 100644 --- a/deploy/workspaces/example.yaml +++ b/deploy/workspaces/example.yaml @@ -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 diff --git a/deploy/workspaces/psd.yaml.example b/deploy/workspaces/psd.yaml.example index ded7c9ee..095c0b63 100644 --- a/deploy/workspaces/psd.yaml.example +++ b/deploy/workspaces/psd.yaml.example @@ -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 diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index 8840c4fe..43cf6fd7 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -59,6 +59,10 @@ async function ensureWorkspaceSelectionPolicy(): Promise { 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(); diff --git a/frontend/src/api/workspaces.test.ts b/frontend/src/api/workspaces.test.ts index b97dc86c..39e3308f 100644 --- a/frontend/src/api/workspaces.test.ts +++ b/frontend/src/api/workspaces.test.ts @@ -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(); +}); diff --git a/frontend/src/api/workspaces.ts b/frontend/src/api/workspaces.ts index 27ace594..66119600 100644 --- a/frontend/src/api/workspaces.ts +++ b/frontend/src/api/workspaces.ts @@ -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; + 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([ diff --git a/frontend/src/shell/SteerInput.test.tsx b/frontend/src/shell/SteerInput.test.tsx index 4b52f5a0..69c994bd 100644 --- a/frontend/src/shell/SteerInput.test.tsx +++ b/frontend/src/shell/SteerInput.test.tsx @@ -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((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(); + render( { failure = message; }} />); 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 () => { diff --git a/frontend/src/shell/SteerInput.tsx b/frontend/src/shell/SteerInput.tsx index 7b08dbba..d517e927 100644 --- a/frontend/src/shell/SteerInput.tsx +++ b/frontend/src/shell/SteerInput.tsx @@ -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 ? ( ) : ( - workspaces.map((w) => ( + workspaces.filter((w) => w.revision?.state !== "migration_required").map((w) => ( diff --git a/frontend/src/shell/WorkspaceEditor.test.tsx b/frontend/src/shell/WorkspaceEditor.test.tsx index a5d00119..7919bc04 100644 --- a/frontend/src/shell/WorkspaceEditor.test.tsx +++ b/frontend/src/shell/WorkspaceEditor.test.tsx @@ -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(); - 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(); 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 () => { diff --git a/frontend/src/shell/WorkspaceEditor.tsx b/frontend/src/shell/WorkspaceEditor.tsx index 7f540e5c..cd7a85cf 100644 --- a/frontend/src/shell/WorkspaceEditor.tsx +++ b/frontend/src/shell/WorkspaceEditor.tsx @@ -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
- {({ id, describedBy, invalid }) => } - - - {({ id, describedBy, invalid }) => } - - - {({ id, describedBy, invalid }) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, database: event.target.value } } }))} />} - - - {({ id, describedBy, invalid }) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, schema: event.target.value } } }))} />} + {({ id, describedBy, invalid }) => } {({ id, describedBy, invalid }) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, collection: event.target.value } } }))} />} - - {({ id, describedBy, invalid }) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, port: numberOrUndefined(event.target.value) } } }))} />} - - {({ id, describedBy, invalid }) => } + {({ id, describedBy, invalid }) => } - {({ id, describedBy, invalid }) => 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 }) => } - {({ id, describedBy, invalid }) => } + {({ id, describedBy, invalid }) => } - {({ id, describedBy, invalid }) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, embedding: { ...value.semantic_index.embedding, model: event.target.value } } }))} />} + {({ id, describedBy, invalid }) => }
diff --git a/frontend/src/shell/WorkspaceManager.test.tsx b/frontend/src/shell/WorkspaceManager.test.tsx index 903ec312..96b1461d 100644 --- a/frontend/src/shell/WorkspaceManager.test.tsx +++ b/frontend/src/shell/WorkspaceManager.test.tsx @@ -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; diff --git a/frontend/src/shell/WorkspaceManager.tsx b/frontend/src/shell/WorkspaceManager.tsx index 4d49a0dd..c766e1de 100644 --- a/frontend/src/shell/WorkspaceManager.tsx +++ b/frontend/src/shell/WorkspaceManager.tsx @@ -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: ()
- {detailQuery.isError && selectedId && !localDraft ? { void detailQuery.refetch(); }} /> : !currentDraft && !detailQuery.isLoading &&

Select a workspace

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

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

Migration required

+

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

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

Select a workspace

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

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

Loading workspace definition…

: currentDraft && <>
diff --git a/frontend/src/shell/WorkspacePublishDialog.test.tsx b/frontend/src/shell/WorkspacePublishDialog.test.tsx index 65c32174..33130635 100644 --- a/frontend/src/shell/WorkspacePublishDialog.test.tsx +++ b/frontend/src/shell/WorkspacePublishDialog.test.tsx @@ -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); }); diff --git a/frontend/src/workspaces/drafts.test.ts b/frontend/src/workspaces/drafts.test.ts index b33cb607..cd83afa3 100644 --- a/frontend/src/workspaces/drafts.test.ts +++ b/frontend/src/workspaces/drafts.test.ts @@ -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(); +}); diff --git a/frontend/src/workspaces/drafts.ts b/frontend/src/workspaces/drafts.ts index 25a91367..2bf7818c 100644 --- a/frontend/src/workspaces/drafts.ts +++ b/frontend/src/workspaces/drafts.ts @@ -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["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: {