feat: add canonical workspace schema
This commit is contained in:
@@ -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<InstallationRole, InstallationVariable[]>();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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<T>(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";
|
||||||
@@ -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=");
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user