diff --git a/backend/src/routes/workspaces.ts b/backend/src/routes/workspaces.ts index f08e2559..5aa5a638 100644 --- a/backend/src/routes/workspaces.ts +++ b/backend/src/routes/workspaces.ts @@ -15,11 +15,10 @@ import { import { resolveRuntimeBindings } from "../workspaces/bindings.js"; import { buildInstallationContract, renderWorkspaceDocs } from "../workspaces/contracts.js"; import { - isCanonicalWorkspace, parseWorkspaceYaml, serializeWorkspaceYaml, - validateCanonicalWorkspace, validateOperationalWorkspace, + validateWorkspaceDescriptor, type CanonicalWorkspace, type WorkspaceDescriptor, } from "../workspaces/schema.js"; @@ -177,7 +176,7 @@ async function importDraft(source: Buffer, config: WorkspaceRegistryConfig): Pro } try { const descriptor = parseWorkspaceYaml(utf8(files["workspace.yaml"])); - const workspace = validateCanonicalWorkspace(descriptor); + const workspace = validateWorkspaceDescriptor(descriptor); const docs = renderWorkspaceDocs(workspace); if ( workspace.workspace.id !== manifest.workspace_id @@ -231,6 +230,14 @@ function workspaceErrorStatus(code: keyof typeof SAFE_MESSAGES): number { return 400; } +function validatedWorkspace(value: unknown): WorkspaceDescriptor | undefined { + try { + return validateWorkspaceDescriptor(value); + } catch { + return undefined; + } +} + function errorReply(reply: FastifyReply, error: unknown) { const code = workspaceErrorCode(error); const body: Record = { code, message: SAFE_MESSAGES[code] }; @@ -253,7 +260,8 @@ function errorReply(reply: FastifyReply, error: unknown) { ) body[key] = revision; } for (const key of ["base", "local", "remote"] as const) { - if (conflict[key] && isCanonicalWorkspace(conflict[key] as WorkspaceDescriptor)) body[key] = conflict[key]; + const workspace = validatedWorkspace(conflict[key]); + if (workspace) body[key] = workspace; } } return reply.code(workspaceErrorStatus(code)).send(body); @@ -262,7 +270,7 @@ function errorReply(reply: FastifyReply, error: unknown) { function publishRequest(value: unknown): PublishWorkspaceRequest { const parsed = publishPayload.parse(value); if (parsed.action === "delete") return parsed; - return { ...parsed, workspace: validateCanonicalWorkspace(parsed.workspace) }; + return { ...parsed, workspace: validateWorkspaceDescriptor(parsed.workspace) }; } export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps): void { @@ -321,7 +329,7 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps) app.post("/workspaces/validate", async (request, reply) => { try { const { workspace } = workspacePayload.parse(request.body); - const canonical = validateCanonicalWorkspace(workspace); + const canonical = validateWorkspaceDescriptor(workspace); return { workspace: canonical, contract: buildInstallationContract(canonical) }; } catch (error) { return errorReply(reply, error); @@ -365,7 +373,7 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps) try { const { id } = z.object({ id: workspaceId }).parse(request.params); const { workspace } = await deps.registry.read(id); - const canonical = validateCanonicalWorkspace(workspace); + const canonical = validateWorkspaceDescriptor(workspace); const bundle = await exportBundle(canonical); return reply .type("application/zip") diff --git a/backend/src/workspaces/bindings.ts b/backend/src/workspaces/bindings.ts index 19c070c7..dbc4784b 100644 --- a/backend/src/workspaces/bindings.ts +++ b/backend/src/workspaces/bindings.ts @@ -9,6 +9,7 @@ import { type VectorTransport, type WorkspaceDescriptor, } from "./schema.js"; +import type { DeprecatedV2Descriptor } from "./deprecated-v2-descriptor.js"; export interface ResolvedEvidenceBinding { values: Record; @@ -85,7 +86,7 @@ function requiredSuffixes( const required = REQUIRED_SUFFIXES[role][transport] ?? []; const diagnostic = role === "DWH" ? workspace.diagnostics?.dwh_rest - : workspace.diagnostics?.vector_rest?.metadata; + : (workspace as unknown as DeprecatedV2Descriptor).diagnostics?.vector_rest?.metadata; return transport === "rest_api" && diagnostic?.auth === "none" ? required.filter((suffix) => suffix !== "API_KEY_FILE") : required; @@ -107,14 +108,13 @@ export function resolveBinding( } const contract = buildInstallationContract(descriptor); + const legacy = descriptor as unknown as DeprecatedV2Descriptor; const variables = contract.variables.filter((variable) => variable.role === role); const transportVariable = variables.find((variable) => variable.suffix === "TRANSPORT"); const supported = role === "DWH" ? descriptor.dwh.supported_transports : role === "VECTOR" - ? ("supported_transports" in descriptor.semantic_index.vector_store - ? descriptor.semantic_index.vector_store.supported_transports - : []) + ? legacy.semantic_index.vector_store.supported_transports : ["rest_api"] as const; const selectedValue = transportVariable ? env[transportVariable.name] : undefined; const selectedTransport = isTransport(selectedValue) ? selectedValue : supported[0]; diff --git a/backend/src/workspaces/contracts.ts b/backend/src/workspaces/contracts.ts index eec16656..e535caa9 100644 --- a/backend/src/workspaces/contracts.ts +++ b/backend/src/workspaces/contracts.ts @@ -1,5 +1,6 @@ import { validateWorkspaceDescriptor } from "./schema.js"; import type { DwhTransport, VectorTransport, WorkspaceDescriptor } from "./schema.js"; +import type { DeprecatedV2Descriptor } from "./deprecated-v2-descriptor.js"; export type InstallationRole = "DWH" | "VECTOR" | "VECTOR_WRITER" | "EMBEDDING" | "EVIDENCE"; export type InstallationSuffix = @@ -145,25 +146,25 @@ function evidenceVariables( export function buildInstallationContract(workspace: WorkspaceDescriptor): InstallationContract { const descriptor = validateWorkspaceDescriptor(workspace); const namespace = namespaceFor(descriptor); + const schemaVersion = Number(descriptor.workspace.schema_version); + const legacy = descriptor as unknown as DeprecatedV2Descriptor; return { workspaceId: descriptor.workspace.id, namespace, variables: [ ...connectorVariables(namespace, "DWH", descriptor.dwh.supported_transports), - ...(descriptor.workspace.schema_version === 2 + ...(schemaVersion === 2 ? connectorVariables( namespace, "VECTOR", - "supported_transports" in descriptor.semantic_index.vector_store - ? descriptor.semantic_index.vector_store.supported_transports - : [], + legacy.semantic_index.vector_store.supported_transports, ) : []), - ...(descriptor.workspace.schema_version === 2 && descriptor.semantic_index.vector_writer + ...(schemaVersion === 2 && legacy.semantic_index.vector_writer ? [createVariable(namespace, "VECTOR_WRITER", "API_KEY_FILE")] : []), - ...(descriptor.workspace.schema_version === 2 + ...(schemaVersion === 2 ? EMBEDDING_SUFFIXES.map((suffix) => createVariable(namespace, "EMBEDDING", suffix)) : []), ...evidenceVariables(namespace, descriptor), diff --git a/backend/src/workspaces/deprecated-v2-descriptor.ts b/backend/src/workspaces/deprecated-v2-descriptor.ts new file mode 100644 index 00000000..dd5fe61e --- /dev/null +++ b/backend/src/workspaces/deprecated-v2-descriptor.ts @@ -0,0 +1,52 @@ +import type { + CanonicalDiagnostics, + DwhTransport, + VectorTransport, +} from "./schema.js"; + +/** + * Temporary compile-time shape for legacy runtime branches that will be removed separately. + * It is deliberately not part of the accepted workspace descriptor schema. + */ +export interface DeprecatedV2Descriptor { + workspace: { + schema_version: 2; + 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; + }; + }; + llm_policy: { + default?: `${string}/${string}`; + allowed: `${string}/${string}`[]; + }; + diagnostics?: CanonicalDiagnostics; +} diff --git a/backend/src/workspaces/diagnostics.ts b/backend/src/workspaces/diagnostics.ts index af082207..5fa4fde2 100644 --- a/backend/src/workspaces/diagnostics.ts +++ b/backend/src/workspaces/diagnostics.ts @@ -12,9 +12,9 @@ import { resolveDiagnosticUrl, validateWorkspaceDescriptor, type RestDiagnosticRequest, - type WorkspaceV2, type WorkspaceDescriptor, } from "./schema.js"; +import type { DeprecatedV2Descriptor } from "./deprecated-v2-descriptor.js"; import type { WorkspaceErrorCode } from "./types.js"; import type { SemanticRuntimeConfig } from "./runtime-renderer.js"; @@ -543,7 +543,7 @@ export function createProductionWorkspaceDiagnoser( options: { writeProbe: boolean }, ): Promise => { const descriptor = validateWorkspaceDescriptor(workspace); - if (descriptor.workspace.schema_version !== 3) { + if (Number(descriptor.workspace.schema_version) !== 3) { return await legacyDiagnoser(descriptor, bindings, options); } return await diagnoseSchemaV3Workspace( @@ -618,11 +618,11 @@ function diagnosticError(code: WorkspaceErrorCode, field?: string): Diagnostic { } function bindingName( - workspace: WorkspaceDescriptor, + workspace: WorkspaceDescriptor | DeprecatedV2Descriptor, role: "DWH" | "VECTOR" | "VECTOR_WRITER" | "EMBEDDING", suffix: string, ): string { - const entry = buildInstallationContract(workspace).variables.find((variable) => ( + const entry = buildInstallationContract(workspace as WorkspaceDescriptor).variables.find((variable) => ( variable.role === role && variable.suffix === suffix )); if (!entry) throw new Error(`workspace contract is missing ${role}_${suffix}`); @@ -798,7 +798,7 @@ async function diagnoseSchemaV3Workspace( } function diagnosticsForMissingBindings( - workspace: WorkspaceV2, + workspace: DeprecatedV2Descriptor, bindings: RuntimeBindings, ): Diagnostic[] { const missing = new Set([ @@ -815,7 +815,7 @@ function diagnosticsForMissingBindings( } function connectorRequest( - workspace: WorkspaceV2, + workspace: DeprecatedV2Descriptor, role: ConnectorRole, bindings: RuntimeBindings, timeoutMs: number, @@ -889,7 +889,7 @@ function connectorRequest( } function tunnelProbeRequest( - workspace: WorkspaceV2, + workspace: DeprecatedV2Descriptor, role: ConnectorRole, bindings: RuntimeBindings, timeoutMs: number, @@ -934,10 +934,10 @@ export function createWorkspaceDiagnoser( options: { writeProbe: boolean }, ): Promise { const descriptor = validateWorkspaceDescriptor(workspace); - if (descriptor.workspace.schema_version !== 2) { + if (Number(descriptor.workspace.schema_version) !== 2) { return { activatable: false, diagnostics: [diagnosticError("workspace_not_activatable")] }; } - const canonical = descriptor as WorkspaceV2; + const canonical = descriptor as unknown as DeprecatedV2Descriptor; const diagnostics = diagnosticsForMissingBindings(canonical, bindings); if (diagnostics.length > 0) return { activatable: false, diagnostics }; diff --git a/backend/src/workspaces/migrate-v2-qdrant.ts b/backend/src/workspaces/migrate-v2-qdrant.ts index eae22d1a..5fd6c40d 100644 --- a/backend/src/workspaces/migrate-v2-qdrant.ts +++ b/backend/src/workspaces/migrate-v2-qdrant.ts @@ -1,7 +1,8 @@ -import { validateOperationalWorkspace, type WorkspaceV2, type WorkspaceV3 } from "./schema.js"; +import { validateOperationalWorkspace, type WorkspaceV3 } from "./schema.js"; +import type { DeprecatedV2Descriptor } from "./deprecated-v2-descriptor.js"; export function migrateWorkspaceV2ToV3( - legacy: WorkspaceV2, + legacy: DeprecatedV2Descriptor, collection: string, ): WorkspaceV3 { return validateOperationalWorkspace({ diff --git a/backend/src/workspaces/runtime-renderer.ts b/backend/src/workspaces/runtime-renderer.ts index 7cf48902..6fa159d8 100644 --- a/backend/src/workspaces/runtime-renderer.ts +++ b/backend/src/workspaces/runtime-renderer.ts @@ -4,9 +4,9 @@ import { buildInstallationContract } from "./contracts.js"; import { validateWorkspaceDescriptor, type WorkspaceDescriptor, - type WorkspaceV2, type WorkspaceV3, } from "./schema.js"; +import type { DeprecatedV2Descriptor } from "./deprecated-v2-descriptor.js"; import type { ResolvedBinding, ResolvedEvidenceBinding, RuntimeBindings } from "./bindings.js"; export type { RuntimeBindings } from "./bindings.js"; @@ -216,8 +216,8 @@ export function renderRuntimeConfig( if (!variable) throw new Error(`workspace contract is missing ${role}_${suffix}`); return variable.name; }; - if (descriptor.workspace.schema_version !== 2) { - if (descriptor.workspace.schema_version === 1) { + if (Number(descriptor.workspace.schema_version) !== 2) { + if (Number(descriptor.workspace.schema_version) === 1) { throw new Error("Workspace descriptor requires explicit migration to schema version 2"); } const canonicalV3 = descriptor as WorkspaceV3; @@ -290,7 +290,7 @@ export function renderRuntimeConfig( return stringify(renderedV3, { lineWidth: 0, sortMapEntries: false }); } - const canonical = descriptor as WorkspaceV2; + const canonical = descriptor as unknown as DeprecatedV2Descriptor; if ([...bindings.dwh.missing, ...bindings.vector.missing, ...bindings.embedding.missing].length > 0) { throw new Error("runtime configuration requires complete bindings"); } diff --git a/backend/src/workspaces/schema.ts b/backend/src/workspaces/schema.ts index acd635cd..2e1886d1 100644 --- a/backend/src/workspaces/schema.ts +++ b/backend/src/workspaces/schema.ts @@ -34,8 +34,8 @@ export interface CanonicalDiagnostics { embedding?: RestDiagnosticRequest & { response: { model: string; dimensions: string } }; } -interface WorkspaceMetadata { - schema_version: Version; +interface WorkspaceMetadata { + schema_version: 3; id: string; name: string; description?: string; @@ -51,50 +51,22 @@ interface WorkspaceDwh { 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; - }; -} - -interface WorkspaceBase { - workspace: WorkspaceMetadata; +interface WorkspaceBase { + workspace: WorkspaceMetadata; dwh: WorkspaceDwh; semantic_index: { vector_store: TVectorStore; - vector_writer?: Record; - embedding: Version extends 3 ? { + embedding: { provider: "ollama_internal"; model: "qwen3-embedding:0.6b"; dimensions: 1024; - timeout_ms?: number; - } : { - provider: "ollama_compatible" | "openai_compatible"; - model: string; - dimensions: number; - timeout_ms?: number; }; }; llm_policy: { default?: `${string}/${string}`; allowed: `${string}/${string}`[]; }; - diagnostics?: TDiagnostics; + diagnostics?: Pick; } interface QdrantVectorStore { @@ -147,17 +119,12 @@ export interface WorkspaceEvidence { policy: EvidencePolicy; } -export interface WorkspaceV3 extends WorkspaceBase<3, QdrantVectorStore> { +export interface WorkspaceV3 extends WorkspaceBase { evidence?: WorkspaceEvidence; } -export interface WorkspaceV2 extends WorkspaceBase<2, VectorStore & { database: string; schema: string }> {} - -/** A readable, non-operational v1 descriptor. It must be explicitly migrated before use. */ -export interface WorkspaceV1 extends WorkspaceBase<1, VectorStore & { database?: string; schema?: string }> {} export type CanonicalWorkspace = WorkspaceV3; -export type LegacyWorkspace = WorkspaceV1 | WorkspaceV2; -export type WorkspaceDescriptor = WorkspaceV1 | WorkspaceV2 | WorkspaceV3; +export type WorkspaceDescriptor = WorkspaceV3; const workspaceId = z.string().regex(/^[a-z][a-z0-9-]{2,62}$/, { message: "workspace id must match ^[a-z][a-z0-9-]{2,62}$", @@ -165,7 +132,6 @@ const workspaceId = z.string().regex(/^[a-z][a-z0-9-]{2,62}$/, { const identifier = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/, { message: "database identifiers must start with a letter or underscore", }); -const dimensions = z.number().int().positive().max(32_768); const port = z.number().int().min(1).max(65_535); const timeoutMs = z.number().int().positive(); const modelReference = z.string().regex(/^[^/\s]+\/[^/\s]+$/, { @@ -190,29 +156,6 @@ const restDiagnosticRequest = z.object({ const dwhRestDiagnostic = restDiagnosticRequest.extend({ response: z.object({ database: responseField, schema: responseField }).strict(), }).strict(); -const vectorMetadataDiagnostic = restDiagnosticRequest.extend({ - response: z.object({ - collection: responseField, - dimensions: responseField, - distance: responseField, - }).strict(), -}).strict(); -const reversibleVectorProbe = restDiagnosticRequest.extend({ - method: z.literal("POST"), - auth: z.enum(["bearer", "x-api-key"]), - response: z.object({ operation: responseField }).strict(), -}).strict(); -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"), @@ -222,36 +165,11 @@ const dwhSchema = z.object({ 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 internalEmbeddingSchema = z.object({ provider: z.literal("ollama_internal"), model: z.literal("qwen3-embedding:0.6b"), dimensions: z.literal(1024), }).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 qdrantVectorStoreSchema = z.object({ engine: z.literal("qdrant"), collection: workspaceId, @@ -420,13 +338,6 @@ function unique(values: readonly T[], context: z.RefinementCtx, path: Propert function workspaceInvariants(workspace: any, context: z.RefinementCtx): void { unique(workspace.dwh.supported_transports, context, ["dwh", "supported_transports"]); - if ("supported_transports" in workspace.semantic_index.vector_store) { - unique( - workspace.semantic_index.vector_store.supported_transports, - context, - ["semantic_index", "vector_store", "supported_transports"], - ); - } unique(workspace.llm_policy.allowed, context, ["llm_policy", "allowed"]); if (workspace.evidence?.source.type === "filesystem") { @@ -461,50 +372,8 @@ function workspaceInvariants(workspace: any, context: z.RefinementCtx): void { message: "diagnostics.dwh_rest requires dwh rest_api transport support", }); } - if ( - workspace.diagnostics?.vector_rest - && ( - !("supported_transports" in workspace.semantic_index.vector_store) - || !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 WorkspaceV1Schema = 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 WorkspaceV2Schema = 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 WorkspaceV3Schema = z.object({ dwh: dwhSchema, llm_policy: llmPolicySchema, @@ -521,7 +390,7 @@ const WorkspaceV3Schema = z.object({ embedding: internalEmbeddingSchema, }).strict(), }).strict().superRefine(workspaceInvariants); -const WorkspaceDescriptorSchema = z.union([WorkspaceV3Schema, WorkspaceV2Schema, WorkspaceV1Schema]); +const WorkspaceDescriptorSchema = WorkspaceV3Schema; export function parseWorkspaceYaml(source: string): WorkspaceDescriptor { const documents = parseAllDocuments(source, { uniqueKeys: true }); @@ -538,46 +407,16 @@ export function validateWorkspaceDescriptor(workspace: unknown): WorkspaceDescri return WorkspaceDescriptorSchema.parse(workspace) as WorkspaceDescriptor; } -export function isCanonicalWorkspace(workspace: WorkspaceDescriptor): workspace is CanonicalWorkspace { - return workspace.workspace.schema_version === 3; +export function isCanonicalWorkspace(workspace: unknown): workspace is CanonicalWorkspace { + return WorkspaceDescriptorSchema.safeParse(workspace).success; } -export function isOperationalWorkspace(workspace: WorkspaceDescriptor): workspace is WorkspaceV3 { - return workspace.workspace.schema_version === 3; +export function isOperationalWorkspace(workspace: unknown): workspace is WorkspaceV3 { + return WorkspaceDescriptorSchema.safeParse(workspace).success; } -/** Rejects readable v1 descriptors at every operational boundary until a caller migrates them. */ -export function validateCanonicalWorkspace(workspace: unknown): CanonicalWorkspace { - return validateOperationalWorkspace(workspace); -} - -/** Rejects readable v1/v2 descriptors at every operational boundary until a caller migrates them. */ export function validateOperationalWorkspace(workspace: unknown): WorkspaceV3 { - const descriptor = validateWorkspaceDescriptor(workspace); - if (!isOperationalWorkspace(descriptor)) { - throw new Error("Workspace descriptor requires explicit migration to schema version 3"); - } - 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: WorkspaceV1, - vectorIdentity: { database: string; schema: string }, -): WorkspaceV2 { - const legacy = WorkspaceV1Schema.parse(workspace) as WorkspaceV1; - const identity = z.object({ database: identifier, schema: identifier }).strict().parse(vectorIdentity); - return WorkspaceV2Schema.parse({ - ...legacy, - workspace: { ...legacy.workspace, schema_version: 2 }, - semantic_index: { - ...legacy.semantic_index, - vector_store: { ...legacy.semantic_index.vector_store, ...identity }, - }, - }) as WorkspaceV2; + return validateWorkspaceDescriptor(workspace); } /** Builds a request URL only after rejecting values that can leave the declared service origin. */ diff --git a/backend/src/workspaces/types.ts b/backend/src/workspaces/types.ts index 25349274..3017d6c7 100644 --- a/backend/src/workspaces/types.ts +++ b/backend/src/workspaces/types.ts @@ -16,4 +16,4 @@ export type WorkspaceErrorCode = | "git_auth_failed" | "git_non_fast_forward" | "git_push_rejected" | "connector_unavailable" | "semantic_index_incompatible"; -export type { WorkspaceV2, WorkspaceV3 } from "./schema.js"; +export type { WorkspaceV3 } from "./schema.js"; diff --git a/backend/test/routes-workspaces.test.ts b/backend/test/routes-workspaces.test.ts index f5849d45..00e85ea5 100644 --- a/backend/test/routes-workspaces.test.ts +++ b/backend/test/routes-workspaces.test.ts @@ -15,8 +15,8 @@ import { createProductionWorkspaceDiagnoser } from "../src/workspaces/diagnostic import { WorkspaceRegistryError } from "../src/workspaces/git-repository.js"; import { WorkspaceRegistry, type WorkspaceRevision } from "../src/workspaces/registry.js"; import { - parseWorkspaceYaml, renderWorkspaceDocs, serializeWorkspaceYaml, validateCanonicalWorkspace, - type CanonicalWorkspace, type WorkspaceV2, + parseWorkspaceYaml, renderWorkspaceDocs, serializeWorkspaceYaml, validateWorkspaceDescriptor, + type CanonicalWorkspace, } from "../src/workspaces/schema.js"; const workspace: CanonicalWorkspace = { @@ -49,7 +49,7 @@ const workspace: CanonicalWorkspace = { llm_policy: { allowed: ["zai/glm-5.2"] }, }; -const workspaceV2: WorkspaceV2 = { +const workspaceV2 = { workspace: { schema_version: 2, id: "psd-clinical", @@ -104,6 +104,11 @@ const workspaceV2: WorkspaceV2 = { llm_policy: { allowed: ["zai/glm-5.2"] }, }; +const workspaceV1 = { + ...workspaceV2, + workspace: { ...workspaceV2.workspace, schema_version: 1 as const }, +}; + const revision: WorkspaceRevision = { id: workspace.workspace.id, commit: "a".repeat(40), @@ -248,6 +253,31 @@ test("validates a canonical workspace and runs the injected installation diagnos expect(diagnose).not.toHaveBeenCalled(); }); +test.each([ + ["v1", workspaceV1], + ["v2", workspaceV2], +])("rejects schema %s at validate and publish boundaries with a sanitized error", async (_version, legacy) => { + const registry = registryFake(); + const app = appFor(registry); + + for (const request of [ + { url: "/workspaces/validate", payload: { workspace: legacy } }, + { + url: "/workspaces/publish", + payload: { action: "create", workspace: legacy, baseCommit: revision.commit }, + }, + ]) { + const response = await app.inject({ method: "POST", ...request }); + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ + code: "workspace_invalid", + message: "Workspace request or bundle is invalid.", + }); + expect(response.body).not.toMatch(/migration_required|schema version/i); + } + expect(registry.publish).not.toHaveBeenCalled(); +}); + test("rejects a migration-required v2 workspace before resolving semantic diagnostics", async () => { const diagnose = vi.fn(async () => ({ activatable: false, @@ -342,12 +372,12 @@ test("returns a 409 field conflict instead of overwriting a changed workspace", const conflict = Object.assign( new WorkspaceRegistryError("workspace_conflict", "Workspace has changed"), { - fields: ["semantic_index.embedding.model"], + fields: ["workspace.description"], expected: { commit: "c".repeat(40), blob: "d".repeat(40) }, actual: { commit: revision.commit, blob: revision.blob }, 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, workspace: { ...workspace.workspace, description: "Local description" } }, + remote: { ...workspace, workspace: { ...workspace.workspace, description: "Remote description" } }, }, ); const registry = registryFake({ publish: vi.fn(async () => { throw conflict; }) }); @@ -364,14 +394,12 @@ test("returns a 409 field conflict instead of overwriting a changed workspace", expect(res.statusCode).toBe(409); expect(res.json()).toMatchObject({ code: "workspace_conflict", - fields: ["semantic_index.embedding.model"], + fields: ["workspace.description"], expected: { commit: "c".repeat(40), blob: "d".repeat(40) }, actual: { commit: revision.commit, blob: revision.blob }, base: workspace, remote: expect.objectContaining({ - semantic_index: expect.objectContaining({ - embedding: expect.objectContaining({ model: "remote/model" }), - }), + workspace: expect.objectContaining({ description: "Remote description" }), }), }); }); @@ -450,7 +478,7 @@ function withEvidence( source: Partial & { type: "filesystem" | "http" | "s3" }, changes: Partial = {}, ): CanonicalWorkspace { - return validateCanonicalWorkspace({ + return validateWorkspaceDescriptor({ ...workspace, evidence: { source, policy: changes }, }); @@ -587,7 +615,7 @@ test.each([ test("real publish create/update, pull, list, and read preserve a complete Evidence descriptor", async () => { const fixture = await createRealRouteFixture(httpEvidenceWorkspace); const status = await fixture.app.inject({ method: "GET", url: "/workspace-registry/status" }); - const created = validateCanonicalWorkspace({ + const created = validateWorkspaceDescriptor({ ...httpEvidenceWorkspace, workspace: { ...httpEvidenceWorkspace.workspace, id: "research-clinical", name: "Research Clinical" }, semantic_index: { @@ -600,7 +628,7 @@ test("real publish create/update, pull, list, and read preserve a complete Evide payload: { action: "create", workspace: created, baseCommit: status.json().head }, }); const createdRevision = create.json().revision as WorkspaceRevision; - const updated = validateCanonicalWorkspace({ + const updated = validateWorkspaceDescriptor({ ...created, evidence: { ...created.evidence, @@ -617,7 +645,7 @@ test("real publish create/update, pull, list, and read preserve a complete Evide }); expect(update.statusCode).toBe(200); await realGit(fixture.author, ["pull", "--ff-only", "origin", "main"]); - const remotelyEdited = validateCanonicalWorkspace({ + const remotelyEdited = validateWorkspaceDescriptor({ ...updated, evidence: { ...updated.evidence, @@ -742,7 +770,7 @@ test("real publish and pull fail safely when the contextual Evidence Git tree is const fixture = await createRealRouteFixture(); await fixture.registry.bootstrap(); const current = await fixture.registry.read("psd-clinical"); - const missing = validateCanonicalWorkspace({ + const missing = validateWorkspaceDescriptor({ ...workspace, workspace: { ...workspace.workspace, id: "missing-evidence", name: "Missing Evidence" }, semantic_index: { diff --git a/backend/test/workspace-registry.test.ts b/backend/test/workspace-registry.test.ts index c79f360e..bf019005 100644 --- a/backend/test/workspace-registry.test.ts +++ b/backend/test/workspace-registry.test.ts @@ -327,6 +327,8 @@ function persistPreStateManifest(root: string, commit: string): void { const envExample = "# Legacy registry artifact\n"; const markdown = "# Legacy registry artifact\n"; + chmodSync(join(snapshotDirectory, envName), 0o600); + chmodSync(join(snapshotDirectory, docsName), 0o600); writeFileSync(join(snapshotDirectory, envName), envExample); writeFileSync(join(snapshotDirectory, docsName), markdown); active.revisions = active.revisions.map(({ state: _state, ...revision }: Record) => revision); @@ -693,25 +695,19 @@ test("resets an ahead checkout after a rejected push and retries publication", a await expect(registry.publish(request)).resolves.toMatchObject({ id: "push-recovery" }); }); -test("lists a v1 descriptor in migration-required state without rendering operational artifacts", async () => { - const legacyYaml = legacyV1Yaml(); +test.each([ + ["v1", legacyV1Yaml()], + ["v2", legacyV2Yaml()], +])("rejects a schema %s descriptor instead of activating it", async (_version, legacyYaml) => { 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); + await expect(registry.bootstrap()).rejects.toMatchObject({ code: "workspace_invalid" }); + expect(existsSync(join(remote.root, "registry", "state", "active.json"))).toBe(false); }); -test("migrates a validated pre-state manifest and keeps its v1 workspace migration-gated", async () => { - const legacyYaml = legacyV1Yaml(); - const remote = await fixture(legacyYaml); +test("migrates a validated pre-state manifest while preserving its v3 operational state", async () => { + const remote = await fixture(); const root = join(remote.root, "registry"); const firstRegistry = new WorkspaceRegistry(config(root, remote.remote)); await firstRegistry.bootstrap(); @@ -723,19 +719,20 @@ test("migrates a validated pre-state manifest and keeps its v1 workspace migrati degraded: false, }); await expect(restoredRegistry.list()).resolves.toMatchObject([ - { id: "psd-clinical", state: "migration_required" }, + { id: "psd-clinical", state: "operational" }, ]); const active = JSON.parse(readFileSync(join(root, "state", "active.json"), "utf8")); const manifest = JSON.parse(readFileSync(join(root, "snapshots", remote.initialCommit, "snapshot.json"), "utf8")); - expect(active.revisions[0].state).toBe("migration_required"); - expect(manifest.revisions[0].state).toBe("migration_required"); - expect(Object.keys(manifest.files)).toEqual(["psd-clinical.yaml"]); + expect(active.revisions[0].state).toBe("operational"); + expect(manifest.revisions[0].state).toBe("operational"); + expect(Object.keys(manifest.files).sort()).toEqual([ + "psd-clinical.env.example", "psd-clinical.md", "psd-clinical.yaml", + ]); }); -test("finishes a pre-state active manifest migration after its snapshot was atomically updated", async () => { - const legacyYaml = legacyV1Yaml(); - const remote = await fixture(legacyYaml); +test("finishes a pre-state active manifest migration after its v3 snapshot was atomically updated", async () => { + const remote = await fixture(); const root = join(remote.root, "registry"); const registry = new WorkspaceRegistry(config(root, remote.remote)); await registry.bootstrap(); @@ -743,19 +740,17 @@ test("finishes a pre-state active manifest migration after its snapshot was atom const snapshotPath = join(root, "snapshots", remote.initialCommit, "snapshot.json"); const manifest = JSON.parse(readFileSync(snapshotPath, "utf8")); - manifest.revisions[0].state = "migration_required"; - manifest.files = { "psd-clinical.yaml": manifest.files["psd-clinical.yaml"] }; + manifest.revisions[0].state = "operational"; writeFileSync(snapshotPath, JSON.stringify(manifest)); const restoredRegistry = new WorkspaceRegistry(config(root, remote.remote)); await expect(restoredRegistry.list()).resolves.toMatchObject([ - { id: "psd-clinical", state: "migration_required" }, + { id: "psd-clinical", state: "operational" }, ]); }); test("rejects a corrupt pre-state manifest rather than accepting it during migration", async () => { - const legacyYaml = legacyV1Yaml(); - const remote = await fixture(legacyYaml); + const remote = await fixture(); const root = join(remote.root, "registry"); const registry = new WorkspaceRegistry(config(root, remote.remote)); await registry.bootstrap(); @@ -872,32 +867,6 @@ test("a session revision lease survives stale retention scans until its manifest expect(existsSync(registry.snapshotPath(remote.initialCommit, "psd-clinical"))).toBe(false); }); -test("does not acquire a session revision lease for a migration_required workspace", async () => { - const remote = await fixture(legacyV1Yaml()); - const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); - await registry.bootstrap(); - - await expect(registry.acquireSessionRevision("psd-clinical")).rejects.toMatchObject({ - code: "workspace_invalid", - }); -}); - -test("lists a schema v2 descriptor as migration_required and refuses to acquire it", async () => { - const remote = await fixture(legacyV2Yaml()); - const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); - await registry.bootstrap(); - - await expect(registry.list()).resolves.toMatchObject([ - { id: "psd-clinical", state: "migration_required" }, - ]); - await expect(registry.acquireSessionRevision("psd-clinical")).rejects.toMatchObject({ - code: "workspace_invalid", - }); - await expect(registry.readPinned("psd-clinical", remote.initialCommit)).rejects.toMatchObject({ - code: "workspace_invalid", - }); -}); - test("lists operational descriptors retained after their workspace was removed from the active revision", async () => { const remote = await fixture(); const root = join(remote.root, "registry"); diff --git a/backend/test/workspaces-schema.test.ts b/backend/test/workspaces-schema.test.ts index 2b6f971f..66ce210a 100644 --- a/backend/test/workspaces-schema.test.ts +++ b/backend/test/workspaces-schema.test.ts @@ -1,13 +1,12 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; +import { parse } from "yaml"; import { expect, test } from "vitest"; import * as workspaceSchema from "../src/workspaces/schema.js"; import { parseWorkspaceYaml, serializeWorkspaceYaml, - validateCanonicalWorkspace, validateWorkspaceDescriptor, - type WorkspaceDescriptor, } from "../src/workspaces/schema.js"; export const validYaml = `workspace: @@ -165,8 +164,8 @@ test("rejects legacy semantic connector fields and diagnostics in schema v3", () ))).toThrow(/unrecognized key|vector_rest/i); }); -test("keeps v1 and v2 descriptors parseable but non-operational", () => { - const v1Yaml = `workspace: +test.each([ + ["v1", `workspace: schema_version: 1 id: psd-clinical name: Policlinico San Donato @@ -192,8 +191,8 @@ semantic_index: llm_policy: allowed: - zai/glm-5.2 -`; - const v2Yaml = `workspace: +`], + ["v2", `workspace: schema_version: 2 id: psd-clinical name: Policlinico San Donato @@ -221,22 +220,17 @@ semantic_index: llm_policy: allowed: - zai/glm-5.2 -`; +`], +])("rejects schema %s descriptors at parser and object-validator boundaries", (_version, yaml) => { + expect(() => parseWorkspaceYaml(yaml)).toThrow(/schema_version|invalid literal|3/i); + expect(() => validateWorkspaceDescriptor(parse(yaml))).toThrow(/schema_version|invalid literal|3/i); +}); - const v1 = parseWorkspaceYaml(v1Yaml); - const v2 = parseWorkspaceYaml(v2Yaml); - const isOperationalWorkspace = (workspaceSchema as { isOperationalWorkspace?: unknown }).isOperationalWorkspace; +test("does not expose the redundant canonical validator or v1 migration", () => { + const legacyExports = workspaceSchema as Record; - expect(v1.workspace.schema_version).toBe(1); - expect(v2.workspace.schema_version).toBe(2); - expect(validateCanonicalWorkspace(parseWorkspaceYaml(validYaml))).toMatchObject({ - workspace: { schema_version: 3 }, - }); - expect(() => validateCanonicalWorkspace(v1)).toThrow(/schema version 3|migration/i); - expect(() => validateCanonicalWorkspace(v2)).toThrow(/schema version 3|migration/i); - expect(isOperationalWorkspace).toBeTypeOf("function"); - expect((isOperationalWorkspace as (workspace: WorkspaceDescriptor) => boolean)(v1)).toBe(false); - expect((isOperationalWorkspace as (workspace: WorkspaceDescriptor) => boolean)(v2)).toBe(false); + expect(legacyExports.validateCanonicalWorkspace).toBeUndefined(); + expect(legacyExports.migrateWorkspaceV1ToV2).toBeUndefined(); }); test("constructs diagnostic URLs only when the resolved URL remains on the service origin", () => {