From 92cb0545be2901f5dd6b796fe3060a1c702ce30e Mon Sep 17 00:00:00 2001 From: mptyl Date: Mon, 3 Aug 2026 21:31:07 +0200 Subject: [PATCH] feat: add canonical workspace schema --- backend/src/workspaces/contracts.ts | 146 ++++++++++++++++++++++ backend/src/workspaces/schema.ts | 138 ++++++++++++++++++++ backend/test/workspaces-contracts.test.ts | 68 ++++++++++ backend/test/workspaces-schema.test.ts | 62 +++++++++ 4 files changed, 414 insertions(+) create mode 100644 backend/src/workspaces/contracts.ts create mode 100644 backend/src/workspaces/schema.ts create mode 100644 backend/test/workspaces-contracts.test.ts create mode 100644 backend/test/workspaces-schema.test.ts diff --git a/backend/src/workspaces/contracts.ts b/backend/src/workspaces/contracts.ts new file mode 100644 index 00000000..6ffcb2c8 --- /dev/null +++ b/backend/src/workspaces/contracts.ts @@ -0,0 +1,146 @@ +import type { CanonicalWorkspace, DwhTransport, VectorTransport } from "./schema.js"; + +export type InstallationRole = "DWH" | "VECTOR" | "EMBEDDING"; +export type InstallationSuffix = + | "TRANSPORT" + | "HOST" + | "PORT" + | "BASE_URL" + | "USER" + | "PASSWORD_FILE" + | "API_KEY_FILE" + | "TLS_CA_FILE" + | "SSH_HOST" + | "SSH_PORT" + | "SSH_USER" + | "SSH_PRIVATE_KEY_FILE" + | "SSH_KNOWN_HOSTS_FILE" + | "SSH_TARGET_HOST" + | "SSH_TARGET_PORT"; + +type ConnectorTransport = DwhTransport | VectorTransport; + +export interface InstallationVariable { + name: string; + role: InstallationRole; + suffix: InstallationSuffix; + secret: boolean; + transports?: readonly ConnectorTransport[]; +} + +export interface InstallationContract { + workspaceId: string; + namespace: string; + variables: InstallationVariable[]; +} + +const CONNECTOR_SUFFIXES: readonly InstallationSuffix[] = [ + "TRANSPORT", + "HOST", + "PORT", + "BASE_URL", + "USER", + "PASSWORD_FILE", + "API_KEY_FILE", + "TLS_CA_FILE", + "SSH_HOST", + "SSH_PORT", + "SSH_USER", + "SSH_PRIVATE_KEY_FILE", + "SSH_KNOWN_HOSTS_FILE", + "SSH_TARGET_HOST", + "SSH_TARGET_PORT", +]; + +const EMBEDDING_SUFFIXES: readonly InstallationSuffix[] = [ + "BASE_URL", + "API_KEY_FILE", + "TLS_CA_FILE", +]; + +function namespaceFor(workspace: CanonicalWorkspace): string { + return workspace.workspace.id.replaceAll("-", "_").toUpperCase(); +} + +function createVariable( + namespace: string, + role: InstallationRole, + suffix: InstallationSuffix, + transports?: readonly ConnectorTransport[], +): InstallationVariable { + return { + name: `THT_WS_${namespace}_${role}_${suffix}`, + role, + suffix, + secret: suffix.endsWith("_FILE"), + ...(transports ? { transports } : {}), + }; +} + +function connectorVariables( + namespace: string, + role: "DWH" | "VECTOR", + transports: readonly ConnectorTransport[], +): InstallationVariable[] { + return CONNECTOR_SUFFIXES.map((suffix) => createVariable(namespace, role, suffix, transports)); +} + +export function buildInstallationContract(workspace: CanonicalWorkspace): InstallationContract { + const namespace = namespaceFor(workspace); + + return { + workspaceId: workspace.workspace.id, + namespace, + variables: [ + ...connectorVariables(namespace, "DWH", workspace.dwh.supported_transports), + ...connectorVariables( + namespace, + "VECTOR", + workspace.semantic_index.vector_store.supported_transports, + ), + ...EMBEDDING_SUFFIXES.map((suffix) => createVariable(namespace, "EMBEDDING", suffix)), + ], + }; +} + +function localizedIntroduction(workspace: CanonicalWorkspace): 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 } { + const contract = buildInstallationContract(workspace); + const variablesByRole = new Map(); + for (const variable of contract.variables) { + const variables = variablesByRole.get(variable.role) ?? []; + variables.push(variable); + variablesByRole.set(variable.role, variables); + } + + const envExample = [ + `# Generated installation bindings for ${workspace.workspace.id}`, + "# Provide secret file paths only; never paste secret values here.", + ...contract.variables.map((variable) => `${variable.name}=`), + "", + ].join("\n"); + + const markdown = [ + "# Installation requirements", + "", + `**Workspace:** ${workspace.workspace.name}`, + "", + localizedIntroduction(workspace), + "", + "Use the following UI fields as installation bindings. Secret fields always contain file paths, never secret values.", + "", + ...(["DWH", "VECTOR", "EMBEDDING"] as const).flatMap((role) => [ + `## ${role === "DWH" ? "Data warehouse" : role === "VECTOR" ? "Vector store" : "Embedding service"}`, + "", + ...(variablesByRole.get(role) ?? []).map((variable) => `- \`${variable.name}\``), + "", + ]), + ].join("\n"); + + return { envExample, markdown }; +} diff --git a/backend/src/workspaces/schema.ts b/backend/src/workspaces/schema.ts new file mode 100644 index 00000000..da5ad961 --- /dev/null +++ b/backend/src/workspaces/schema.ts @@ -0,0 +1,138 @@ +import { parseAllDocuments, stringify } from "yaml"; +import { z } from "zod"; + +export const DWH_TRANSPORTS = ["postgres_direct", "rest_api", "ssh_tunnel"] as const; +export type DwhTransport = (typeof DWH_TRANSPORTS)[number]; + +export const VECTOR_TRANSPORTS = ["pgvector_direct", "rest_api", "ssh_tunnel"] as const; +export type VectorTransport = (typeof VECTOR_TRANSPORTS)[number]; + +export interface CanonicalWorkspace { + workspace: { + schema_version: 1; + id: string; + name: string; + description?: string; + language: "en" | "it"; + }; + dwh: { + engine: "postgres"; + database: string; + schema: string; + supported_transports: DwhTransport[]; + }; + semantic_index: { + vector_store: { + engine: "pgvector"; + collection: string; + dimensions: number; + distance: "cosine" | "l2" | "inner_product"; + supported_transports: VectorTransport[]; + }; + embedding: { + provider: "ollama_compatible" | "openai_compatible"; + model: string; + dimensions: number; + }; + }; + llm_policy: { + default?: `${string}/${string}`; + allowed: `${string}/${string}`[]; + }; +} + +const workspaceId = z.string().regex(/^[a-z][a-z0-9-]{2,62}$/, { + message: "workspace id must match ^[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 modelReference = z.string().regex(/^[^/\s]+\/[^/\s]+$/, { + message: "model must use provider/model syntax", +}); + +function unique(values: readonly T[], context: z.RefinementCtx, path: PropertyKey[]) { + if (new Set(values).size !== values.length) { + context.addIssue({ code: "custom", path, message: "supported transports must not repeat" }); + } +} + +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, + supported_transports: z.array(z.enum(DWH_TRANSPORTS)).min(1), + }).strict(), + semantic_index: z.object({ + vector_store: z.object({ + engine: z.literal("pgvector"), + collection: identifier, + dimensions, + distance: z.enum(["cosine", "l2", "inner_product"]), + supported_transports: z.array(z.enum(VECTOR_TRANSPORTS)).min(1), + }).strict(), + embedding: z.object({ + provider: z.enum(["ollama_compatible", "openai_compatible"]), + model: z.string().trim().min(1), + dimensions, + }).strict(), + }).strict(), + llm_policy: z.object({ + default: modelReference.optional(), + allowed: z.array(modelReference).min(1), + }).strict(), +}).strict().superRefine((workspace, context) => { + unique(workspace.dwh.supported_transports, context, ["dwh", "supported_transports"]); + 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.semantic_index.vector_store.dimensions !== workspace.semantic_index.embedding.dimensions) { + context.addIssue({ + code: "custom", + path: ["semantic_index", "embedding", "dimensions"], + 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", + path: ["llm_policy", "default"], + 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"); + } + + const document = documents[0]; + if (document.errors.length > 0) { + throw new Error(`Invalid workspace YAML: ${document.errors.map((error) => error.message).join("; ")}`); + } + + return WorkspaceSchema.parse(document.toJSON()) as CanonicalWorkspace; +} + +export function serializeWorkspaceYaml(workspace: CanonicalWorkspace): string { + const canonical = WorkspaceSchema.parse(workspace) as CanonicalWorkspace; + return stringify(canonical, { lineWidth: 0, sortMapEntries: true }); +} + +export { buildInstallationContract, renderWorkspaceDocs } from "./contracts.js"; diff --git a/backend/test/workspaces-contracts.test.ts b/backend/test/workspaces-contracts.test.ts new file mode 100644 index 00000000..a65e010f --- /dev/null +++ b/backend/test/workspaces-contracts.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from "vitest"; +import { buildInstallationContract, renderWorkspaceDocs } from "../src/workspaces/contracts.js"; +import { parseWorkspaceYaml } from "../src/workspaces/schema.js"; + +const validWorkspace = 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 + - rest_api +semantic_index: + vector_store: + engine: pgvector + collection: clinical_documents + dimensions: 768 + distance: cosine + supported_transports: + - pgvector_direct + - rest_api + embedding: + provider: ollama_compatible + model: nomic-embed-text-v2-moe + dimensions: 768 +llm_policy: + default: zai/glm-5.2 + allowed: + - zai/glm-5.2 + - openai/gpt-5 +`); + +test("generates stable FILE-based secret requirements from an immutable ID", () => { + const contract = buildInstallationContract(validWorkspace); + + expect(contract.variables.map((variable) => variable.name)) + .toContain("THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE"); + expect(contract.variables.filter((variable) => variable.secret).every((variable) => ( + variable.name.endsWith("_FILE") + ))).toBe(true); + expect(renderWorkspaceDocs(validWorkspace).envExample).not.toContain("secret-value"); +}); + +test("derives variable names from fixed role and suffix metadata", () => { + const contract = buildInstallationContract(validWorkspace); + const password = contract.variables.find((variable) => ( + variable.role === "DWH" && variable.suffix === "PASSWORD_FILE" + )); + + expect(password).toMatchObject({ + name: "THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE", + role: "DWH", + suffix: "PASSWORD_FILE", + secret: true, + }); +}); + +test("renders English UI headings and workspace-language Italian prose", () => { + const docs = renderWorkspaceDocs(validWorkspace); + + expect(docs.markdown).toContain("# Installation requirements"); + expect(docs.markdown).toContain("Configurazione dell'installazione"); + expect(docs.envExample).toContain("THT_WS_PSD_CLINICAL_VECTOR_TRANSPORT="); +}); diff --git a/backend/test/workspaces-schema.test.ts b/backend/test/workspaces-schema.test.ts new file mode 100644 index 00000000..1367df6e --- /dev/null +++ b/backend/test/workspaces-schema.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from "vitest"; +import { parseWorkspaceYaml, serializeWorkspaceYaml } from "../src/workspaces/schema.js"; + +export const validYaml = `workspace: + schema_version: 1 + id: psd-clinical + name: Policlinico San Donato + description: Clinical data warehouse workspace + language: it +dwh: + engine: postgres + database: postgres + schema: datawarehouse + supported_transports: + - postgres_direct + - rest_api + - ssh_tunnel +semantic_index: + vector_store: + engine: pgvector + collection: clinical_documents + dimensions: 768 + distance: cosine + supported_transports: + - pgvector_direct + - rest_api + - ssh_tunnel + embedding: + provider: ollama_compatible + model: nomic-embed-text-v2-moe + dimensions: 768 +llm_policy: + default: zai/glm-5.2 + allowed: + - zai/glm-5.2 + - openai/gpt-5 +`; + +test("rejects a workspace whose embedding dimensions differ from its collection", () => { + expect(() => parseWorkspaceYaml(validYaml.replace("dimensions: 768", "dimensions: 1536"))) + .toThrow(/dimensions/i); +}); + +test("rejects an LLM default outside its allowlist", () => { + expect(() => parseWorkspaceYaml(validYaml.replace("- zai/glm-5.2", "- openai/gpt-5"))) + .toThrow(/allowlist/i); +}); + +test("rejects unknown keys and invalid immutable IDs", () => { + expect(() => parseWorkspaceYaml(validYaml.replace(" language: it", " language: it\n label: PSD"))) + .toThrow(/unrecognized key/i); + expect(() => parseWorkspaceYaml(validYaml.replace("id: psd-clinical", "id: PSD"))) + .toThrow(/id/i); +}); + +test("serializes canonical YAML that parses back to the same workspace", () => { + const workspace = parseWorkspaceYaml(validYaml); + const serialized = serializeWorkspaceYaml(workspace); + + expect(serializeWorkspaceYaml(parseWorkspaceYaml(serialized))).toBe(serialized); + expect(parseWorkspaceYaml(serialized)).toEqual(workspace); +});