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 index 21f2b07e..da2f169d 100644 --- 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 @@ -38,3 +38,28 @@ Self-review: 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. + +Fix round 1/5 — August 8, 2026 + +Summary: +- Made missing or invalid workspace summaries fail safe in frontend session creation and composer selection instead of falling open as legacy. +- Added an actionable unavailable message in workspace management for incomplete summaries with no canonical revision. +- Replaced the old runtime-oriented example descriptor files with exact backend WorkspaceV3 descriptor YAML. + +Additional files changed: +- `frontend/src/api/sessions.test.ts` +- `backend/test/workspaces-schema.test.ts` + +Fix-round verification: +- `cd frontend && npx vitest run src/api/sessions.test.ts src/shell/SteerInput.test.tsx src/shell/WorkspaceManager.test.tsx src/shell/WorkspaceEditor.test.tsx src/shell/WorkspacePublishDialog.test.tsx src/workspaces/drafts.test.ts src/api/workspaces.test.ts` + - Result: 7 files passed, 73 tests passed. +- `cd frontend && npx tsc -b` + - Result: passed. +- `cd backend && npx vitest run test/workspaces-schema.test.ts` + - Result: 1 file passed, 17 tests passed. +- `git diff --check` + - Result: passed. + +Notes: +- Missing `revision` in a workspace summary now fails with the same session/composer safety posture as `migration_required`, using the existing safe workspace-policy error for session creation and an explicit unavailable message in workspace management. +- The committed example files now validate as actual schema-v3 descriptors instead of deployment/runtime templates with forbidden semantic endpoint fields. diff --git a/backend/test/workspaces-schema.test.ts b/backend/test/workspaces-schema.test.ts index 6f8f580f..e70cadfd 100644 --- a/backend/test/workspaces-schema.test.ts +++ b/backend/test/workspaces-schema.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { expect, test } from "vitest"; import * as workspaceSchema from "../src/workspaces/schema.js"; import { @@ -95,6 +97,28 @@ test("accepts only the schema v3 internal qdrant semantic shape", () => { }); }); +test("committed example descriptors parse as exact schema v3 workspaces", () => { + const example = readFileSync(resolve(process.cwd(), "../deploy/workspaces/example.yaml"), "utf8"); + const psdExample = readFileSync(resolve(process.cwd(), "../deploy/workspaces/psd.yaml.example"), "utf8"); + + expect(() => parseWorkspaceYaml(example)).not.toThrow(); + expect(() => parseWorkspaceYaml(psdExample)).not.toThrow(); + expect(parseWorkspaceYaml(example)).toMatchObject({ + workspace: { schema_version: 3 }, + semantic_index: { + vector_store: { engine: "qdrant", distance: "cosine", dimensions: 1024 }, + embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 }, + }, + }); + expect(parseWorkspaceYaml(psdExample)).toMatchObject({ + workspace: { schema_version: 3 }, + semantic_index: { + vector_store: { engine: "qdrant", distance: "cosine", dimensions: 1024 }, + embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 }, + }, + }); +}); + test("rejects pgvector semantic stores in schema v3", () => { expect(() => parseWorkspaceYaml(validYaml.replace("engine: qdrant", "engine: pgvector"))) .toThrow(/qdrant|pgvector/i); diff --git a/deploy/workspaces/example.yaml b/deploy/workspaces/example.yaml index ef27e5ba..d85f5e2d 100644 --- a/deploy/workspaces/example.yaml +++ b/deploy/workspaces/example.yaml @@ -1,66 +1,39 @@ -language: en +workspace: + schema_version: 3 + id: example + name: Example workspace + description: Generic example WorkspaceV3 descriptor. + language: en dwh: - type: thoth_rest - database: - database: ${THT_DB_NAME} - schema: datawarehouse - endpoint: - base_url: ${THT_DWH_REST_URL} - api_key: ${THT_DWH_API_KEY} - ssl_ca: ${THT_SSL_CA} + engine: postgres + database: postgres + schema: datawarehouse + supported_transports: + - postgres_direct + - rest_api -# Relative logical roots are resolved beneath /data/workspaces/example. -roots: - artifacts: artifacts - indexes: indexes - sessions: sessions - -examples: - max_per_column: 10 - -lsh: - signature_size: 64 - n_gram: 3 - threshold: 0.5 - max_values_per_column: 1000 - -eligibility: - max_declared_len: 128 - max_avg_length: 40 - max_sampled_len: 200 - ignore_columns: [etl_last_update] - -evidence: - source_root: ${THT_DOCS_ROOT} - evidence_dir: evidence - -resources: - vector: - # Schema-v3 descriptors publish only the collection identity; the runtime renderer - # supplies this fixed internal Qdrant architecture. +semantic_index: + vector_store: engine: qdrant - base_url: http://qdrant:6333 collection: example - embeddings: - # Embeddings are fixed to the internal Ollama service for schema-v3 descriptors. + dimensions: 1024 + distance: cosine + embedding: provider: ollama_internal - base_url: http://embedding:11434 model: qwen3-embedding:0.6b dimensions: 1024 -vector: - max_chunk_chars: 4000 +llm_policy: + default: zai/glm-5.2 + allowed: + - zai/glm-5.2 -search: - rrf_k: 60 - top_schema_tables: 12 - schema_chunk_pool: 150 - -execution: - allow: [cte_test, explain, preview, aggregate, export] - max_preview_rows: 10 - max_export_rows: 100000 - statement_timeout_ms: 30000 - warn_execution_ms: 5000 - max_aggregate_cells: 20 +diagnostics: + dwh_rest: + method: GET + path: /health + auth: none + response: + database: database + schema: schema diff --git a/deploy/workspaces/psd.yaml.example b/deploy/workspaces/psd.yaml.example index 095c0b63..2aa4e478 100644 --- a/deploy/workspaces/psd.yaml.example +++ b/deploy/workspaces/psd.yaml.example @@ -1,40 +1,39 @@ -language: en +workspace: + schema_version: 3 + id: psd-clinical + name: PSD Clinical + description: Example PSD-oriented WorkspaceV3 descriptor. + language: en dwh: - type: thoth_rest - database: - database: ${THT_DB_NAME} - schema: datawarehouse - endpoint: - base_url: ${THT_DWH_REST_URL} - api_key: ${THT_DWH_API_KEY} - ssl_ca: ${THT_SSL_CA} + engine: postgres + database: postgres + schema: datawarehouse + supported_transports: + - postgres_direct + - rest_api -roots: - artifacts: /data/workspaces/generic/artifacts - indexes: /data/workspaces/generic/indexes - sessions: /data/workspaces/generic/sessions - -evidence: - source_root: ${THT_DOCS_ROOT} - evidence_dir: evidence - -resources: - vector: - # Schema-v3 descriptors publish only the collection identity; the runtime renderer - # supplies this fixed internal Qdrant architecture. +semantic_index: + vector_store: engine: qdrant - base_url: http://qdrant:6333 - collection: generic - embeddings: - # Embeddings are fixed to the internal Ollama service for schema-v3 descriptors. + collection: psd-clinical + dimensions: 1024 + distance: cosine + embedding: provider: ollama_internal - base_url: http://embedding:11434 model: qwen3-embedding:0.6b dimensions: 1024 -execution: - allow: [cte_test, explain, preview, aggregate, export] - max_preview_rows: 10 - max_export_rows: 100000 - statement_timeout_ms: 30000 +llm_policy: + default: zai/glm-5.2 + allowed: + - zai/glm-5.2 + +diagnostics: + dwh_rest: + method: GET + path: /health + auth: none + response: + database: database + schema: schema diff --git a/frontend/src/api/sessions.test.ts b/frontend/src/api/sessions.test.ts index 9e77f24c..e7a8610a 100644 --- a/frontend/src/api/sessions.test.ts +++ b/frontend/src/api/sessions.test.ts @@ -15,7 +15,12 @@ test("createSession migrates legacy selections and POSTs browser preferences", a })), 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" }); @@ -30,6 +35,28 @@ test("createSession migrates legacy selections and POSTs browser preferences", a }); }); +test("createSession rejects a workspace summary that omits the canonical revision", async () => { + localStorage.clear(); + let posted = false; + server.use( + http.get("/api/settings", () => HttpResponse.json({ + workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low", + })), + http.get("/api/workspaces", () => HttpResponse.json([{ + id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical", + }])), + http.post("/api/sessions", async () => { + posted = true; + return HttpResponse.json({ id: "s1" }); + }), + ); + + await expect(createSession({ question: "q" })).rejects.toMatchObject({ + message: "Could not load selected workspace policy. Please retry.", + }); + expect(posted).toBe(false); +}); + test.each([202, 204])("prewarmRuntime accepts a body-less %s response", async (status) => { let called = false; server.use( diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index 43cf6fd7..4b255632 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -59,7 +59,11 @@ async function ensureWorkspaceSelectionPolicy(): Promise { throw new WorkspaceSelectionError(WORKSPACE_SUMMARY_ERROR); } if (workspacePreferences.load().workspaceId !== workspaceId) continue; - if (workspace?.revision.state === "migration_required") { + if (workspace && !workspace.revision) { + workspacePolicyGate.reject(workspaceId); + throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR); + } + if (workspace?.revision?.state === "migration_required") { workspacePolicyGate.rejectSummary(workspaceId); throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR); } diff --git a/frontend/src/shell/SteerInput.test.tsx b/frontend/src/shell/SteerInput.test.tsx index 69c994bd..099ec063 100644 --- a/frontend/src/shell/SteerInput.test.tsx +++ b/frontend/src/shell/SteerInput.test.tsx @@ -389,6 +389,35 @@ test("initial submit rejects a migration-required workspace after summaries load expect(body).toBeUndefined(); }); +test("initial submit rejects a workspace summary that omits the canonical revision", async () => { + let body: unknown; + let failure: string | undefined; + localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({ + workspaceId: "broken-workspace", provider: "zai", model: "glm-5.2", thinking: "medium", + })); + server.use( + http.get("/api/settings", () => HttpResponse.json({ workspace: "broken-workspace" })), + http.get("/api/workspaces", () => HttpResponse.json([{ + id: "broken-workspace", name: "broken-workspace", file: "broken-workspace.yaml", displayName: "Broken workspace", + }])), + http.get("/api/models", () => HttpResponse.json({ models: [ + { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, + ] })), + http.post("/api/sessions", async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ id: "s1" }); + }), + ); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( { failure = message; }} />); + + await userEvent.type(screen.getByRole("textbox", { name: /new question/i }), "q"); + await userEvent.click(screen.getByRole("button", { name: /send/i })); + + 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 () => { let body: unknown; let failure: string | undefined; diff --git a/frontend/src/shell/SteerInput.tsx b/frontend/src/shell/SteerInput.tsx index d517e927..46c9e704 100644 --- a/frontend/src/shell/SteerInput.tsx +++ b/frontend/src/shell/SteerInput.tsx @@ -180,12 +180,11 @@ export function ComposerFooter() { const workspace = preferences.workspaceId ?? settings?.workspace ?? ""; const selectedWorkspace = workspaces.find((candidate) => candidate.id === workspace); + const selectedWorkspaceHasRevision = Boolean(selectedWorkspace?.revision); const { data: workspaceRecord, isError: workspacePolicyError } = useQuery({ queryKey: ["workspace", workspace], queryFn: () => getWorkspace(workspace), - // Legacy metadata responses do not carry a registry revision, so retain the - // existing selector behavior without issuing an incompatible detail request. - enabled: Boolean(selectedWorkspace?.revision), + enabled: selectedWorkspaceHasRevision, }); const model = preferences.model ?? settings?.model ?? ""; const thinking = preferences.thinking ?? settings?.thinking ?? "medium"; @@ -202,6 +201,8 @@ export function ComposerFooter() { workspacePolicyGate.beginSummary(workspace); } else if (workspaceSummariesError) { workspacePolicyGate.rejectSummary(workspace); + } else if (selectedWorkspace && !selectedWorkspaceHasRevision) { + workspacePolicyGate.rejectSummary(workspace); } else if (selectedWorkspace?.revision?.state === "migration_required") { workspacePolicyGate.rejectSummary(workspace); } else if (selectedWorkspace?.revision) { @@ -241,7 +242,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?.state === "migration_required") workspacePolicyGate.rejectSummary(patch.workspaceId); + if (selected && !selected.revision) workspacePolicyGate.rejectSummary(patch.workspaceId); + else if (selected?.revision?.state === "migration_required") workspacePolicyGate.rejectSummary(patch.workspaceId); else if (selected?.revision) workspacePolicyGate.select(patch.workspaceId); else workspacePolicyGate.allowLegacy(patch.workspaceId); } @@ -267,7 +269,7 @@ export function ComposerFooter() { {workspaces.length === 0 ? ( ) : ( - workspaces.filter((w) => w.revision?.state !== "migration_required").map((w) => ( + workspaces.filter((w) => w.revision && w.revision.state !== "migration_required").map((w) => ( diff --git a/frontend/src/shell/WorkspaceManager.test.tsx b/frontend/src/shell/WorkspaceManager.test.tsx index 96b1461d..88c88f75 100644 --- a/frontend/src/shell/WorkspaceManager.test.tsx +++ b/frontend/src/shell/WorkspaceManager.test.tsx @@ -232,6 +232,24 @@ test("shows a migration banner for legacy descriptors and does not load editor d expect(screen.queryByLabelText("Vector collection")).not.toBeInTheDocument(); }); +test("shows an actionable unavailable message when a workspace summary omits its canonical revision", async () => { + const user = userEvent.setup(); + server.use( + http.get("/api/workspaces", () => HttpResponse.json([ + { + id: "broken-workspace", name: "Broken workspace", displayName: "Broken workspace", description: "Broken data", + language: "en", file: "workspaces/broken-workspace.yaml", + }, + ])), + ); + renderManager(); + + await user.click(await screen.findByRole("button", { name: "Broken workspace" })); + + expect(await screen.findByText("This workspace summary is incomplete. Refresh the registry or choose another workspace before creating sessions or editing drafts.")).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 c766e1de..f2a075fc 100644 --- a/frontend/src/shell/WorkspaceManager.tsx +++ b/frontend/src/shell/WorkspaceManager.tsx @@ -63,11 +63,12 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: () 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 selectedSummaryIncomplete = Boolean(selectedSummary && !selectedSummary.revision); + const selectedIsLegacy = selectedSummary?.revision?.state === "migration_required"; const detailQuery = useQuery({ queryKey: ["workspace", selectedId], queryFn: () => getWorkspace(selectedId!), - enabled: Boolean(open && selectedId && !localDraft && !selectedIsLegacy), + enabled: Boolean(open && selectedId && !localDraft && !selectedSummaryIncomplete && !selectedIsLegacy), }); const status = statusQuery.data; const record = detailQuery.data; @@ -311,13 +312,18 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
- {selectedIsLegacy ? ( + {selectedSummaryIncomplete ? ( +
+

Workspace summary unavailable

+

This workspace summary is incomplete. Refresh the registry or choose another workspace before creating sessions or editing drafts.

+
+ ) : 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) && ( + {!selectedSummaryIncomplete && !selectedIsLegacy && !detailQuery.isError && (currentDraft || detailQuery.isLoading) && ( <> {detailQuery.isLoading && !currentDraft ?

Loading workspace definition…

: currentDraft && <>