From 450d7ab07fdeafc001b96aaad2f48b7aae9588cf Mon Sep 17 00:00:00 2001 From: mptyl Date: Mon, 3 Aug 2026 23:30:15 +0200 Subject: [PATCH] fix: gate workspace diagnostic migration --- backend/src/workspaces/bindings.ts | 9 +- backend/src/workspaces/contracts.ts | 10 +- backend/src/workspaces/diagnostics.ts | 4 +- backend/src/workspaces/registry.ts | 61 +++- backend/src/workspaces/runtime-renderer.ts | 4 +- backend/src/workspaces/schema.ts | 301 ++++++++++++------ backend/test/workspace-registry.test.ts | 25 +- .../test/workspace-runtime-renderer.test.ts | 30 +- backend/test/workspaces-bindings.test.ts | 19 +- backend/test/workspaces-contracts.test.ts | 8 +- backend/test/workspaces-diagnostics.test.ts | 2 +- .../test/workspaces-git-repository.test.ts | 2 +- backend/test/workspaces-schema.test.ts | 81 ++++- ...026-08-03-git-workspace-registry-design.md | 19 +- 14 files changed, 430 insertions(+), 145 deletions(-) diff --git a/backend/src/workspaces/bindings.ts b/backend/src/workspaces/bindings.ts index a3c26157..e5e5e0fd 100644 --- a/backend/src/workspaces/bindings.ts +++ b/backend/src/workspaces/bindings.ts @@ -5,9 +5,9 @@ import { DWH_TRANSPORTS, VECTOR_TRANSPORTS, validateCanonicalWorkspace, - type CanonicalWorkspace, type DwhTransport, type VectorTransport, + type WorkspaceDescriptor, } from "./schema.js"; export interface ResolvedBinding { @@ -63,10 +63,11 @@ function isSafeSecretFile(path: string, secretRoots: readonly string[]): boolean } function requiredSuffixes( - role: Exclude, + role: InstallationRole, transport: DwhTransport | VectorTransport, ): readonly InstallationSuffix[] { if (role === "EMBEDDING") return EMBEDDING_REQUIRED_SUFFIXES; + if (role === "VECTOR_WRITER") return ["API_KEY_FILE"]; return REQUIRED_SUFFIXES[role][transport] ?? []; } @@ -75,8 +76,8 @@ function requiredSuffixes( * deliberately left for the harness secret-file loader, so bindings cannot leak credentials. */ export function resolveBinding( - workspace: CanonicalWorkspace, - role: Exclude, + workspace: WorkspaceDescriptor, + role: InstallationRole, env: NodeJS.ProcessEnv, secretRoots: readonly string[], ): ResolvedBinding { diff --git a/backend/src/workspaces/contracts.ts b/backend/src/workspaces/contracts.ts index f2252ecb..48c0441a 100644 --- a/backend/src/workspaces/contracts.ts +++ b/backend/src/workspaces/contracts.ts @@ -1,5 +1,5 @@ import { validateCanonicalWorkspace } from "./schema.js"; -import type { CanonicalWorkspace, DwhTransport, VectorTransport } from "./schema.js"; +import type { DwhTransport, VectorTransport, WorkspaceDescriptor } from "./schema.js"; export type InstallationRole = "DWH" | "VECTOR" | "VECTOR_WRITER" | "EMBEDDING"; export type InstallationSuffix = @@ -68,7 +68,7 @@ const EMBEDDING_SUFFIXES: readonly InstallationSuffix[] = [ "TLS_CA_FILE", ]; -function namespaceFor(workspace: CanonicalWorkspace): string { +function namespaceFor(workspace: WorkspaceDescriptor): string { return workspace.workspace.id.replaceAll("-", "_").toUpperCase(); } @@ -119,7 +119,7 @@ function connectorVariables( ]; } -export function buildInstallationContract(workspace: CanonicalWorkspace): InstallationContract { +export function buildInstallationContract(workspace: WorkspaceDescriptor): InstallationContract { const canonical = validateCanonicalWorkspace(workspace); const namespace = namespaceFor(canonical); @@ -141,13 +141,13 @@ export function buildInstallationContract(workspace: CanonicalWorkspace): Instal }; } -function localizedIntroduction(workspace: CanonicalWorkspace): string { +function localizedIntroduction(workspace: WorkspaceDescriptor): string { return workspace.workspace.language === "it" ? `Configurazione dell'installazione per ${workspace.workspace.name}. Imposta solo i binding supportati da questa installazione.` : `Installation setup for ${workspace.workspace.name}. Configure only the bindings supported by this installation.`; } -export function renderWorkspaceDocs(workspace: CanonicalWorkspace): { envExample: string; markdown: string } { +export function renderWorkspaceDocs(workspace: WorkspaceDescriptor): { envExample: string; markdown: string } { const canonical = validateCanonicalWorkspace(workspace); const contract = buildInstallationContract(canonical); const variablesByRole = new Map(); diff --git a/backend/src/workspaces/diagnostics.ts b/backend/src/workspaces/diagnostics.ts index 55f88f5e..d25dfa02 100644 --- a/backend/src/workspaces/diagnostics.ts +++ b/backend/src/workspaces/diagnostics.ts @@ -5,7 +5,7 @@ import { once } from "node:events"; import { MAX_WORKSPACE_DIAGNOSTIC_TIMEOUT_MS } from "../config.js"; import { buildInstallationContract } from "./contracts.js"; import type { RuntimeBindings } from "./runtime-renderer.js"; -import { validateCanonicalWorkspace, type CanonicalWorkspace } from "./schema.js"; +import { validateCanonicalWorkspace, type CanonicalWorkspace, type WorkspaceDescriptor } from "./schema.js"; import type { WorkspaceErrorCode } from "./types.js"; export interface Diagnostic { @@ -388,7 +388,7 @@ export function createWorkspaceDiagnoser( const fallbackTimeout = boundedTimeout(options.timeoutMs, DEFAULT_WORKSPACE_DIAGNOSTIC_TIMEOUT_MS); return async function diagnoseWorkspace( - workspace: CanonicalWorkspace, + workspace: WorkspaceDescriptor, bindings: RuntimeBindings, options: { writeProbe: boolean }, ): Promise { diff --git a/backend/src/workspaces/registry.ts b/backend/src/workspaces/registry.ts index 727b2ea0..aa8747cc 100644 --- a/backend/src/workspaces/registry.ts +++ b/backend/src/workspaces/registry.ts @@ -9,7 +9,13 @@ import { WorkspaceRepositoryLock, type GitStatus, } from "./git-repository.js"; -import { parseWorkspaceYaml, serializeWorkspaceYaml, type CanonicalWorkspace } from "./schema.js"; +import { + isCanonicalWorkspace, + parseWorkspaceYaml, + serializeWorkspaceYaml, + type CanonicalWorkspace, + type WorkspaceDescriptor, +} from "./schema.js"; import type { WorkspaceErrorCode, WorkspaceRegistryConfig } from "./types.js"; export type { GitStatus } from "./git-repository.js"; @@ -19,6 +25,7 @@ export interface WorkspaceRevision { commit: string; blob: string; snapshotPath: string; + state: "operational" | "migration_required"; } export type PublishWorkspaceRequest = @@ -109,7 +116,7 @@ export class WorkspaceRegistry { return (await this.activeState()).revisions; } - async read(id: string): Promise<{ workspace: CanonicalWorkspace; revision: WorkspaceRevision }> { + async read(id: string): Promise<{ workspace: WorkspaceDescriptor; revision: WorkspaceRevision }> { const state = await this.activeState(); const revision = state.revisions.find((candidate) => candidate.id === id); if (!revision) throw new WorkspaceRegistryError("workspace_invalid", "Workspace is unavailable"); @@ -133,7 +140,13 @@ export class WorkspaceRegistry { throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository contains no workspaces"); } - const snapshots: Array<{ id: string; source: string; workspace: CanonicalWorkspace; blob: string }> = []; + const snapshots: Array<{ + id: string; + source: string; + workspace: WorkspaceDescriptor; + blob: string; + state: WorkspaceRevision["state"]; + }> = []; try { for (const path of files) { const id = path.slice("workspaces/".length, -".yaml".length); @@ -142,9 +155,22 @@ export class WorkspaceRegistry { if (workspace.workspace.id !== id) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace ID does not match its repository path"); } - buildInstallationContract(workspace); - renderWorkspaceDocs(workspace); - snapshots.push({ id, source: serializeWorkspaceYaml(workspace), workspace, blob: await this.repository.blob(path) }); + let snapshotSource = source; + const state: WorkspaceRevision["state"] = isCanonicalWorkspace(workspace) + ? "operational" + : "migration_required"; + if (isCanonicalWorkspace(workspace)) { + buildInstallationContract(workspace); + renderWorkspaceDocs(workspace); + snapshotSource = serializeWorkspaceYaml(workspace); + } + snapshots.push({ + id, + source: snapshotSource, + workspace, + blob: await this.repository.blob(path), + state, + }); } } catch (error) { throw workspaceError(error); @@ -156,6 +182,7 @@ export class WorkspaceRegistry { commit: safeHead, blob: snapshot.blob, snapshotPath: this.snapshotPath(safeHead, snapshot.id), + state: snapshot.state, })); if (this.pathExists(snapshotDirectory)) { await this.assertSnapshotIntegrity({ head: safeHead, revisions }); @@ -168,13 +195,15 @@ export class WorkspaceRegistry { const yamlName = `${snapshot.id}.yaml`; const envName = `${snapshot.id}.env.example`; const docsName = `${snapshot.id}.md`; - const docs = renderWorkspaceDocs(snapshot.workspace); await writeFile(join(staging, yamlName), snapshot.source, { encoding: "utf8", mode: 0o400 }); - await writeFile(join(staging, envName), docs.envExample, { encoding: "utf8", mode: 0o400 }); - await writeFile(join(staging, docsName), docs.markdown, { encoding: "utf8", mode: 0o400 }); files[yamlName] = digest(snapshot.source); - files[envName] = digest(docs.envExample); - files[docsName] = digest(docs.markdown); + if (snapshot.state === "operational") { + const docs = renderWorkspaceDocs(snapshot.workspace); + await writeFile(join(staging, envName), docs.envExample, { encoding: "utf8", mode: 0o400 }); + await writeFile(join(staging, docsName), docs.markdown, { encoding: "utf8", mode: 0o400 }); + files[envName] = digest(docs.envExample); + files[docsName] = digest(docs.markdown); + } } await writeFile(join(staging, "snapshot.json"), JSON.stringify({ head: safeHead, revisions, files }), { encoding: "utf8", mode: 0o400, @@ -239,6 +268,7 @@ export class WorkspaceRegistry { safeCommit(revision.commit); safeBlob(revision.blob); if (revision.commit !== state.head || ids.has(revision.id)) throw new Error("bad revision"); + if (revision.state !== "operational" && revision.state !== "migration_required") throw new Error("bad revision"); ids.add(revision.id); workspacePath(revision.id); if (!isAbsolute(revision.snapshotPath) || revision.snapshotPath !== this.snapshotPath(revision.commit, revision.id)) { @@ -256,9 +286,9 @@ export class WorkspaceRegistry { if (manifest.head !== state.head || !this.sameRevisions(manifest.revisions, state.revisions)) { throw new Error("manifest revisions do not match active state"); } - const expected = state.revisions.flatMap((revision) => [ - `${revision.id}.yaml`, `${revision.id}.env.example`, `${revision.id}.md`, - ]); + const expected = state.revisions.flatMap((revision) => revision.state === "operational" + ? [`${revision.id}.yaml`, `${revision.id}.env.example`, `${revision.id}.md`] + : [`${revision.id}.yaml`]); if (Object.keys(manifest.files).length !== expected.length || !expected.every((name) => ( /^[0-9a-f]{64}$/.test(manifest.files[name] ?? "") ))) throw new Error("manifest files are invalid"); @@ -284,7 +314,8 @@ export class WorkspaceRegistry { const candidate = right[index]; return candidate !== undefined && candidate.id === revision.id && candidate.commit === revision.commit - && candidate.blob === revision.blob && candidate.snapshotPath === revision.snapshotPath; + && candidate.blob === revision.blob && candidate.snapshotPath === revision.snapshotPath + && candidate.state === revision.state; }); } diff --git a/backend/src/workspaces/runtime-renderer.ts b/backend/src/workspaces/runtime-renderer.ts index 58bb2311..5695503d 100644 --- a/backend/src/workspaces/runtime-renderer.ts +++ b/backend/src/workspaces/runtime-renderer.ts @@ -1,6 +1,6 @@ import { stringify } from "yaml"; import { buildInstallationContract } from "./contracts.js"; -import { validateCanonicalWorkspace, type CanonicalWorkspace } from "./schema.js"; +import { validateCanonicalWorkspace, type WorkspaceDescriptor } from "./schema.js"; import type { ResolvedBinding } from "./bindings.js"; export interface RuntimeBindings { @@ -74,7 +74,7 @@ function placeholderConnection(identity: { database: string; schema: string }): /** Render the compatibility fields consumed by the current Python harness. */ export function renderRuntimeConfig( - workspace: CanonicalWorkspace, + workspace: WorkspaceDescriptor, bindings: RuntimeBindings, paths: RuntimePaths, ): string { diff --git a/backend/src/workspaces/schema.ts b/backend/src/workspaces/schema.ts index 5cab72cc..e4b0f5b3 100644 --- a/backend/src/workspaces/schema.ts +++ b/backend/src/workspaces/schema.ts @@ -20,56 +20,58 @@ export interface RestDiagnosticRequest { } export interface CanonicalDiagnostics { - dwh_rest?: RestDiagnosticRequest & { - response: { database: string; schema: string }; - }; + dwh_rest?: RestDiagnosticRequest & { response: { database: string; schema: string } }; vector_rest?: { metadata: RestDiagnosticRequest & { response: { collection: string; dimensions: string; distance: string }; }; reversible_probe?: RestDiagnosticRequest & { method: "POST" }; }; - embedding?: RestDiagnosticRequest & { - response: { model: string; dimensions: string }; + embedding?: RestDiagnosticRequest & { response: { model: string; dimensions: string } }; +} + +interface WorkspaceMetadata { + schema_version: Version; + id: string; + name: string; + description?: string; + language: "en" | "it"; +} + +interface WorkspaceDwh { + engine: "postgres"; + database: string; + schema: string; + port?: number; + timeout_ms?: number; + supported_transports: DwhTransport[]; +} + +interface VectorStore { + engine: "pgvector"; + collection: string; + dimensions: number; + distance: "cosine" | "l2" | "inner_product"; + port?: number; + timeout_ms?: number; + supported_transports: VectorTransport[]; +} + +interface SemanticIndex { + vector_store: TVectorStore; + vector_writer?: Record; + embedding: { + provider: "ollama_compatible" | "openai_compatible"; + model: string; + dimensions: number; + timeout_ms?: number; }; } -export interface CanonicalWorkspace { - workspace: { - schema_version: 1; - id: string; - name: string; - description?: string; - language: "en" | "it"; - }; - dwh: { - engine: "postgres"; - database: string; - schema: string; - port?: number; - timeout_ms?: number; - supported_transports: DwhTransport[]; - }; - semantic_index: { - vector_store: { - engine: "pgvector"; - database: string; - schema: string; - collection: string; - dimensions: number; - distance: "cosine" | "l2" | "inner_product"; - port?: number; - timeout_ms?: number; - supported_transports: VectorTransport[]; - }; - vector_writer?: Record; - embedding: { - provider: "ollama_compatible" | "openai_compatible"; - model: string; - dimensions: number; - timeout_ms?: number; - }; - }; +interface WorkspaceBase { + workspace: WorkspaceMetadata; + dwh: WorkspaceDwh; + semantic_index: SemanticIndex; llm_policy: { default?: `${string}/${string}`; allowed: `${string}/${string}`[]; @@ -77,6 +79,13 @@ export interface CanonicalWorkspace { diagnostics?: CanonicalDiagnostics; } +export interface CanonicalWorkspace extends WorkspaceBase<2, VectorStore & { database: string; schema: string }> {} + +/** A readable, non-operational v1 descriptor. It must be explicitly migrated before use. */ +export interface LegacyWorkspace extends WorkspaceBase<1, VectorStore & { database?: string; schema?: string }> {} + +export type WorkspaceDescriptor = CanonicalWorkspace | LegacyWorkspace; + const workspaceId = z.string().regex(/^[a-z][a-z0-9-]{2,62}$/, { message: "workspace id must match ^[a-z][a-z0-9-]{2,62}$", }); @@ -89,8 +98,13 @@ const timeoutMs = z.number().int().positive(); const modelReference = z.string().regex(/^[^/\s]+\/[^/\s]+$/, { message: "model must use provider/model syntax", }); -const diagnosticPath = z.string().regex(/^\/[^\s?#]*$/, { - message: "diagnostic paths must be absolute and cannot include whitespace, queries, or fragments", + +function isOriginRelativeDiagnosticPath(value: string): boolean { + return /^\/(?!\/)[^\\\u0000-\u001F\u007F?#]*$/.test(value) && !/%5c/i.test(value); +} + +const diagnosticPath = z.string().refine(isOriginRelativeDiagnosticPath, { + message: "diagnostic paths must be origin-relative and cannot contain backslashes, control characters, queries, or fragments", }); const responseField = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/, { message: "diagnostic response fields must be identifiers", @@ -114,6 +128,52 @@ const reversibleVectorProbe = restDiagnosticRequest.extend({ method: z.literal(" const embeddingDiagnostic = restDiagnosticRequest.extend({ response: z.object({ model: responseField, dimensions: responseField }).strict(), }).strict(); +const diagnosticsSchema = z.object({ + dwh_rest: dwhRestDiagnostic.optional(), + vector_rest: z.object({ + metadata: vectorMetadataDiagnostic, + reversible_probe: reversibleVectorProbe.optional(), + }).strict().optional(), + embedding: embeddingDiagnostic.optional(), +}).strict().optional(); + +const dwhSchema = z.object({ + engine: z.literal("postgres"), + database: identifier, + schema: identifier, + port: port.optional(), + timeout_ms: timeoutMs.optional(), + supported_transports: z.array(z.enum(DWH_TRANSPORTS)).min(1), +}).strict(); +const embeddingSchema = z.object({ + provider: z.enum(["ollama_compatible", "openai_compatible"]), + model: z.string().trim().min(1), + dimensions, + timeout_ms: timeoutMs.optional(), +}).strict(); +const vectorStoreShape = { + engine: z.literal("pgvector"), + collection: identifier, + dimensions, + distance: z.enum(["cosine", "l2", "inner_product"]), + port: port.optional(), + timeout_ms: timeoutMs.optional(), + supported_transports: z.array(z.enum(VECTOR_TRANSPORTS)).min(1), +}; +const legacyVectorStoreSchema = z.object({ + ...vectorStoreShape, + database: identifier.optional(), + schema: identifier.optional(), +}).strict(); +const canonicalVectorStoreSchema = z.object({ + ...vectorStoreShape, + database: identifier, + schema: identifier, +}).strict(); +const llmPolicySchema = z.object({ + default: modelReference.optional(), + allowed: z.array(modelReference).min(1), +}).strict(); function unique(values: readonly T[], context: z.RefinementCtx, path: PropertyKey[]) { if (new Set(values).size !== values.length) { @@ -121,55 +181,7 @@ function unique(values: readonly T[], context: z.RefinementCtx, path: Propert } } -const WorkspaceSchema = z.object({ - workspace: z.object({ - schema_version: z.literal(1), - id: workspaceId, - name: z.string().trim().min(1), - description: z.string().trim().min(1).optional(), - language: z.enum(["en", "it"]), - }).strict(), - dwh: z.object({ - engine: z.literal("postgres"), - database: identifier, - schema: identifier, - port: port.optional(), - timeout_ms: timeoutMs.optional(), - supported_transports: z.array(z.enum(DWH_TRANSPORTS)).min(1), - }).strict(), - semantic_index: z.object({ - vector_store: z.object({ - engine: z.literal("pgvector"), - database: identifier, - schema: identifier, - collection: identifier, - dimensions, - distance: z.enum(["cosine", "l2", "inner_product"]), - port: port.optional(), - timeout_ms: timeoutMs.optional(), - supported_transports: z.array(z.enum(VECTOR_TRANSPORTS)).min(1), - }).strict(), - vector_writer: z.object({}).strict().optional(), - embedding: z.object({ - provider: z.enum(["ollama_compatible", "openai_compatible"]), - model: z.string().trim().min(1), - dimensions, - timeout_ms: timeoutMs.optional(), - }).strict(), - }).strict(), - llm_policy: z.object({ - default: modelReference.optional(), - allowed: z.array(modelReference).min(1), - }).strict(), - diagnostics: z.object({ - dwh_rest: dwhRestDiagnostic.optional(), - vector_rest: z.object({ - metadata: vectorMetadataDiagnostic, - reversible_probe: reversibleVectorProbe.optional(), - }).strict().optional(), - embedding: embeddingDiagnostic.optional(), - }).strict().optional(), -}).strict().superRefine((workspace, context) => { +function workspaceInvariants(workspace: any, context: z.RefinementCtx): void { unique(workspace.dwh.supported_transports, context, ["dwh", "supported_transports"]); unique( workspace.semantic_index.vector_store.supported_transports, @@ -185,7 +197,6 @@ const WorkspaceSchema = z.object({ message: "embedding dimensions must match vector store dimensions", }); } - if (workspace.llm_policy.default && !workspace.llm_policy.allowed.includes(workspace.llm_policy.default)) { context.addIssue({ code: "custom", @@ -193,24 +204,110 @@ const WorkspaceSchema = z.object({ message: "LLM default must be included in the allowlist", }); } -}); - -export function parseWorkspaceYaml(source: string): CanonicalWorkspace { - const documents = parseAllDocuments(source, { uniqueKeys: true }); - if (documents.length !== 1) { - throw new Error("Workspace YAML must contain exactly one document"); + if (workspace.diagnostics?.dwh_rest && !workspace.dwh.supported_transports.includes("rest_api")) { + context.addIssue({ + code: "custom", + path: ["diagnostics", "dwh_rest"], + message: "diagnostics.dwh_rest requires dwh rest_api transport support", + }); } + if ( + workspace.diagnostics?.vector_rest + && !workspace.semantic_index.vector_store.supported_transports.includes("rest_api") + ) { + context.addIssue({ + code: "custom", + path: ["diagnostics", "vector_rest"], + message: "diagnostics.vector_rest requires vector_store rest_api transport support", + }); + } +} +const workspaceShape = { + dwh: dwhSchema, + llm_policy: llmPolicySchema, + diagnostics: diagnosticsSchema, +}; +const LegacyWorkspaceSchema = z.object({ + ...workspaceShape, + workspace: z.object({ + schema_version: z.literal(1), id: workspaceId, name: z.string().trim().min(1), + description: z.string().trim().min(1).optional(), language: z.enum(["en", "it"]), + }).strict(), + semantic_index: z.object({ + vector_store: legacyVectorStoreSchema, + vector_writer: z.object({}).strict().optional(), + embedding: embeddingSchema, + }).strict(), +}).strict().superRefine(workspaceInvariants); +const CanonicalWorkspaceSchema = z.object({ + ...workspaceShape, + workspace: z.object({ + schema_version: z.literal(2), id: workspaceId, name: z.string().trim().min(1), + description: z.string().trim().min(1).optional(), language: z.enum(["en", "it"]), + }).strict(), + semantic_index: z.object({ + vector_store: canonicalVectorStoreSchema, + vector_writer: z.object({}).strict().optional(), + embedding: embeddingSchema, + }).strict(), +}).strict().superRefine(workspaceInvariants); +const WorkspaceDescriptorSchema = z.union([CanonicalWorkspaceSchema, LegacyWorkspaceSchema]); + +export function parseWorkspaceYaml(source: string): WorkspaceDescriptor { + const documents = parseAllDocuments(source, { uniqueKeys: true }); + if (documents.length !== 1) throw new Error("Workspace YAML must contain exactly one document"); const document = documents[0]; if (document.errors.length > 0) { throw new Error(`Invalid workspace YAML: ${document.errors.map((error) => error.message).join("; ")}`); } - - return validateCanonicalWorkspace(document.toJSON()); + return validateWorkspaceDescriptor(document.toJSON()); } +export function validateWorkspaceDescriptor(workspace: unknown): WorkspaceDescriptor { + return WorkspaceDescriptorSchema.parse(workspace) as WorkspaceDescriptor; +} + +export function isCanonicalWorkspace(workspace: WorkspaceDescriptor): workspace is CanonicalWorkspace { + return workspace.workspace.schema_version === 2; +} + +/** Rejects readable v1 descriptors at every operational boundary until a caller migrates them. */ export function validateCanonicalWorkspace(workspace: unknown): CanonicalWorkspace { - return WorkspaceSchema.parse(workspace) as CanonicalWorkspace; + const descriptor = validateWorkspaceDescriptor(workspace); + if (!isCanonicalWorkspace(descriptor)) { + throw new Error("Workspace descriptor requires explicit migration to schema version 2"); + } + return descriptor; +} + +/** + * Explicitly upgrades a readable v1 descriptor. The caller must supply vector identity; the + * transformer never derives it from DWH identity, even where both services share a database. + */ +export function migrateWorkspaceV1ToV2( + workspace: LegacyWorkspace, + vectorIdentity: { database: string; schema: string }, +): CanonicalWorkspace { + const legacy = LegacyWorkspaceSchema.parse(workspace) as LegacyWorkspace; + const identity = z.object({ database: identifier, schema: identifier }).strict().parse(vectorIdentity); + return validateCanonicalWorkspace({ + ...legacy, + workspace: { ...legacy.workspace, schema_version: 2 }, + semantic_index: { + ...legacy.semantic_index, + vector_store: { ...legacy.semantic_index.vector_store, ...identity }, + }, + }); +} + +/** Builds a request URL only after rejecting values that can leave the declared service origin. */ +export function resolveDiagnosticUrl(baseUrl: string, path: string): URL { + if (!isOriginRelativeDiagnosticPath(path)) throw new Error("Diagnostic path must remain on the configured origin"); + const base = new URL(baseUrl); + const resolved = new URL(path, base); + if (resolved.origin !== base.origin) throw new Error("Diagnostic URL must remain on the configured origin"); + return resolved; } export function serializeWorkspaceYaml(workspace: CanonicalWorkspace): string { diff --git a/backend/test/workspace-registry.test.ts b/backend/test/workspace-registry.test.ts index 4ba84cf4..768f912c 100644 --- a/backend/test/workspace-registry.test.ts +++ b/backend/test/workspace-registry.test.ts @@ -11,7 +11,7 @@ import { WorkspaceRegistry } from "../src/workspaces/registry.js"; import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js"; const validYaml = `workspace: - schema_version: 1 + schema_version: 2 id: psd-clinical name: Policlinico San Donato language: it @@ -48,7 +48,7 @@ async function git(cwd: string, args: string[]): Promise { await runFile("git", args, { cwd }); } -async function fixture(): Promise<{ +async function fixture(workspaceSource = validYaml): Promise<{ root: string; remote: string; source: string; initialCommit: string; }> { const root = mkdtempSync(join(tmpdir(), "thoth-workspace-registry-")); @@ -61,7 +61,7 @@ async function fixture(): Promise<{ await git(source, ["config", "user.name", "Workspace Registry Test"]); await git(source, ["config", "user.email", "workspace-registry@example.invalid"]); mkdirSync(join(source, "workspaces")); - writeFileSync(join(source, "workspaces", "psd-clinical.yaml"), validYaml); + writeFileSync(join(source, "workspaces", "psd-clinical.yaml"), workspaceSource); await git(source, ["add", "workspaces/psd-clinical.yaml"]); await git(source, ["commit", "-m", "Initial workspace"]); await git(source, ["remote", "add", "origin", remote]); @@ -104,6 +104,25 @@ test("bootstraps a checkout and activates a validated immutable snapshot", async }); }); +test("lists a v1 descriptor in migration-required state without rendering operational artifacts", async () => { + const legacyYaml = validYaml.replace( + " database: postgres\n schema: vectors\n", + "", + ).replace("schema_version: 2", "schema_version: 1"); + const remote = await fixture(legacyYaml); + const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); + + const status = await registry.bootstrap(); + const [revision] = await registry.list(); + + expect(revision).toMatchObject({ state: "migration_required" }); + await expect(registry.read("psd-clinical")).resolves.toMatchObject({ + workspace: { workspace: { schema_version: 1 } }, + }); + expect(existsSync(join(remote.root, "registry", "snapshots", status.head!, "psd-clinical.env.example"))).toBe(false); + expect(existsSync(join(remote.root, "registry", "snapshots", status.head!, "psd-clinical.md"))).toBe(false); +}); + test("keeps the last valid snapshot when a pulled commit has invalid YAML", async () => { const remote = await fixture(); const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); diff --git a/backend/test/workspace-runtime-renderer.test.ts b/backend/test/workspace-runtime-renderer.test.ts index 7315f629..2c682f54 100644 --- a/backend/test/workspace-runtime-renderer.test.ts +++ b/backend/test/workspace-runtime-renderer.test.ts @@ -4,7 +4,7 @@ import { renderRuntimeConfig, type RuntimeBindings, type RuntimePaths } from ".. import { parseWorkspaceYaml } from "../src/workspaces/schema.js"; const workspace = parseWorkspaceYaml(`workspace: - schema_version: 1 + schema_version: 2 id: psd-clinical name: Policlinico San Donato language: it @@ -34,6 +34,30 @@ const paths: RuntimePaths = { artifacts: "/data/workspaces/psd-clinical/artifacts", indexes: "/data/workspaces/psd-clinical/indexes", }; +const legacyWorkspace = parseWorkspaceYaml(`workspace: + schema_version: 1 + id: psd-clinical + name: Policlinico San Donato + language: it +dwh: + engine: postgres + database: postgres + schema: datawarehouse + supported_transports: [postgres_direct] +semantic_index: + vector_store: + engine: pgvector + collection: clinical_documents + dimensions: 768 + distance: cosine + supported_transports: [pgvector_direct] + embedding: + provider: ollama_compatible + model: nomic-embed-text-v2-moe + dimensions: 768 +llm_policy: + allowed: [zai/glm-5.2] +`); const directBindings: RuntimeBindings = { dwh: { @@ -99,6 +123,10 @@ test("renders a direct PostgreSQL binding to the legacy harness shape", () => { expect(yaml).toContain("schema: datawarehouse"); }); +test("refuses to render a v1 descriptor until an explicit migration creates v2", () => { + expect(() => renderRuntimeConfig(legacyWorkspace, directBindings, paths)).toThrow(/migrat/i); +}); + test("omits direct TLS fields when binding validation did not retain a file path", () => { const dwhValues = { ...directBindings.dwh.values }; const vectorValues = { ...directBindings.vector.values }; diff --git a/backend/test/workspaces-bindings.test.ts b/backend/test/workspaces-bindings.test.ts index 31d8786e..2418e334 100644 --- a/backend/test/workspaces-bindings.test.ts +++ b/backend/test/workspaces-bindings.test.ts @@ -6,7 +6,7 @@ import { resolveBinding } from "../src/workspaces/bindings.js"; import { parseWorkspaceYaml } from "../src/workspaces/schema.js"; const workspace = parseWorkspaceYaml(`workspace: - schema_version: 1 + schema_version: 2 id: psd-clinical name: Policlinico San Donato language: it @@ -121,3 +121,20 @@ test("rejects a selected transport that the canonical workspace does not support missing: ["THT_WS_PSD_CLINICAL_DWH_TRANSPORT"], }); }); + +test("never treats a vector reader credential as the optional writer binding", () => { + const readerKey = secretPath("vector-reader-key"); + const writerWorkspace = { + ...workspace, + semantic_index: { ...workspace.semantic_index, vector_writer: {} }, + }; + const resolveWriter = () => resolveBinding(writerWorkspace, "VECTOR_WRITER" as never, { + THT_WS_PSD_CLINICAL_VECTOR_API_KEY_FILE: readerKey.path, + }, [readerKey.root]); + + expect(resolveWriter).not.toThrow(); + expect(resolveWriter()).toMatchObject({ + missing: ["THT_WS_PSD_CLINICAL_VECTOR_WRITER_API_KEY_FILE"], + values: {}, + }); +}); diff --git a/backend/test/workspaces-contracts.test.ts b/backend/test/workspaces-contracts.test.ts index 00b2a927..ce4197f8 100644 --- a/backend/test/workspaces-contracts.test.ts +++ b/backend/test/workspaces-contracts.test.ts @@ -5,7 +5,7 @@ import { type CanonicalWorkspace, parseWorkspaceYaml } from "../src/workspaces/s import { renderRuntimeConfig, type RuntimeBindings } from "../src/workspaces/runtime-renderer.js"; const validWorkspace = parseWorkspaceYaml(`workspace: - schema_version: 1 + schema_version: 2 id: psd-clinical name: Policlinico San Donato language: it @@ -74,7 +74,7 @@ test("renders English UI headings and workspace-language Italian prose", () => { test("renders the vector store identity and creates writer credentials only when declared", () => { const writerWorkspace = parseWorkspaceYaml(`workspace: - schema_version: 1 + schema_version: 2 id: psd-clinical name: Policlinico San Donato language: it @@ -82,7 +82,7 @@ dwh: engine: postgres database: warehouse schema: datawarehouse - supported_transports: [postgres_direct] + supported_transports: [postgres_direct, rest_api] semantic_index: vector_store: engine: pgvector @@ -91,7 +91,7 @@ semantic_index: collection: clinical_documents dimensions: 768 distance: cosine - supported_transports: [pgvector_direct] + supported_transports: [pgvector_direct, rest_api] vector_writer: {} embedding: provider: ollama_compatible diff --git a/backend/test/workspaces-diagnostics.test.ts b/backend/test/workspaces-diagnostics.test.ts index 092ceb03..db2e3058 100644 --- a/backend/test/workspaces-diagnostics.test.ts +++ b/backend/test/workspaces-diagnostics.test.ts @@ -8,7 +8,7 @@ import type { RuntimeBindings } from "../src/workspaces/runtime-renderer.js"; import { parseWorkspaceYaml } from "../src/workspaces/schema.js"; const workspace = parseWorkspaceYaml(`workspace: - schema_version: 1 + schema_version: 2 id: psd-clinical name: Policlinico San Donato language: it diff --git a/backend/test/workspaces-git-repository.test.ts b/backend/test/workspaces-git-repository.test.ts index 2fc79c7f..7c827e09 100644 --- a/backend/test/workspaces-git-repository.test.ts +++ b/backend/test/workspaces-git-repository.test.ts @@ -8,7 +8,7 @@ import { GitWorkspaceRepository, WorkspaceRepositoryLock } from "../src/workspac import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js"; const validYaml = `workspace: - schema_version: 1 + schema_version: 2 id: psd-clinical name: Policlinico San Donato language: it diff --git a/backend/test/workspaces-schema.test.ts b/backend/test/workspaces-schema.test.ts index 3f79cdc3..145796d5 100644 --- a/backend/test/workspaces-schema.test.ts +++ b/backend/test/workspaces-schema.test.ts @@ -1,8 +1,9 @@ import { expect, test } from "vitest"; -import { parseWorkspaceYaml, serializeWorkspaceYaml } from "../src/workspaces/schema.js"; +import * as workspaceSchema from "../src/workspaces/schema.js"; +import { parseWorkspaceYaml, serializeWorkspaceYaml, validateCanonicalWorkspace } from "../src/workspaces/schema.js"; export const validYaml = `workspace: - schema_version: 1 + schema_version: 2 id: psd-clinical name: Policlinico San Donato description: Clinical data warehouse workspace @@ -119,6 +120,82 @@ test("requires explicit vector database and schema identities with strict diagno .toThrow(/method/i); expect(() => parseWorkspaceYaml(diagnosticWorkspace.replace(" distance: distance", " distance: distance\n extra: ignored"))) .toThrow(/unrecognized key/i); + for (const unsafePath of [ + "//diagnostic.invalid/rpc", "'/\\\\diagnostic'", "'/rpc\\\\diagnostic'", "'/rpc/%5Cdiagnostic'", "'/rpc/\u0001'", + ]) { + expect(() => parseWorkspaceYaml(diagnosticWorkspace.replace("path: /rpc/ping", `path: ${unsafePath}`))) + .toThrow(/origin-relative|path/i); + } + expect(() => parseWorkspaceYaml(diagnosticWorkspace.replace("auth: bearer", "auth: basic"))) + .toThrow(/auth/i); + expect(() => parseWorkspaceYaml(diagnosticWorkspace.replace(" schema: schema", " schema: schema\n status: status"))) + .toThrow(/unrecognized key/i); + expect(() => parseWorkspaceYaml(diagnosticWorkspace.replace(" schema: schema", " schema: bad field"))) + .toThrow(/response field/i); +}); + +test("keeps v1 descriptors readable but requires explicit migration before v2 operations", () => { + const v1WithoutVectorIdentity = validYaml.replace("schema_version: 2", "schema_version: 1").replace( + " database: postgres\n schema: vectors\n", "", + ); + expect(() => parseWorkspaceYaml(v1WithoutVectorIdentity)).not.toThrow(); + expect(() => parseWorkspaceYaml(validYaml)).not.toThrow(); + const v1 = parseWorkspaceYaml(v1WithoutVectorIdentity); + const v2 = parseWorkspaceYaml(validYaml); + + expect(v1.workspace.schema_version).toBe(1); + expect(() => validateCanonicalWorkspace(v1)).toThrow(/migrat/i); + expect(v2.workspace.schema_version).toBe(2); + + const migrate = (workspaceSchema as { migrateWorkspaceV1ToV2?: unknown }).migrateWorkspaceV1ToV2; + expect(migrate).toBeTypeOf("function"); + const migrated = (migrate as (workspace: typeof v1, identity: { database: string; schema: string }) => unknown)(v1, { + database: "vector_database", + schema: "vectors", + }); + expect(validateCanonicalWorkspace(migrated)).toMatchObject({ + workspace: { schema_version: 2 }, + semantic_index: { vector_store: { database: "vector_database", schema: "vectors" } }, + }); +}); + +test("constructs diagnostic URLs only when the resolved URL remains on the service origin", () => { + const resolveDiagnosticUrl = (workspaceSchema as { resolveDiagnosticUrl?: unknown }).resolveDiagnosticUrl; + + expect(resolveDiagnosticUrl).toBeTypeOf("function"); + expect((resolveDiagnosticUrl as (baseUrl: string, path: string) => URL)("https://service.example/base", "/rpc/ping")) + .toMatchObject({ href: "https://service.example/rpc/ping" }); + expect(() => (resolveDiagnosticUrl as (baseUrl: string, path: string) => URL)( + "https://service.example/base", "//diagnostic.invalid/rpc", + )).toThrow(/origin/i); +}); + +test("rejects REST diagnostic declarations without their matching connector transport", () => { + const diagnostics = `diagnostics: + dwh_rest: + method: POST + path: /rpc/ping + auth: bearer + response: + database: database + schema: schema + vector_rest: + metadata: + method: GET + path: /metadata + auth: bearer + response: + collection: collection + dimensions: dimensions + distance: distance +llm_policy: +`; + const declared = validYaml.replace("llm_policy:\n", diagnostics); + + expect(() => parseWorkspaceYaml(declared.replace(" - rest_api\n", ""))).toThrow(/dwh_rest/i); + expect(() => parseWorkspaceYaml(declared.replace(" - rest_api\n", " - rest_api\n", 1).replace( + " - rest_api\n", "", + ))).toThrow(/vector_rest/i); }); test("serializes canonical YAML that parses back to the same workspace", () => { diff --git a/docs/superpowers/specs/2026-08-03-git-workspace-registry-design.md b/docs/superpowers/specs/2026-08-03-git-workspace-registry-design.md index bc2e1bca..f131414c 100644 --- a/docs/superpowers/specs/2026-08-03-git-workspace-registry-design.md +++ b/docs/superpowers/specs/2026-08-03-git-workspace-registry-design.md @@ -114,7 +114,7 @@ The initial canonical shape is: ```yaml workspace: - schema_version: 1 + schema_version: 2 id: psd-clinical name: Policlinico San Donato description: Clinical data warehouse workspace @@ -132,6 +132,8 @@ dwh: semantic_index: vector_store: engine: pgvector + database: postgres + schema: vectors collection: clinical_documents dimensions: 768 distance: cosine @@ -153,7 +155,20 @@ llm_policy: The exact machine schema is maintained by `WorkspaceSchema` and versioned with explicit migrations. Unknown keys are rejected by default so misspellings do not silently change runtime behavior. -### 6.1 Semantic-index invariant +### 6.1 Version migration and operational state + +Schema version 2 makes `semantic_index.vector_store.database` and `.schema` mandatory. They +identify the vector service independently of the DWH, even when both happen to use the same +PostgreSQL instance. + +Version 1 descriptors remain readable and listable so operators can discover legacy Git content. +They are marked `migration_required` and may not generate installation bindings, runtime +configuration, diagnostics, or publication artifacts. Migration is an explicit UI/transformer +action that supplies the vector database/schema; it must never infer either value from the DWH. +The resulting descriptor is written as schema version 2 and then passes normal operational +validation. + +### 6.2 Semantic-index invariant `semantic_index` is atomic. The vector collection, vector dimensions, distance metric, embedding provider, embedding model, and embedding dimensions describe one index contract.