fix: gate workspace diagnostic migration
This commit is contained in:
@@ -5,9 +5,9 @@ import {
|
|||||||
DWH_TRANSPORTS,
|
DWH_TRANSPORTS,
|
||||||
VECTOR_TRANSPORTS,
|
VECTOR_TRANSPORTS,
|
||||||
validateCanonicalWorkspace,
|
validateCanonicalWorkspace,
|
||||||
type CanonicalWorkspace,
|
|
||||||
type DwhTransport,
|
type DwhTransport,
|
||||||
type VectorTransport,
|
type VectorTransport,
|
||||||
|
type WorkspaceDescriptor,
|
||||||
} from "./schema.js";
|
} from "./schema.js";
|
||||||
|
|
||||||
export interface ResolvedBinding {
|
export interface ResolvedBinding {
|
||||||
@@ -63,10 +63,11 @@ function isSafeSecretFile(path: string, secretRoots: readonly string[]): boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
function requiredSuffixes(
|
function requiredSuffixes(
|
||||||
role: Exclude<InstallationRole, "VECTOR_WRITER">,
|
role: InstallationRole,
|
||||||
transport: DwhTransport | VectorTransport,
|
transport: DwhTransport | VectorTransport,
|
||||||
): readonly InstallationSuffix[] {
|
): readonly InstallationSuffix[] {
|
||||||
if (role === "EMBEDDING") return EMBEDDING_REQUIRED_SUFFIXES;
|
if (role === "EMBEDDING") return EMBEDDING_REQUIRED_SUFFIXES;
|
||||||
|
if (role === "VECTOR_WRITER") return ["API_KEY_FILE"];
|
||||||
return REQUIRED_SUFFIXES[role][transport] ?? [];
|
return REQUIRED_SUFFIXES[role][transport] ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,8 +76,8 @@ function requiredSuffixes(
|
|||||||
* deliberately left for the harness secret-file loader, so bindings cannot leak credentials.
|
* deliberately left for the harness secret-file loader, so bindings cannot leak credentials.
|
||||||
*/
|
*/
|
||||||
export function resolveBinding(
|
export function resolveBinding(
|
||||||
workspace: CanonicalWorkspace,
|
workspace: WorkspaceDescriptor,
|
||||||
role: Exclude<InstallationRole, "VECTOR_WRITER">,
|
role: InstallationRole,
|
||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
secretRoots: readonly string[],
|
secretRoots: readonly string[],
|
||||||
): ResolvedBinding {
|
): ResolvedBinding {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { validateCanonicalWorkspace } from "./schema.js";
|
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 InstallationRole = "DWH" | "VECTOR" | "VECTOR_WRITER" | "EMBEDDING";
|
||||||
export type InstallationSuffix =
|
export type InstallationSuffix =
|
||||||
@@ -68,7 +68,7 @@ const EMBEDDING_SUFFIXES: readonly InstallationSuffix[] = [
|
|||||||
"TLS_CA_FILE",
|
"TLS_CA_FILE",
|
||||||
];
|
];
|
||||||
|
|
||||||
function namespaceFor(workspace: CanonicalWorkspace): string {
|
function namespaceFor(workspace: WorkspaceDescriptor): string {
|
||||||
return workspace.workspace.id.replaceAll("-", "_").toUpperCase();
|
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 canonical = validateCanonicalWorkspace(workspace);
|
||||||
const namespace = namespaceFor(canonical);
|
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"
|
return workspace.workspace.language === "it"
|
||||||
? `Configurazione dell'installazione per ${workspace.workspace.name}. Imposta solo i binding supportati da questa installazione.`
|
? `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.`;
|
: `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 canonical = validateCanonicalWorkspace(workspace);
|
||||||
const contract = buildInstallationContract(canonical);
|
const contract = buildInstallationContract(canonical);
|
||||||
const variablesByRole = new Map<InstallationRole, InstallationVariable[]>();
|
const variablesByRole = new Map<InstallationRole, InstallationVariable[]>();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { once } from "node:events";
|
|||||||
import { MAX_WORKSPACE_DIAGNOSTIC_TIMEOUT_MS } from "../config.js";
|
import { MAX_WORKSPACE_DIAGNOSTIC_TIMEOUT_MS } from "../config.js";
|
||||||
import { buildInstallationContract } from "./contracts.js";
|
import { buildInstallationContract } from "./contracts.js";
|
||||||
import type { RuntimeBindings } from "./runtime-renderer.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";
|
import type { WorkspaceErrorCode } from "./types.js";
|
||||||
|
|
||||||
export interface Diagnostic {
|
export interface Diagnostic {
|
||||||
@@ -388,7 +388,7 @@ export function createWorkspaceDiagnoser(
|
|||||||
const fallbackTimeout = boundedTimeout(options.timeoutMs, DEFAULT_WORKSPACE_DIAGNOSTIC_TIMEOUT_MS);
|
const fallbackTimeout = boundedTimeout(options.timeoutMs, DEFAULT_WORKSPACE_DIAGNOSTIC_TIMEOUT_MS);
|
||||||
|
|
||||||
return async function diagnoseWorkspace(
|
return async function diagnoseWorkspace(
|
||||||
workspace: CanonicalWorkspace,
|
workspace: WorkspaceDescriptor,
|
||||||
bindings: RuntimeBindings,
|
bindings: RuntimeBindings,
|
||||||
options: { writeProbe: boolean },
|
options: { writeProbe: boolean },
|
||||||
): Promise<WorkspaceDiagnostics> {
|
): Promise<WorkspaceDiagnostics> {
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ import {
|
|||||||
WorkspaceRepositoryLock,
|
WorkspaceRepositoryLock,
|
||||||
type GitStatus,
|
type GitStatus,
|
||||||
} from "./git-repository.js";
|
} 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";
|
import type { WorkspaceErrorCode, WorkspaceRegistryConfig } from "./types.js";
|
||||||
|
|
||||||
export type { GitStatus } from "./git-repository.js";
|
export type { GitStatus } from "./git-repository.js";
|
||||||
@@ -19,6 +25,7 @@ export interface WorkspaceRevision {
|
|||||||
commit: string;
|
commit: string;
|
||||||
blob: string;
|
blob: string;
|
||||||
snapshotPath: string;
|
snapshotPath: string;
|
||||||
|
state: "operational" | "migration_required";
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PublishWorkspaceRequest =
|
export type PublishWorkspaceRequest =
|
||||||
@@ -109,7 +116,7 @@ export class WorkspaceRegistry {
|
|||||||
return (await this.activeState()).revisions;
|
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 state = await this.activeState();
|
||||||
const revision = state.revisions.find((candidate) => candidate.id === id);
|
const revision = state.revisions.find((candidate) => candidate.id === id);
|
||||||
if (!revision) throw new WorkspaceRegistryError("workspace_invalid", "Workspace is unavailable");
|
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");
|
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 {
|
try {
|
||||||
for (const path of files) {
|
for (const path of files) {
|
||||||
const id = path.slice("workspaces/".length, -".yaml".length);
|
const id = path.slice("workspaces/".length, -".yaml".length);
|
||||||
@@ -142,9 +155,22 @@ export class WorkspaceRegistry {
|
|||||||
if (workspace.workspace.id !== id) {
|
if (workspace.workspace.id !== id) {
|
||||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace ID does not match its repository path");
|
throw new WorkspaceRegistryError("workspace_invalid", "Workspace ID does not match its repository path");
|
||||||
}
|
}
|
||||||
buildInstallationContract(workspace);
|
let snapshotSource = source;
|
||||||
renderWorkspaceDocs(workspace);
|
const state: WorkspaceRevision["state"] = isCanonicalWorkspace(workspace)
|
||||||
snapshots.push({ id, source: serializeWorkspaceYaml(workspace), workspace, blob: await this.repository.blob(path) });
|
? "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) {
|
} catch (error) {
|
||||||
throw workspaceError(error);
|
throw workspaceError(error);
|
||||||
@@ -156,6 +182,7 @@ export class WorkspaceRegistry {
|
|||||||
commit: safeHead,
|
commit: safeHead,
|
||||||
blob: snapshot.blob,
|
blob: snapshot.blob,
|
||||||
snapshotPath: this.snapshotPath(safeHead, snapshot.id),
|
snapshotPath: this.snapshotPath(safeHead, snapshot.id),
|
||||||
|
state: snapshot.state,
|
||||||
}));
|
}));
|
||||||
if (this.pathExists(snapshotDirectory)) {
|
if (this.pathExists(snapshotDirectory)) {
|
||||||
await this.assertSnapshotIntegrity({ head: safeHead, revisions });
|
await this.assertSnapshotIntegrity({ head: safeHead, revisions });
|
||||||
@@ -168,13 +195,15 @@ export class WorkspaceRegistry {
|
|||||||
const yamlName = `${snapshot.id}.yaml`;
|
const yamlName = `${snapshot.id}.yaml`;
|
||||||
const envName = `${snapshot.id}.env.example`;
|
const envName = `${snapshot.id}.env.example`;
|
||||||
const docsName = `${snapshot.id}.md`;
|
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, 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[yamlName] = digest(snapshot.source);
|
||||||
files[envName] = digest(docs.envExample);
|
if (snapshot.state === "operational") {
|
||||||
files[docsName] = digest(docs.markdown);
|
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 }), {
|
await writeFile(join(staging, "snapshot.json"), JSON.stringify({ head: safeHead, revisions, files }), {
|
||||||
encoding: "utf8", mode: 0o400,
|
encoding: "utf8", mode: 0o400,
|
||||||
@@ -239,6 +268,7 @@ export class WorkspaceRegistry {
|
|||||||
safeCommit(revision.commit);
|
safeCommit(revision.commit);
|
||||||
safeBlob(revision.blob);
|
safeBlob(revision.blob);
|
||||||
if (revision.commit !== state.head || ids.has(revision.id)) throw new Error("bad revision");
|
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);
|
ids.add(revision.id);
|
||||||
workspacePath(revision.id);
|
workspacePath(revision.id);
|
||||||
if (!isAbsolute(revision.snapshotPath) || revision.snapshotPath !== this.snapshotPath(revision.commit, 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)) {
|
if (manifest.head !== state.head || !this.sameRevisions(manifest.revisions, state.revisions)) {
|
||||||
throw new Error("manifest revisions do not match active state");
|
throw new Error("manifest revisions do not match active state");
|
||||||
}
|
}
|
||||||
const expected = state.revisions.flatMap((revision) => [
|
const expected = state.revisions.flatMap((revision) => revision.state === "operational"
|
||||||
`${revision.id}.yaml`, `${revision.id}.env.example`, `${revision.id}.md`,
|
? [`${revision.id}.yaml`, `${revision.id}.env.example`, `${revision.id}.md`]
|
||||||
]);
|
: [`${revision.id}.yaml`]);
|
||||||
if (Object.keys(manifest.files).length !== expected.length || !expected.every((name) => (
|
if (Object.keys(manifest.files).length !== expected.length || !expected.every((name) => (
|
||||||
/^[0-9a-f]{64}$/.test(manifest.files[name] ?? "")
|
/^[0-9a-f]{64}$/.test(manifest.files[name] ?? "")
|
||||||
))) throw new Error("manifest files are invalid");
|
))) throw new Error("manifest files are invalid");
|
||||||
@@ -284,7 +314,8 @@ export class WorkspaceRegistry {
|
|||||||
const candidate = right[index];
|
const candidate = right[index];
|
||||||
return candidate !== undefined
|
return candidate !== undefined
|
||||||
&& candidate.id === revision.id && candidate.commit === revision.commit
|
&& 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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { stringify } from "yaml";
|
import { stringify } from "yaml";
|
||||||
import { buildInstallationContract } from "./contracts.js";
|
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";
|
import type { ResolvedBinding } from "./bindings.js";
|
||||||
|
|
||||||
export interface RuntimeBindings {
|
export interface RuntimeBindings {
|
||||||
@@ -74,7 +74,7 @@ function placeholderConnection(identity: { database: string; schema: string }):
|
|||||||
|
|
||||||
/** Render the compatibility fields consumed by the current Python harness. */
|
/** Render the compatibility fields consumed by the current Python harness. */
|
||||||
export function renderRuntimeConfig(
|
export function renderRuntimeConfig(
|
||||||
workspace: CanonicalWorkspace,
|
workspace: WorkspaceDescriptor,
|
||||||
bindings: RuntimeBindings,
|
bindings: RuntimeBindings,
|
||||||
paths: RuntimePaths,
|
paths: RuntimePaths,
|
||||||
): string {
|
): string {
|
||||||
|
|||||||
+199
-102
@@ -20,56 +20,58 @@ export interface RestDiagnosticRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CanonicalDiagnostics {
|
export interface CanonicalDiagnostics {
|
||||||
dwh_rest?: RestDiagnosticRequest & {
|
dwh_rest?: RestDiagnosticRequest & { response: { database: string; schema: string } };
|
||||||
response: { database: string; schema: string };
|
|
||||||
};
|
|
||||||
vector_rest?: {
|
vector_rest?: {
|
||||||
metadata: RestDiagnosticRequest & {
|
metadata: RestDiagnosticRequest & {
|
||||||
response: { collection: string; dimensions: string; distance: string };
|
response: { collection: string; dimensions: string; distance: string };
|
||||||
};
|
};
|
||||||
reversible_probe?: RestDiagnosticRequest & { method: "POST" };
|
reversible_probe?: RestDiagnosticRequest & { method: "POST" };
|
||||||
};
|
};
|
||||||
embedding?: RestDiagnosticRequest & {
|
embedding?: RestDiagnosticRequest & { response: { model: string; dimensions: string } };
|
||||||
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 {
|
interface WorkspaceBase<Version extends 1 | 2, TVectorStore extends VectorStore> {
|
||||||
workspace: {
|
workspace: WorkspaceMetadata<Version>;
|
||||||
schema_version: 1;
|
dwh: WorkspaceDwh;
|
||||||
id: string;
|
semantic_index: SemanticIndex<TVectorStore>;
|
||||||
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;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
llm_policy: {
|
llm_policy: {
|
||||||
default?: `${string}/${string}`;
|
default?: `${string}/${string}`;
|
||||||
allowed: `${string}/${string}`[];
|
allowed: `${string}/${string}`[];
|
||||||
@@ -77,6 +79,13 @@ export interface CanonicalWorkspace {
|
|||||||
diagnostics?: CanonicalDiagnostics;
|
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}$/, {
|
const workspaceId = z.string().regex(/^[a-z][a-z0-9-]{2,62}$/, {
|
||||||
message: "workspace id must match ^[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]+$/, {
|
const modelReference = z.string().regex(/^[^/\s]+\/[^/\s]+$/, {
|
||||||
message: "model must use provider/model syntax",
|
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_]*$/, {
|
const responseField = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/, {
|
||||||
message: "diagnostic response fields must be identifiers",
|
message: "diagnostic response fields must be identifiers",
|
||||||
@@ -114,6 +128,52 @@ const reversibleVectorProbe = restDiagnosticRequest.extend({ method: z.literal("
|
|||||||
const embeddingDiagnostic = restDiagnosticRequest.extend({
|
const embeddingDiagnostic = restDiagnosticRequest.extend({
|
||||||
response: z.object({ model: responseField, dimensions: responseField }).strict(),
|
response: z.object({ model: responseField, dimensions: responseField }).strict(),
|
||||||
}).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[]) {
|
function unique<T>(values: readonly T[], context: z.RefinementCtx, path: PropertyKey[]) {
|
||||||
if (new Set(values).size !== values.length) {
|
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({
|
function workspaceInvariants(workspace: any, context: z.RefinementCtx): void {
|
||||||
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) => {
|
|
||||||
unique(workspace.dwh.supported_transports, context, ["dwh", "supported_transports"]);
|
unique(workspace.dwh.supported_transports, context, ["dwh", "supported_transports"]);
|
||||||
unique(
|
unique(
|
||||||
workspace.semantic_index.vector_store.supported_transports,
|
workspace.semantic_index.vector_store.supported_transports,
|
||||||
@@ -185,7 +197,6 @@ const WorkspaceSchema = z.object({
|
|||||||
message: "embedding dimensions must match vector store dimensions",
|
message: "embedding dimensions must match vector store dimensions",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (workspace.llm_policy.default && !workspace.llm_policy.allowed.includes(workspace.llm_policy.default)) {
|
if (workspace.llm_policy.default && !workspace.llm_policy.allowed.includes(workspace.llm_policy.default)) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
@@ -193,24 +204,110 @@ const WorkspaceSchema = z.object({
|
|||||||
message: "LLM default must be included in the allowlist",
|
message: "LLM default must be included in the allowlist",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
if (workspace.diagnostics?.dwh_rest && !workspace.dwh.supported_transports.includes("rest_api")) {
|
||||||
|
context.addIssue({
|
||||||
export function parseWorkspaceYaml(source: string): CanonicalWorkspace {
|
code: "custom",
|
||||||
const documents = parseAllDocuments(source, { uniqueKeys: true });
|
path: ["diagnostics", "dwh_rest"],
|
||||||
if (documents.length !== 1) {
|
message: "diagnostics.dwh_rest requires dwh rest_api transport support",
|
||||||
throw new Error("Workspace YAML must contain exactly one document");
|
});
|
||||||
}
|
}
|
||||||
|
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];
|
const document = documents[0];
|
||||||
if (document.errors.length > 0) {
|
if (document.errors.length > 0) {
|
||||||
throw new Error(`Invalid workspace YAML: ${document.errors.map((error) => error.message).join("; ")}`);
|
throw new Error(`Invalid workspace YAML: ${document.errors.map((error) => error.message).join("; ")}`);
|
||||||
}
|
}
|
||||||
|
return validateWorkspaceDescriptor(document.toJSON());
|
||||||
return validateCanonicalWorkspace(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 {
|
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 {
|
export function serializeWorkspaceYaml(workspace: CanonicalWorkspace): string {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { WorkspaceRegistry } from "../src/workspaces/registry.js";
|
|||||||
import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js";
|
import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js";
|
||||||
|
|
||||||
const validYaml = `workspace:
|
const validYaml = `workspace:
|
||||||
schema_version: 1
|
schema_version: 2
|
||||||
id: psd-clinical
|
id: psd-clinical
|
||||||
name: Policlinico San Donato
|
name: Policlinico San Donato
|
||||||
language: it
|
language: it
|
||||||
@@ -48,7 +48,7 @@ async function git(cwd: string, args: string[]): Promise<void> {
|
|||||||
await runFile("git", args, { cwd });
|
await runFile("git", args, { cwd });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fixture(): Promise<{
|
async function fixture(workspaceSource = validYaml): Promise<{
|
||||||
root: string; remote: string; source: string; initialCommit: string;
|
root: string; remote: string; source: string; initialCommit: string;
|
||||||
}> {
|
}> {
|
||||||
const root = mkdtempSync(join(tmpdir(), "thoth-workspace-registry-"));
|
const root = mkdtempSync(join(tmpdir(), "thoth-workspace-registry-"));
|
||||||
@@ -61,7 +61,7 @@ async function fixture(): Promise<{
|
|||||||
await git(source, ["config", "user.name", "Workspace Registry Test"]);
|
await git(source, ["config", "user.name", "Workspace Registry Test"]);
|
||||||
await git(source, ["config", "user.email", "workspace-registry@example.invalid"]);
|
await git(source, ["config", "user.email", "workspace-registry@example.invalid"]);
|
||||||
mkdirSync(join(source, "workspaces"));
|
mkdirSync(join(source, "workspaces"));
|
||||||
writeFileSync(join(source, "workspaces", "psd-clinical.yaml"), validYaml);
|
writeFileSync(join(source, "workspaces", "psd-clinical.yaml"), workspaceSource);
|
||||||
await git(source, ["add", "workspaces/psd-clinical.yaml"]);
|
await git(source, ["add", "workspaces/psd-clinical.yaml"]);
|
||||||
await git(source, ["commit", "-m", "Initial workspace"]);
|
await git(source, ["commit", "-m", "Initial workspace"]);
|
||||||
await git(source, ["remote", "add", "origin", remote]);
|
await git(source, ["remote", "add", "origin", remote]);
|
||||||
@@ -104,6 +104,25 @@ test("bootstraps a checkout and activates a validated immutable snapshot", async
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("lists a v1 descriptor in migration-required state without rendering operational artifacts", async () => {
|
||||||
|
const legacyYaml = validYaml.replace(
|
||||||
|
" database: postgres\n schema: vectors\n",
|
||||||
|
"",
|
||||||
|
).replace("schema_version: 2", "schema_version: 1");
|
||||||
|
const remote = await fixture(legacyYaml);
|
||||||
|
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||||
|
|
||||||
|
const status = await registry.bootstrap();
|
||||||
|
const [revision] = await registry.list();
|
||||||
|
|
||||||
|
expect(revision).toMatchObject({ state: "migration_required" });
|
||||||
|
await expect(registry.read("psd-clinical")).resolves.toMatchObject({
|
||||||
|
workspace: { workspace: { schema_version: 1 } },
|
||||||
|
});
|
||||||
|
expect(existsSync(join(remote.root, "registry", "snapshots", status.head!, "psd-clinical.env.example"))).toBe(false);
|
||||||
|
expect(existsSync(join(remote.root, "registry", "snapshots", status.head!, "psd-clinical.md"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps the last valid snapshot when a pulled commit has invalid YAML", async () => {
|
test("keeps the last valid snapshot when a pulled commit has invalid YAML", async () => {
|
||||||
const remote = await fixture();
|
const remote = await fixture();
|
||||||
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
|
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { renderRuntimeConfig, type RuntimeBindings, type RuntimePaths } from "..
|
|||||||
import { parseWorkspaceYaml } from "../src/workspaces/schema.js";
|
import { parseWorkspaceYaml } from "../src/workspaces/schema.js";
|
||||||
|
|
||||||
const workspace = parseWorkspaceYaml(`workspace:
|
const workspace = parseWorkspaceYaml(`workspace:
|
||||||
schema_version: 1
|
schema_version: 2
|
||||||
id: psd-clinical
|
id: psd-clinical
|
||||||
name: Policlinico San Donato
|
name: Policlinico San Donato
|
||||||
language: it
|
language: it
|
||||||
@@ -34,6 +34,30 @@ const paths: RuntimePaths = {
|
|||||||
artifacts: "/data/workspaces/psd-clinical/artifacts",
|
artifacts: "/data/workspaces/psd-clinical/artifacts",
|
||||||
indexes: "/data/workspaces/psd-clinical/indexes",
|
indexes: "/data/workspaces/psd-clinical/indexes",
|
||||||
};
|
};
|
||||||
|
const legacyWorkspace = parseWorkspaceYaml(`workspace:
|
||||||
|
schema_version: 1
|
||||||
|
id: psd-clinical
|
||||||
|
name: Policlinico San Donato
|
||||||
|
language: it
|
||||||
|
dwh:
|
||||||
|
engine: postgres
|
||||||
|
database: postgres
|
||||||
|
schema: datawarehouse
|
||||||
|
supported_transports: [postgres_direct]
|
||||||
|
semantic_index:
|
||||||
|
vector_store:
|
||||||
|
engine: pgvector
|
||||||
|
collection: clinical_documents
|
||||||
|
dimensions: 768
|
||||||
|
distance: cosine
|
||||||
|
supported_transports: [pgvector_direct]
|
||||||
|
embedding:
|
||||||
|
provider: ollama_compatible
|
||||||
|
model: nomic-embed-text-v2-moe
|
||||||
|
dimensions: 768
|
||||||
|
llm_policy:
|
||||||
|
allowed: [zai/glm-5.2]
|
||||||
|
`);
|
||||||
|
|
||||||
const directBindings: RuntimeBindings = {
|
const directBindings: RuntimeBindings = {
|
||||||
dwh: {
|
dwh: {
|
||||||
@@ -99,6 +123,10 @@ test("renders a direct PostgreSQL binding to the legacy harness shape", () => {
|
|||||||
expect(yaml).toContain("schema: datawarehouse");
|
expect(yaml).toContain("schema: datawarehouse");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("refuses to render a v1 descriptor until an explicit migration creates v2", () => {
|
||||||
|
expect(() => renderRuntimeConfig(legacyWorkspace, directBindings, paths)).toThrow(/migrat/i);
|
||||||
|
});
|
||||||
|
|
||||||
test("omits direct TLS fields when binding validation did not retain a file path", () => {
|
test("omits direct TLS fields when binding validation did not retain a file path", () => {
|
||||||
const dwhValues = { ...directBindings.dwh.values };
|
const dwhValues = { ...directBindings.dwh.values };
|
||||||
const vectorValues = { ...directBindings.vector.values };
|
const vectorValues = { ...directBindings.vector.values };
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { resolveBinding } from "../src/workspaces/bindings.js";
|
|||||||
import { parseWorkspaceYaml } from "../src/workspaces/schema.js";
|
import { parseWorkspaceYaml } from "../src/workspaces/schema.js";
|
||||||
|
|
||||||
const workspace = parseWorkspaceYaml(`workspace:
|
const workspace = parseWorkspaceYaml(`workspace:
|
||||||
schema_version: 1
|
schema_version: 2
|
||||||
id: psd-clinical
|
id: psd-clinical
|
||||||
name: Policlinico San Donato
|
name: Policlinico San Donato
|
||||||
language: it
|
language: it
|
||||||
@@ -121,3 +121,20 @@ test("rejects a selected transport that the canonical workspace does not support
|
|||||||
missing: ["THT_WS_PSD_CLINICAL_DWH_TRANSPORT"],
|
missing: ["THT_WS_PSD_CLINICAL_DWH_TRANSPORT"],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("never treats a vector reader credential as the optional writer binding", () => {
|
||||||
|
const readerKey = secretPath("vector-reader-key");
|
||||||
|
const writerWorkspace = {
|
||||||
|
...workspace,
|
||||||
|
semantic_index: { ...workspace.semantic_index, vector_writer: {} },
|
||||||
|
};
|
||||||
|
const resolveWriter = () => resolveBinding(writerWorkspace, "VECTOR_WRITER" as never, {
|
||||||
|
THT_WS_PSD_CLINICAL_VECTOR_API_KEY_FILE: readerKey.path,
|
||||||
|
}, [readerKey.root]);
|
||||||
|
|
||||||
|
expect(resolveWriter).not.toThrow();
|
||||||
|
expect(resolveWriter()).toMatchObject({
|
||||||
|
missing: ["THT_WS_PSD_CLINICAL_VECTOR_WRITER_API_KEY_FILE"],
|
||||||
|
values: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { type CanonicalWorkspace, parseWorkspaceYaml } from "../src/workspaces/s
|
|||||||
import { renderRuntimeConfig, type RuntimeBindings } from "../src/workspaces/runtime-renderer.js";
|
import { renderRuntimeConfig, type RuntimeBindings } from "../src/workspaces/runtime-renderer.js";
|
||||||
|
|
||||||
const validWorkspace = parseWorkspaceYaml(`workspace:
|
const validWorkspace = parseWorkspaceYaml(`workspace:
|
||||||
schema_version: 1
|
schema_version: 2
|
||||||
id: psd-clinical
|
id: psd-clinical
|
||||||
name: Policlinico San Donato
|
name: Policlinico San Donato
|
||||||
language: it
|
language: it
|
||||||
@@ -74,7 +74,7 @@ test("renders English UI headings and workspace-language Italian prose", () => {
|
|||||||
|
|
||||||
test("renders the vector store identity and creates writer credentials only when declared", () => {
|
test("renders the vector store identity and creates writer credentials only when declared", () => {
|
||||||
const writerWorkspace = parseWorkspaceYaml(`workspace:
|
const writerWorkspace = parseWorkspaceYaml(`workspace:
|
||||||
schema_version: 1
|
schema_version: 2
|
||||||
id: psd-clinical
|
id: psd-clinical
|
||||||
name: Policlinico San Donato
|
name: Policlinico San Donato
|
||||||
language: it
|
language: it
|
||||||
@@ -82,7 +82,7 @@ dwh:
|
|||||||
engine: postgres
|
engine: postgres
|
||||||
database: warehouse
|
database: warehouse
|
||||||
schema: datawarehouse
|
schema: datawarehouse
|
||||||
supported_transports: [postgres_direct]
|
supported_transports: [postgres_direct, rest_api]
|
||||||
semantic_index:
|
semantic_index:
|
||||||
vector_store:
|
vector_store:
|
||||||
engine: pgvector
|
engine: pgvector
|
||||||
@@ -91,7 +91,7 @@ semantic_index:
|
|||||||
collection: clinical_documents
|
collection: clinical_documents
|
||||||
dimensions: 768
|
dimensions: 768
|
||||||
distance: cosine
|
distance: cosine
|
||||||
supported_transports: [pgvector_direct]
|
supported_transports: [pgvector_direct, rest_api]
|
||||||
vector_writer: {}
|
vector_writer: {}
|
||||||
embedding:
|
embedding:
|
||||||
provider: ollama_compatible
|
provider: ollama_compatible
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { RuntimeBindings } from "../src/workspaces/runtime-renderer.js";
|
|||||||
import { parseWorkspaceYaml } from "../src/workspaces/schema.js";
|
import { parseWorkspaceYaml } from "../src/workspaces/schema.js";
|
||||||
|
|
||||||
const workspace = parseWorkspaceYaml(`workspace:
|
const workspace = parseWorkspaceYaml(`workspace:
|
||||||
schema_version: 1
|
schema_version: 2
|
||||||
id: psd-clinical
|
id: psd-clinical
|
||||||
name: Policlinico San Donato
|
name: Policlinico San Donato
|
||||||
language: it
|
language: it
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { GitWorkspaceRepository, WorkspaceRepositoryLock } from "../src/workspac
|
|||||||
import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js";
|
import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js";
|
||||||
|
|
||||||
const validYaml = `workspace:
|
const validYaml = `workspace:
|
||||||
schema_version: 1
|
schema_version: 2
|
||||||
id: psd-clinical
|
id: psd-clinical
|
||||||
name: Policlinico San Donato
|
name: Policlinico San Donato
|
||||||
language: it
|
language: it
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parseWorkspaceYaml, serializeWorkspaceYaml } from "../src/workspaces/schema.js";
|
import * as workspaceSchema from "../src/workspaces/schema.js";
|
||||||
|
import { parseWorkspaceYaml, serializeWorkspaceYaml, validateCanonicalWorkspace } from "../src/workspaces/schema.js";
|
||||||
|
|
||||||
export const validYaml = `workspace:
|
export const validYaml = `workspace:
|
||||||
schema_version: 1
|
schema_version: 2
|
||||||
id: psd-clinical
|
id: psd-clinical
|
||||||
name: Policlinico San Donato
|
name: Policlinico San Donato
|
||||||
description: Clinical data warehouse workspace
|
description: Clinical data warehouse workspace
|
||||||
@@ -119,6 +120,82 @@ test("requires explicit vector database and schema identities with strict diagno
|
|||||||
.toThrow(/method/i);
|
.toThrow(/method/i);
|
||||||
expect(() => parseWorkspaceYaml(diagnosticWorkspace.replace(" distance: distance", " distance: distance\n extra: ignored")))
|
expect(() => parseWorkspaceYaml(diagnosticWorkspace.replace(" distance: distance", " distance: distance\n extra: ignored")))
|
||||||
.toThrow(/unrecognized key/i);
|
.toThrow(/unrecognized key/i);
|
||||||
|
for (const unsafePath of [
|
||||||
|
"//diagnostic.invalid/rpc", "'/\\\\diagnostic'", "'/rpc\\\\diagnostic'", "'/rpc/%5Cdiagnostic'", "'/rpc/\u0001'",
|
||||||
|
]) {
|
||||||
|
expect(() => parseWorkspaceYaml(diagnosticWorkspace.replace("path: /rpc/ping", `path: ${unsafePath}`)))
|
||||||
|
.toThrow(/origin-relative|path/i);
|
||||||
|
}
|
||||||
|
expect(() => parseWorkspaceYaml(diagnosticWorkspace.replace("auth: bearer", "auth: basic")))
|
||||||
|
.toThrow(/auth/i);
|
||||||
|
expect(() => parseWorkspaceYaml(diagnosticWorkspace.replace(" schema: schema", " schema: schema\n status: status")))
|
||||||
|
.toThrow(/unrecognized key/i);
|
||||||
|
expect(() => parseWorkspaceYaml(diagnosticWorkspace.replace(" schema: schema", " schema: bad field")))
|
||||||
|
.toThrow(/response field/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps v1 descriptors readable but requires explicit migration before v2 operations", () => {
|
||||||
|
const v1WithoutVectorIdentity = validYaml.replace("schema_version: 2", "schema_version: 1").replace(
|
||||||
|
" database: postgres\n schema: vectors\n", "",
|
||||||
|
);
|
||||||
|
expect(() => parseWorkspaceYaml(v1WithoutVectorIdentity)).not.toThrow();
|
||||||
|
expect(() => parseWorkspaceYaml(validYaml)).not.toThrow();
|
||||||
|
const v1 = parseWorkspaceYaml(v1WithoutVectorIdentity);
|
||||||
|
const v2 = parseWorkspaceYaml(validYaml);
|
||||||
|
|
||||||
|
expect(v1.workspace.schema_version).toBe(1);
|
||||||
|
expect(() => validateCanonicalWorkspace(v1)).toThrow(/migrat/i);
|
||||||
|
expect(v2.workspace.schema_version).toBe(2);
|
||||||
|
|
||||||
|
const migrate = (workspaceSchema as { migrateWorkspaceV1ToV2?: unknown }).migrateWorkspaceV1ToV2;
|
||||||
|
expect(migrate).toBeTypeOf("function");
|
||||||
|
const migrated = (migrate as (workspace: typeof v1, identity: { database: string; schema: string }) => unknown)(v1, {
|
||||||
|
database: "vector_database",
|
||||||
|
schema: "vectors",
|
||||||
|
});
|
||||||
|
expect(validateCanonicalWorkspace(migrated)).toMatchObject({
|
||||||
|
workspace: { schema_version: 2 },
|
||||||
|
semantic_index: { vector_store: { database: "vector_database", schema: "vectors" } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("constructs diagnostic URLs only when the resolved URL remains on the service origin", () => {
|
||||||
|
const resolveDiagnosticUrl = (workspaceSchema as { resolveDiagnosticUrl?: unknown }).resolveDiagnosticUrl;
|
||||||
|
|
||||||
|
expect(resolveDiagnosticUrl).toBeTypeOf("function");
|
||||||
|
expect((resolveDiagnosticUrl as (baseUrl: string, path: string) => URL)("https://service.example/base", "/rpc/ping"))
|
||||||
|
.toMatchObject({ href: "https://service.example/rpc/ping" });
|
||||||
|
expect(() => (resolveDiagnosticUrl as (baseUrl: string, path: string) => URL)(
|
||||||
|
"https://service.example/base", "//diagnostic.invalid/rpc",
|
||||||
|
)).toThrow(/origin/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects REST diagnostic declarations without their matching connector transport", () => {
|
||||||
|
const diagnostics = `diagnostics:
|
||||||
|
dwh_rest:
|
||||||
|
method: POST
|
||||||
|
path: /rpc/ping
|
||||||
|
auth: bearer
|
||||||
|
response:
|
||||||
|
database: database
|
||||||
|
schema: schema
|
||||||
|
vector_rest:
|
||||||
|
metadata:
|
||||||
|
method: GET
|
||||||
|
path: /metadata
|
||||||
|
auth: bearer
|
||||||
|
response:
|
||||||
|
collection: collection
|
||||||
|
dimensions: dimensions
|
||||||
|
distance: distance
|
||||||
|
llm_policy:
|
||||||
|
`;
|
||||||
|
const declared = validYaml.replace("llm_policy:\n", diagnostics);
|
||||||
|
|
||||||
|
expect(() => parseWorkspaceYaml(declared.replace(" - rest_api\n", ""))).toThrow(/dwh_rest/i);
|
||||||
|
expect(() => parseWorkspaceYaml(declared.replace(" - rest_api\n", " - rest_api\n", 1).replace(
|
||||||
|
" - rest_api\n", "",
|
||||||
|
))).toThrow(/vector_rest/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("serializes canonical YAML that parses back to the same workspace", () => {
|
test("serializes canonical YAML that parses back to the same workspace", () => {
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ The initial canonical shape is:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
workspace:
|
workspace:
|
||||||
schema_version: 1
|
schema_version: 2
|
||||||
id: psd-clinical
|
id: psd-clinical
|
||||||
name: Policlinico San Donato
|
name: Policlinico San Donato
|
||||||
description: Clinical data warehouse workspace
|
description: Clinical data warehouse workspace
|
||||||
@@ -132,6 +132,8 @@ dwh:
|
|||||||
semantic_index:
|
semantic_index:
|
||||||
vector_store:
|
vector_store:
|
||||||
engine: pgvector
|
engine: pgvector
|
||||||
|
database: postgres
|
||||||
|
schema: vectors
|
||||||
collection: clinical_documents
|
collection: clinical_documents
|
||||||
dimensions: 768
|
dimensions: 768
|
||||||
distance: cosine
|
distance: cosine
|
||||||
@@ -153,7 +155,20 @@ llm_policy:
|
|||||||
|
|
||||||
The exact machine schema is maintained by `WorkspaceSchema` and versioned with explicit migrations. Unknown keys are rejected by default so misspellings do not silently change runtime behavior.
|
The exact machine schema is maintained by `WorkspaceSchema` and versioned with explicit migrations. Unknown keys are rejected by default so misspellings do not silently change runtime behavior.
|
||||||
|
|
||||||
### 6.1 Semantic-index invariant
|
### 6.1 Version migration and operational state
|
||||||
|
|
||||||
|
Schema version 2 makes `semantic_index.vector_store.database` and `.schema` mandatory. They
|
||||||
|
identify the vector service independently of the DWH, even when both happen to use the same
|
||||||
|
PostgreSQL instance.
|
||||||
|
|
||||||
|
Version 1 descriptors remain readable and listable so operators can discover legacy Git content.
|
||||||
|
They are marked `migration_required` and may not generate installation bindings, runtime
|
||||||
|
configuration, diagnostics, or publication artifacts. Migration is an explicit UI/transformer
|
||||||
|
action that supplies the vector database/schema; it must never infer either value from the DWH.
|
||||||
|
The resulting descriptor is written as schema version 2 and then passes normal operational
|
||||||
|
validation.
|
||||||
|
|
||||||
|
### 6.2 Semantic-index invariant
|
||||||
|
|
||||||
`semantic_index` is atomic. The vector collection, vector dimensions, distance metric, embedding provider, embedding model, and embedding dimensions describe one index contract.
|
`semantic_index` is atomic. The vector collection, vector dimensions, distance metric, embedding provider, embedding model, and embedding dimensions describe one index contract.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user