fix: gate workspace diagnostic migration

This commit is contained in:
2026-08-03 23:30:15 +02:00
parent c5685f4962
commit 450d7ab07f
14 changed files with 430 additions and 145 deletions
+5 -4
View File
@@ -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<InstallationRole, "VECTOR_WRITER">,
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<InstallationRole, "VECTOR_WRITER">,
workspace: WorkspaceDescriptor,
role: InstallationRole,
env: NodeJS.ProcessEnv,
secretRoots: readonly string[],
): ResolvedBinding {
+5 -5
View File
@@ -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<InstallationRole, InstallationVariable[]>();
+2 -2
View File
@@ -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<WorkspaceDiagnostics> {
+46 -15
View File
@@ -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;
});
}
+2 -2
View File
@@ -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 {
+199 -102
View File
@@ -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<Version extends 1 | 2> {
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<TVectorStore extends VectorStore> {
vector_store: TVectorStore;
vector_writer?: Record<string, never>;
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<string, never>;
embedding: {
provider: "ollama_compatible" | "openai_compatible";
model: string;
dimensions: number;
timeout_ms?: number;
};
};
interface WorkspaceBase<Version extends 1 | 2, TVectorStore extends VectorStore> {
workspace: WorkspaceMetadata<Version>;
dwh: WorkspaceDwh;
semantic_index: SemanticIndex<TVectorStore>;
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<T>(values: readonly T[], context: z.RefinementCtx, path: PropertyKey[]) {
if (new Set(values).size !== values.length) {
@@ -121,55 +181,7 @@ function unique<T>(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 {