Source

graph/document/GraphWorkflowDocumentBuilder.ts

import { ValidationError } from "@decaf-ts/db-decorators";
import type { GraphJsonValue } from "./GraphJsonValue";
import {
  isGraphJsonSafeValue,
  isGraphUnsafeObjectKey,
} from "./GraphJsonValue";
import type { GraphEndpoint, GraphNodeEndpoint, GraphWorkflowEndpoint } from "./GraphEndpoint";
import { isGraphEndpoint, isGraphNodeEndpoint, isGraphWorkflowEndpoint } from "./GraphEndpoint";
import { isGraphInputBinding } from "./GraphNodeBinding";
import type { GraphValueReference } from "./GraphLoopConfiguration";
import type { GraphLoopConfiguration } from "./GraphLoopConfiguration";
import type { GraphWorkflowUiState } from "./GraphWorkflowUiState";
import type { GraphEdgeInstance } from "./GraphEdgeInstance";
import type { GraphNodeInstance } from "./GraphNodeInstance";
import type {
  GraphWorkflowDocument,
  GraphWorkflowPortInstance,
  GraphWorkflowSettings,
} from "./GraphWorkflowDocument";
import { isGraphValueSchema } from "../catalog/GraphValueSchema";

/** A workflow boundary port accepted by {@link GraphWorkflowDocumentBuilder}. */
export type GraphWorkflowPortInput = GraphWorkflowPortInstance;

/** Options for constructing a {@link GraphWorkflowDocumentBuilder}. */
export interface GraphWorkflowDocumentBuilderOptions {
  /** Document id; defaults to an empty string when omitted. */
  id?: string;
  /** Human-readable document name; defaults to an empty string when omitted. */
  name?: string;
}

/**
 * Fluent builder for the canonical {@link GraphWorkflowDocument}
 * (DECAF-50 §4.4.4). Collects boundary ports, node instances, edges, and
 * optional settings/metadata/UI state, then validates and freezes a
 * defensively-copied document on {@link GraphWorkflowDocumentBuilder.build}.
 */
export class GraphWorkflowDocumentBuilder {
  private readonly document: GraphWorkflowDocument;

  /**
   * @param id Document id.
   * @param name Optional document name; defaults to an empty string.
   */
  constructor(id: string, name?: string) {
    this.document = {
      id,
      name: name ?? "",
      inputs: [],
      outputs: [],
      nodes: [],
      edges: [],
    };
  }

  /** Appends a workflow input port. */
  addInput(port: GraphWorkflowPortInput): this {
    this.document.inputs.push(port);
    return this;
  }

  /** Appends a workflow output port. */
  addOutput(port: GraphWorkflowPortInput): this {
    this.document.outputs.push(port);
    return this;
  }

  /** Appends a node instance, defaulting `parameters` to an empty object. */
  addNode(node: GraphNodeInstance): this {
    this.document.nodes.push({
      ...node,
      parameters: node.parameters ?? {},
    });
    return this;
  }

  /** Appends an edge instance. */
  addEdge(edge: GraphEdgeInstance): this {
    this.document.edges.push(edge);
    return this;
  }

  /** Sets (or clears) the document's free-form settings bag. */
  setSettings(settings?: GraphWorkflowSettings): this {
    this.document.settings = settings;
    return this;
  }

  /** Sets (or clears) the document's metadata bag. */
  setMetadata(metadata?: Record<string, GraphJsonValue>): this {
    this.document.metadata = metadata;
    return this;
  }

  /** Sets (or clears) the document's editor UI state. */
  setUi(ui?: GraphWorkflowUiState): this {
    this.document.ui = ui;
    return this;
  }

  /**
   * Validates the accumulated document with
   * {@link assertGraphWorkflowDocumentValid} and returns a deep-ish copy:
   * ports, nodes, and edges are cloned per entry, and the optional
   * `settings`/`metadata`/`ui` bags are only present when explicitly set, so
   * callers never observe builder-internal `undefined` fields.
   *
   * @throws ValidationError when the document fails validation.
   */
  build(): GraphWorkflowDocument {
    assertGraphWorkflowDocumentValid(this.document);
    const document: GraphWorkflowDocument = {
      id: this.document.id,
      name: this.document.name,
      inputs: this.document.inputs.map((port) => ({ ...port })),
      outputs: this.document.outputs.map((port) => ({ ...port })),
      nodes: this.document.nodes.map((node) => ({ ...node })),
      edges: this.document.edges.map((edge) => ({ ...edge })),
    };
    if (this.document.settings !== undefined) {
      document.settings = this.document.settings;
    }
    if (this.document.metadata !== undefined) {
      document.metadata = this.document.metadata;
    }
    if (this.document.ui !== undefined) {
      document.ui = this.document.ui;
    }
    return document;
  }
}

/**
 * Shared validation context for
 * {@link assertGraphWorkflowDocumentValid}: tracks documents already visited
 * on the current nesting path so cyclic loop bodies are detected.
 */
export interface GraphWorkflowDocumentValidationContext {
  seen: unknown[];
}

/**
 * Structural validation gate for a canonical {@link GraphWorkflowDocument}
 * (shared by the frontend builder and the backend nine-stage validator,
 * DECAF-50 §4.8 stage 1 shape rules). Checks unique port/node/edge ids,
 * valid schemas, binding shapes, edge endpoints, and recursively validates
 * nested loop-body documents.
 *
 * @throws ValidationError describing the first invalid aspect found.
 */
export function assertGraphWorkflowDocumentValid(
  document: GraphWorkflowDocument,
  context: GraphWorkflowDocumentValidationContext = { seen: [] }
): void {
  if (context.seen.includes(document)) {
    throw new ValidationError(
      `Document '${describeDocument(document)}' contains a loop body that references itself (cyclic document)`
    );
  }
  if (typeof document.id !== "string" || !document.id) {
    throw new ValidationError("Document 'id' is required and must be a non-empty string");
  }
  if (typeof document.name !== "string" || !document.name) {
    throw new ValidationError("Document 'name' is required and must be a non-empty string");
  }
  const workflowPorts = new Set<string>();
  for (const port of [...document.inputs, ...document.outputs]) {
    if (typeof port.id !== "string" || !port.id) {
      throw new ValidationError(
        "Workflow port 'id' is required and must be a non-empty string"
      );
    }
    if (workflowPorts.has(port.id)) {
      throw new ValidationError(
        `Workflow port '${port.id}' is declared more than once; workflow port ids must be unique`
      );
    }
    if (port.schema !== undefined && !isGraphValueSchema(port.schema)) {
      throw new ValidationError(
        `Workflow port '${port.id}' schema must be a valid GraphValueSchema`
      );
    }
    workflowPorts.add(port.id);
  }
  const nestedContext: GraphWorkflowDocumentValidationContext = {
    seen: [...context.seen, document],
  };
  const nodeIds = new Set<string>();
  for (const node of document.nodes) {
    assertGraphNodeValid(node, nodeIds);
  }
  for (const node of document.nodes) {
    if (node.loop) {
      assertGraphLoopConfigurationValid(node, node.loop, nestedContext);
    }
  }
  const edgeIds = new Set<string>();
  for (const edge of document.edges) {
    assertGraphEdgeValid(edge, edgeIds);
  }
  for (const edge of document.edges) {
    assertEdgeEndpoint(
      edge.source,
      document,
      workflowPorts,
      nodeIds,
      `Edge '${edge.id}' source`
    );
    assertEdgeEndpoint(
      edge.target,
      document,
      workflowPorts,
      nodeIds,
      `Edge '${edge.id}' target`
    );
  }
  if (document.settings !== undefined && !isGraphJsonSafeValue(document.settings)) {
    throw new ValidationError(
      "Document settings must be JSON-safe (a map of JSON-safe values)"
    );
  }
  if (document.metadata !== undefined && !isGraphJsonSafeValue(document.metadata)) {
    throw new ValidationError(
      "Document metadata must be JSON-safe: functions, class instances, undefined, NaN/Infinity, symbol keys, or unsafe prototype keys are not allowed"
    );
  }
  if (document.ui !== undefined && !isGraphJsonSafeValue(document.ui)) {
    throw new ValidationError(
      "Document ui must be JSON-safe: functions, class instances, undefined, NaN/Infinity, symbol keys, or unsafe prototype keys are not allowed"
    );
  }
}

function describeDocument(document: unknown): string {
  const id = (document as Record<string, unknown>)?.["id"];
  return typeof id === "string" ? id : "<unnamed document>";
}

function assertGraphNodeValid(node: GraphNodeInstance, nodeIds: Set<string>): void {
  if (typeof node.id !== "string" || !node.id) {
    throw new ValidationError("Node 'id' is required and must be a non-empty string");
  }
  if (nodeIds.has(node.id)) {
    throw new ValidationError(
      `Node id '${node.id}' is declared more than once; node ids must be unique`
    );
  }
  if (typeof node.kind !== "string" || !node.kind) {
    throw new ValidationError(
      `Node '${node.id}' must declare a non-empty string 'kind'`
    );
  }
  if (node.parameters === undefined || node.parameters === null) {
    throw new ValidationError(
      `Node '${node.id}' parameters must be present (an empty object when there are none)`
    );
  }
  nodeIds.add(node.id);
  if (node.inputBindings) {
    for (const [key, binding] of Object.entries(node.inputBindings)) {
      if (isGraphUnsafeObjectKey(key)) {
        throw new ValidationError(
          `Node '${node.id}' input binding key '${key}' is not allowed (unsafe prototype-pollutant key)`
        );
      }
      if (!isGraphInputBinding(binding)) {
        throw new ValidationError(
          `Node '${node.id}' input binding for '${key}' must be a valid GraphInputBinding (edge, literal, or expression)`
        );
      }
      if (binding.mode === "literal" && !isGraphJsonSafeValue(binding.value)) {
        throw new ValidationError(
          `Node '${node.id}' literal binding for '${key}' must contain JSON-safe values`
        );
      }
      if (binding.mode === "expression" && !binding.expression) {
        throw new ValidationError(
          `Node '${node.id}' expression binding for '${key}' must contain a non-empty expression`
        );
      }
    }
  }
  if (node.outputBindings) {
    for (const [key, binding] of Object.entries(node.outputBindings)) {
      if (isGraphUnsafeObjectKey(key)) {
        throw new ValidationError(
          `Node '${node.id}' output binding key '${key}' is not allowed (unsafe prototype-pollutant key)`
        );
      }
      if (!binding || typeof binding !== "object" || Array.isArray(binding)) {
        throw new ValidationError(
          `Node '${node.id}' output binding for '${key}' must be an object with enabled and alias settings`
        );
      }
    }
  }
  if (!isGraphJsonSafeValue(node.parameters)) {
    throw new ValidationError(
      `Node '${node.id}' parameters must be JSON-safe: functions, class instances, undefined, NaN/Infinity, symbol keys, or unsafe prototype keys are not allowed`
    );
  }
}

function assertGraphLoopConfigurationValid(
  node: GraphNodeInstance,
  config: GraphLoopConfiguration,
  context: GraphWorkflowDocumentValidationContext
): void {
  if (!config.body || typeof config.body !== "object") {
    throw new ValidationError(
      `Node '${node.id}' loop configuration requires a body document`
    );
  }
  assertGraphWorkflowDocumentValid(config.body, context);
  if (
    config.maxIterations !== undefined &&
    (!Number.isFinite(config.maxIterations) || config.maxIterations <= 0)
  ) {
    throw new ValidationError(
      `Node '${node.id}' loop maxIterations must be a positive number`
    );
  }
  if (
    config.timeoutMs !== undefined &&
    (typeof config.timeoutMs !== "number" || config.timeoutMs <= 0)
  ) {
    throw new ValidationError(
      `Node '${node.id}' loop timeoutMs must be a positive number in milliseconds`
    );
  }
  if (
    config.concurrency !== undefined &&
    (typeof config.concurrency !== "number" || config.concurrency <= 0)
  ) {
    throw new ValidationError(
      `Node '${node.id}' loop concurrency must be a positive number`
    );
  }
  for (const mappings of [config.inputMappings, config.outputMappings]) {
    if (!mappings) continue;
    for (const [key, reference] of Object.entries(mappings)) {
      if (isGraphUnsafeObjectKey(key)) {
        throw new ValidationError(
          `Node '${node.id}' loop mapping key '${key}' is not allowed (unsafe prototype-pollutant key)`
        );
      }
      assertGraphValueReferenceShape(reference, node.id, key);
    }
  }
}

function assertGraphValueReferenceShape(
  reference: GraphValueReference,
  nodeId: string,
  mappingKey: string
): void {
  if (!reference || typeof reference !== "object") {
    throw new ValidationError(
      `Node '${nodeId}' loop mapping '${mappingKey}' must reference a workflow port, node port, literal, or expression`
    );
  }
  switch (reference.source) {
    case "workflow":
      if (typeof reference.port !== "string" || !reference.port) {
        throw new ValidationError(
          `Node '${nodeId}' loop mapping '${mappingKey}' referencing a workflow must reference a port identifier`
        );
      }
      break;
    case "node":
      if (
        typeof reference.nodeId !== "string" ||
        !reference.nodeId ||
        typeof reference.port !== "string" ||
        !reference.port
      ) {
        throw new ValidationError(
          `Node '${nodeId}' loop mapping '${mappingKey}' referencing a node must reference a node id and a port`
        );
      }
      break;
    case "literal":
      if (!isGraphJsonSafeValue(reference.value)) {
        throw new ValidationError(
          `Node '${nodeId}' loop mapping '${mappingKey}' literal references must be JSON-safe values`
        );
      }
      break;
    case "expression":
      if (typeof reference.expression !== "string" || !reference.expression) {
        throw new ValidationError(
          `Node '${nodeId}' loop mapping '${mappingKey}' expression references must be non-empty expressions`
        );
      }
      break;
    default:
      throw new ValidationError(
        `Node '${nodeId}' loop mapping '${mappingKey}' must use a workflow, node, literal, or expression reference source`
      );
  }
}

function assertGraphEdgeValid(edge: GraphEdgeInstance, edgeIds: Set<string>): void {
  if (typeof edge.id !== "string" || !edge.id) {
    throw new ValidationError("Edge 'id' is required and must be a non-empty string");
  }
  if (edgeIds.has(edge.id)) {
    throw new ValidationError(
      `Edge id '${edge.id}' is declared more than once; edge ids must be unique`
    );
  }
  if (!isGraphEndpoint(edge.source)) {
    throw new ValidationError(
      `Edge '${edge.id}' source endpoint is not a valid GraphEndpoint`
    );
  }
  if (!isGraphEndpoint(edge.target)) {
    throw new ValidationError(
      `Edge '${edge.id}' target endpoint is not a valid GraphEndpoint`
    );
  }
  if (edge.type !== "data" && edge.type !== "connection") {
    throw new ValidationError(
      `Edge '${edge.id}' type '${String(edge.type)}' is not a valid edge type (data or connection are allowed)`
    );
  }
  edgeIds.add(edge.id);
  if (!isGraphJsonSafeValue(edge)) {
    throw new ValidationError(
      `Edge '${edge.id}' contains values that are not JSON-safe: functions, class instances, undefined, NaN/Infinity, symbol keys, or unsafe prototype keys are not allowed`
    );
  }
}

function assertEdgeEndpoint(
  endpoint: GraphEndpoint,
  document: GraphWorkflowDocument,
  workflowPorts: Set<string>,
  nodeIds: Set<string>,
  context: string
): void {
  if (isGraphWorkflowEndpoint(endpoint)) {
    if (!workflowPorts.has(endpoint.port)) {
      throw new ValidationError(
        `${context} references workflow port '${endpoint.port}' which is not declared on the document`
      );
    }
    return;
  }
  if (isGraphNodeEndpoint(endpoint)) {
    if (!nodeIds.has(endpoint.nodeId)) {
      throw new ValidationError(
        `${context} references node '${endpoint.nodeId}' which is not part of the document`
      );
    }
    if (typeof endpoint.port !== "string" || !endpoint.port) {
      throw new ValidationError(
        `${context} node endpoints must reference a non-empty port identifier`
      );
    }
  }
}

export type { GraphWorkflowEndpoint, GraphNodeEndpoint };