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