import type { TSchema } from "@alkdev/typebox"; import { KindGuard } from "@alkdev/typebox"; import type { UNode } from "@alkdev/ujsx"; import { createHostRoot } from "@alkdev/ujsx"; import { hasCycle } from "graphology-dag"; import { DirectedGraph } from "graphology"; import type { FlowGraph } from "../graph/construction.js"; import type { OperationNodeAttrs } from "../schema/node.js"; import type { OperationEdgeAttrs } from "../schema/edge.js"; import type { ValidationError, AnyValidationError } from "../error/index.js"; import { GraphologyHostConfig } from "../host/graphology.js"; import { reachableFrom } from "../graph/queries.js"; function getRequiredTopLevelFields(schema: unknown): Set { const fields = new Set(); if (schema === null || schema === undefined || typeof schema !== "object") return fields; const s = schema as TSchema; if (!KindGuard.IsObject(s)) return fields; const props = s.properties as Record | undefined; const required = s.required as string[] | undefined; if (props && required) { for (const key of required) { fields.add(key); } } return fields; } function getProvidedFields(schema: unknown): Set { const fields = new Set(); if (schema === null || schema === undefined || typeof schema !== "object") return fields; const s = schema as TSchema; if (!KindGuard.IsObject(s)) return fields; const props = s.properties as Record | undefined; if (props) { for (const key of Object.keys(props)) { fields.add(key); } } return fields; } export function validatePreconditions( graph: FlowGraph, ): ValidationError[] { const errors: ValidationError[] = []; const nodeKeys = graph.nodes(); for (const nodeKey of nodeKeys) { const attrs = graph.getNodeAttributes(nodeKey) as unknown as OperationNodeAttrs; const inputSchema = attrs.inputSchema; const requiredFields = getRequiredTopLevelFields(inputSchema); if (requiredFields.size === 0) continue; const predecessors = graph.predecessors(nodeKey); if (predecessors.length === 0) { for (const field of requiredFields) { errors.push({ type: "schema", nodeKey, field, message: `Required input field "${field}" has no predecessor providing it`, }); } continue; } const providedFields = new Set(); for (const predKey of predecessors) { const predAttrs = graph.getNodeAttributes(predKey) as unknown as OperationNodeAttrs; const predProvided = getProvidedFields(predAttrs.outputSchema); for (const field of predProvided) { providedFields.add(field); } } for (const field of requiredFields) { if (!providedFields.has(field)) { errors.push({ type: "schema", nodeKey, field, message: `Required input field "${field}" is not provided by any predecessor`, }); } } } return errors; } function collectOperationNodeKeys(dag: DirectedGraph): string[] { const names: string[] = []; dag.forEachNode((key) => { if (!key.startsWith("__")) { names.push(key); } }); return names; } export function validateTemplate( template: UNode, operationGraph: FlowGraph, ): AnyValidationError[] { const errors: AnyValidationError[] = []; let renderedDag: DirectedGraph; try { const root = createHostRoot(GraphologyHostConfig, null); root.render(template); renderedDag = root.ctx.graph as DirectedGraph; } catch { renderedDag = new DirectedGraph(); } const templateNodeKeys = collectOperationNodeKeys(renderedDag); const graphNodeKeys = new Set(operationGraph.nodes()); for (const opKey of templateNodeKeys) { if (!graphNodeKeys.has(opKey)) { errors.push({ type: "graph", category: "orphan-node", details: { operation: opKey, message: `Operation "${opKey}" not found in operation graph` }, }); } } if (hasCycle(renderedDag)) { errors.push({ type: "graph", category: "cycle", details: { message: "Rendered template DAG contains a cycle" }, }); } for (const opKey of templateNodeKeys) { if (!graphNodeKeys.has(opKey)) continue; const outEdges = renderedDag.outEdges(opKey) ?? []; for (const edge of outEdges) { const target = renderedDag.target(edge); if (target.startsWith("__")) continue; if (!graphNodeKeys.has(target)) continue; if (operationGraph.hasEdge(opKey, target)) { const edgeAttrs = operationGraph.getEdgeAttributes(opKey, target) as unknown as OperationEdgeAttrs; if (!edgeAttrs.compatible) { errors.push({ type: "type-compat", sourceKey: opKey, targetKey: target, compatible: false, mismatches: edgeAttrs.mismatches ?? [], }); } } } } if (templateNodeKeys.length > 1) { const roots: string[] = []; for (const key of templateNodeKeys) { const inDegree = renderedDag.inDegree(key); if (inDegree === 0) { roots.push(key); } } if (roots.length > 0) { const reachable = reachableFrom(renderedDag, roots); for (const nodeKey of templateNodeKeys) { if (!reachable.has(nodeKey)) { errors.push({ type: "graph", category: "orphan-node", details: { nodeKey, message: `Operation "${nodeKey}" is not reachable from start` }, }); } } } } for (const nodeKey of templateNodeKeys) { const inDegree = renderedDag.inDegree(nodeKey); const outDegree = renderedDag.outDegree(nodeKey); if (inDegree === 0 && outDegree === 0 && templateNodeKeys.length > 1) { errors.push({ type: "graph", category: "orphan-node", details: { nodeKey, message: `Operation "${nodeKey}" has no edges (orphan node)` }, }); } } return errors; }