Files
lda-wf/web/packages/rpc/scripts/workflow-contract-generator.ts
T

444 lines
14 KiB
TypeScript

import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { compileFromFile } from "json-schema-to-typescript";
import { Schema } from "effect";
type JsonPrimitive = boolean | null | number | string;
type JsonValue = JsonPrimitive | JsonValue[] | { readonly [key: string]: JsonValue };
type JsonObject = { readonly [key: string]: JsonValue };
interface WorkflowContractParam {
readonly name: string;
readonly required: boolean;
readonly schema: JsonObject;
}
interface WorkflowContractOperation {
readonly method: string;
readonly params: readonly WorkflowContractParam[];
readonly resultSchema: JsonObject;
}
export interface WorkflowContractManifest {
readonly manifestVersion: 1;
readonly schemas: JsonObject;
readonly operations: readonly WorkflowContractOperation[];
}
const decodeJson = Schema.decodeUnknownSync(Schema.parseJson(Schema.Unknown));
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const jsonValueAt = (value: unknown, path: string): JsonValue => {
if (
value === null ||
typeof value === "boolean" ||
typeof value === "number" ||
typeof value === "string"
) {
return value;
}
if (Array.isArray(value)) {
return value.map((item, index) => jsonValueAt(item, `${path}[${index}]`));
}
if (isRecord(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
jsonValueAt(item, `${path}.${key}`),
]),
);
}
throw new Error(`${path} must contain only JSON values`);
};
const objectAt = (value: unknown, path: string): JsonObject => {
if (!isRecord(value)) throw new Error(`${path} must be an object`);
const decoded = jsonValueAt(value, path);
if (Array.isArray(decoded) || decoded === null || typeof decoded !== "object") {
throw new Error(`${path} must be an object`);
}
return decoded;
};
const arrayAt = (value: unknown, path: string): readonly unknown[] => {
if (!Array.isArray(value)) throw new Error(`${path} must be an array`);
return value;
};
const stringAt = (value: unknown, path: string): string => {
if (typeof value !== "string" || value.length === 0) {
throw new Error(`${path} must be a non-empty string`);
}
return value;
};
const booleanAt = (value: unknown, path: string): boolean => {
if (typeof value !== "boolean") throw new Error(`${path} must be a boolean`);
return value;
};
const compareText = (left: string, right: string): number =>
left < right ? -1 : left > right ? 1 : 0;
const runtimeOperationNameList = [
"workflow.health",
"workflow.sources.list",
"workflow.capabilities.call",
"workflow.capabilities.list",
"workflow.capabilities.inspect",
"workflow.draft_workspaces.add_step_from_capability",
"workflow.draft_workspaces.create_empty",
"workflow.draft_workspaces.create_from_capability",
"workflow.draft_workspaces.list",
"workflow.draft_workspaces.get",
"workflow.draft_workspaces.inspect_authoring_contract",
"workflow.draft_workspaces.set_contract",
"workflow.draft_workspaces.set_route",
"workflow.draft_workspaces.set_start",
"workflow.draft_workspaces.set_step_input_bindings",
"workflow.draft_workspaces.set_step_output_bindings",
"workflow.draft_workspaces.update_capability_step",
"workflow.draft_workspaces.validate",
"workflow.draft_workspaces.set_workflow_output_bindings",
"workflow.artifacts.list",
"workflow.artifacts.inspect",
"workflow.deployments.list",
"workflow.deployments.inspect",
"workflow.deployments.validate",
"workflow.runs.list",
"workflow.runs.inspect",
"workflow.runs.start",
"workflow.runs.resume",
"workflow.runs.trace",
] as const;
const runtimeOperationNames = new Set<string>(runtimeOperationNameList);
export const parseWorkflowContractManifest = (
manifestText: string,
): WorkflowContractManifest => {
const value = decodeJson(manifestText);
if (!isRecord(value)) throw new Error("workflow contract manifest must be an object");
if (value.manifest_version !== 1) {
throw new Error("workflow contract manifest_version must be 1");
}
const components = objectAt(value.components, "components");
const schemas = objectAt(components.schemas, "components.schemas");
const methods = new Set<string>();
const operations = arrayAt(value.operations, "operations")
.map((operationValue, operationIndex): WorkflowContractOperation => {
if (!isRecord(operationValue)) {
throw new Error(`operations[${operationIndex}] must be an object`);
}
const method = stringAt(
operationValue.method,
`operations[${operationIndex}].method`,
);
if (methods.has(method)) throw new Error(`duplicate operation method ${method}`);
methods.add(method);
const parameterNames = new Set<string>();
const params = arrayAt(
operationValue.params,
`operations[${operationIndex}].params`,
).map((paramValue, paramIndex): WorkflowContractParam => {
if (!isRecord(paramValue)) {
throw new Error(
`operations[${operationIndex}].params[${paramIndex}] must be an object`,
);
}
const name = stringAt(
paramValue.name,
`operations[${operationIndex}].params[${paramIndex}].name`,
);
if (parameterNames.has(name)) {
throw new Error(`duplicate parameter name ${name} in ${method}`);
}
parameterNames.add(name);
return {
name,
required: booleanAt(
paramValue.required,
`operations[${operationIndex}].params[${paramIndex}].required`,
),
schema: objectAt(
paramValue.schema,
`operations[${operationIndex}].params[${paramIndex}].schema`,
),
};
});
const result = objectAt(
operationValue.result,
`operations[${operationIndex}].result`,
);
return {
method,
params,
resultSchema: objectAt(
result.schema,
`operations[${operationIndex}].result.schema`,
),
};
})
.sort((left, right) => compareText(left.method, right.method));
return { manifestVersion: 1, schemas, operations };
};
const rewriteComponentRefs = (value: JsonValue): JsonValue => {
if (Array.isArray(value)) return value.map(rewriteComponentRefs);
if (value === null || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
key === "$ref" && typeof item === "string"
? item.replace("#/components/schemas/", "#/definitions/")
: rewriteComponentRefs(item),
]),
);
};
const paramsSchemaFor = (operation: WorkflowContractOperation): JsonObject =>
operation.params.length === 0
? {
// TypeScript's `{}` accepts every non-nullish value; this preserves an empty RPC object.
tsType: "Record<string, never>",
}
: {
additionalProperties: false,
properties: Object.fromEntries(
operation.params.map((param) => [
param.name,
rewriteComponentRefs(param.schema),
]),
),
required: operation.params
.filter((param) => param.required)
.map((param) => param.name),
type: "object",
};
const runtimeParamsSchemaFor = (
operation: WorkflowContractOperation,
): JsonObject => ({
additionalProperties: false,
properties: Object.fromEntries(
operation.params.map((param) => [
param.name,
normalizeRuntimeSchema(param.schema),
]),
),
required: operation.params
.filter((param) => param.required)
.map((param) => param.name),
type: "object",
});
const hasDistinctBranchTypes = (branches: ReadonlyArray<JsonValue>): boolean => {
const types = branches.map((branch) =>
branch !== null && !Array.isArray(branch) && typeof branch === "object"
? branch.type
: undefined,
);
return types.every((type): type is string => typeof type === "string") &&
new Set(types).size === types.length;
};
const normalizeRuntimeSchema = (value: JsonValue): JsonValue => {
if (Array.isArray(value)) return value.map(normalizeRuntimeSchema);
if (value === null || typeof value !== "object") return value;
const normalized = Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
normalizeRuntimeSchema(child),
]),
);
const oneOf = normalized.oneOf;
if (
Array.isArray(oneOf) &&
normalized.anyOf === undefined &&
hasDistinctBranchTypes(oneOf)
) {
const { oneOf: _, ...withoutOneOf } = normalized;
// Distinct JSON types cannot overlap, so oneOf and anyOf accept the same
// values. Unknown or same-type branches keep oneOf and fail closed later.
return { ...withoutOneOf, anyOf: oneOf };
}
return normalized;
};
const runtimeContractSource = (manifest: WorkflowContractManifest): string => {
const operations = manifest.operations.filter(({ method }) =>
runtimeOperationNames.has(method),
);
const selectedNames = new Set(operations.map(({ method }) => method));
const missingNames = runtimeOperationNameList.filter(
(name) => !selectedNames.has(name),
);
if (missingNames.length > 0) {
throw new Error(`missing runtime operation(s): ${missingNames.join(", ")}`);
}
const referencedSchemas = new Set<string>();
const visit = (value: JsonValue): void => {
if (Array.isArray(value)) {
value.forEach(visit);
return;
}
if (value === null || typeof value !== "object") return;
for (const [key, item] of Object.entries(value)) {
if (key !== "$ref" || typeof item !== "string") {
visit(item);
continue;
}
if (!item.startsWith("#/components/schemas/")) {
throw new Error(`external runtime schema reference ${item}`);
}
const name = item.slice("#/components/schemas/".length);
if (referencedSchemas.has(name)) continue;
const schema = manifest.schemas[name];
if (schema === undefined) {
throw new Error(`missing runtime component schema ${name}`);
}
referencedSchemas.add(name);
visit(schema);
}
};
for (const operation of operations) {
visit(runtimeParamsSchemaFor(operation));
visit(operation.resultSchema);
}
const runtimeContract = {
components: Object.fromEntries(
Array.from(referencedSchemas)
.sort(compareText)
.map((name) => {
const schema = manifest.schemas[name];
if (schema === undefined) {
throw new Error(`missing runtime component schema ${name}`);
}
return [name, normalizeRuntimeSchema(schema)];
}),
),
operations: Object.fromEntries(
operations.map((operation) => [
operation.method,
{
payload: runtimeParamsSchemaFor(operation),
success: normalizeRuntimeSchema(operation.resultSchema),
},
]),
),
};
return [
"",
"// Runtime JSON Schema is limited to parity-verified authored RPCs.",
`export const workflowRuntimeContract = ${JSON.stringify(runtimeContract, null, 2)};`,
"",
].join("\n");
};
const compilerSchemaFor = (manifest: WorkflowContractManifest): JsonObject => ({
additionalProperties: false,
definitions: rewriteComponentRefs(manifest.schemas),
properties: Object.fromEntries(
manifest.operations.map((operation) => [
operation.method,
{
additionalProperties: false,
properties: {
params: paramsSchemaFor(operation),
result: rewriteComponentRefs(operation.resultSchema),
},
required: ["params", "result"],
type: "object",
},
]),
),
required: manifest.operations.map((operation) => operation.method),
title: "WorkflowContractMap",
type: "object",
});
const compileSchema = async (schema: JsonObject): Promise<string> => {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "workflow-contract-"));
const schemaPath = join(temporaryDirectory, "workflow-contract.schema.json");
try {
// compileFromFile owns JSON Schema parsing, avoiding a second handwritten schema AST.
await writeFile(schemaPath, `${JSON.stringify(schema, null, 2)}\n`, "utf8");
return await compileFromFile(schemaPath, {
bannerComment: "",
format: true,
unknownAny: true,
unreachableDefinitions: true,
});
} finally {
await rm(temporaryDirectory, { force: true, recursive: true });
}
};
const inventorySource = (operationNames: readonly string[]): string => {
const union = operationNames.map((name) => ` | ${JSON.stringify(name)}`).join("\n");
const values = operationNames.map((name) => ` ${JSON.stringify(name)},`).join("\n");
return [
"// Generated by `pnpm contract:write`. Do not edit by hand.",
"// These are compile-time wire types, not runtime decoders or authorization.",
"",
"export type WorkflowOperationName =",
`${union};`,
"",
"export const workflowOperationNames: readonly WorkflowOperationName[] = [",
values,
"];",
"",
].join("\n");
};
export const generateWorkflowContractSource = async (
manifestText: string,
): Promise<string> => {
const manifest = parseWorkflowContractManifest(manifestText);
const runtimeSource = runtimeContractSource(manifest);
const compiled = await compileSchema(compilerSchemaFor(manifest));
const helpers = [
"",
"export type WorkflowOperationParams<Name extends WorkflowOperationName> =",
' WorkflowContractMap[Name]["params"];',
"",
"export type WorkflowOperationResult<Name extends WorkflowOperationName> =",
' WorkflowContractMap[Name]["result"];',
"",
].join("\n");
return `${inventorySource(manifest.operations.map(({ method }) => method))}${compiled.trim()}\n${helpers}${runtimeSource}`;
};
export const writeWorkflowContract = async (
manifestPath: string,
outputPath: string,
): Promise<void> => {
const manifestText = await readFile(manifestPath, "utf8");
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, await generateWorkflowContractSource(manifestText), "utf8");
};
export const checkWorkflowContract = async (
manifestPath: string,
outputPath: string,
): Promise<void> => {
const [manifestText, checkedSource] = await Promise.all([
readFile(manifestPath, "utf8"),
readFile(outputPath, "utf8"),
]);
const generatedSource = await generateWorkflowContractSource(manifestText);
if (generatedSource !== checkedSource) {
throw new Error("generated workflow contract is stale; run `pnpm contract:write`");
}
};