Source

graph/document/GraphDecoratedWorkflowCompiler.ts

import { ValidationError } from "@decaf-ts/db-decorators";
import type { Constructor } from "@decaf-ts/decoration";
import type { Model } from "@decaf-ts/decorator-validation";
import type {
  GraphPortDefinition,
  GraphWorkflowDefinition,
  GraphWorkflowNodeMetadata,
  GraphWorkflowRelationMetadata,
} from "../constants";
import { graphLeafPortsOf, graphWorkflowDefinitionOf } from "../reader";
import type { GraphWorkflowDocument, GraphWorkflowPortInstance } from "./GraphWorkflowDocument";
import type { GraphNodeInstance } from "./GraphNodeInstance";
import type { GraphEdgeInstance } from "./GraphEdgeInstance";
import type { GraphEndpoint, GraphNodeEndpoint, GraphWorkflowEndpoint } from "./GraphEndpoint";
import type { GraphJsonValue } from "./GraphJsonValue";
import { isGraphJsonSafeValue } from "./GraphJsonValue";
import type {
  GraphNodeUiState,
  GraphWorkflowUiState,
  GraphWorkflowViewport,
} from "./GraphWorkflowUiState";
import type { GraphLoopConfiguration } from "./GraphLoopConfiguration";
import type { GraphValueSchema } from "../catalog/GraphValueSchema";
import {
  graphValueSchemaFromValidation,
  type GraphValidationRecord,
} from "../catalog/GraphValueSchemaDerivation";
import { GraphWorkflowDocumentBuilder } from "./GraphWorkflowDocumentBuilder";

/**
 * Options for compiling a decorated workflow into a canonical
 * {@link GraphWorkflowDocument}.
 */
export interface GraphDecoratedWorkflowCompileOptions {
  /** Document id; defaults to the workflow's tag or name. */
  id?: string;
  /** Human-readable document name; defaults to the workflow's name. */
  name?: string;
  /** Canvas positions per node id, carried into the document's UI state. */
  positions?: Record<string, { x: number; y: number }>;
  /** Initial canvas viewport carried into the document's UI state. */
  viewport?: GraphWorkflowViewport;
}

/** Anything the compiler can resolve into a {@link GraphWorkflowDefinition}: a decorated workflow class (constructor), a model instance, or a definition. */
export type GraphDecoratedWorkflowInput = Constructor | Model | GraphWorkflowDefinition;

const GRAPH_BOUNDARY_ALIASES = ["$workflow", "workflow", "graph"];

const GRAPH_DATE_TYPE_NAME_FORMATS: Record<string, string> = {
  date: "date",
  datetime: "date-time",
  "date-time": "date-time",
  duration: "duration",
};


type GraphNodeDefinitionShim = {
  name: string;
  tag?: string;
  kind?: string;
  ports: GraphPortDefinition[];
  graph?: { metadata?: Record<string, unknown> } & Record<string, unknown>;
};

/**
 * Compiles a decorated workflow (or an already-resolved
 * {@link GraphWorkflowDefinition}) into a canonical
 * {@link GraphWorkflowDocument}: workflow boundary ports, one instance per
 * node (input-port defaults and loop/switch metadata folded into
 * `parameters`/`metadata`), one edge per relation, plus UI state. Browser-safe:
 * node constructors are never invoked, so `@node` classes can seed demo
 * graphs without engine imports (DECAF-50 §4.4.4).
 *
 * @throws ValidationError when the input cannot be resolved to a decorated
 * graph workflow definition.
 */
export function graphDecoratedWorkflowCompiler(
  workflow: GraphDecoratedWorkflowInput,
  options: GraphDecoratedWorkflowCompileOptions = {}
): GraphWorkflowDocument {
  const definition = resolveGraphWorkflowDefinition(workflow);
  const nodes = definition.nodes.map((nodeMetadata) =>
    graphNodeInstanceOf(nodeMetadata, definition, options.positions ?? {})
  );
  const builder = new GraphWorkflowDocumentBuilder(
    options.id ?? definition.tag ?? definition.name,
    options.name ?? definition.name
  );
  for (const port of graphLeafPortsOf(definition.inputs)) {
    builder.addInput(graphWorkflowPortOf(port));
  }
  for (const port of graphLeafPortsOf(definition.outputs)) {
    builder.addOutput(graphWorkflowPortOf(port));
  }
  for (const node of nodes) {
    builder.addNode(node);
  }
  definition.relations.forEach((relation, index) => {
    builder.addEdge(graphEdgeOf(relation, definition, index));
  });
  builder.setUi(graphWorkflowUiStateOf(options));
  builder.setMetadata(graphDocumentMetadataOf(definition));
  return builder.build();
}

function resolveGraphWorkflowDefinition(
  workflow: GraphDecoratedWorkflowInput
): GraphWorkflowDefinition {
  try {
    return graphWorkflowDefinitionOf(workflow);
  } catch (e) {
    throw new ValidationError(
      `Could not resolve a decorated graph workflow from the given value: ${String(e)}`
    );
  }
}

function graphWorkflowPortOf(port: GraphPortDefinition): GraphWorkflowPortInstance {
  const workflowPort: GraphWorkflowPortInstance = {
    id: port.path ?? port.property,
    label: port.label,
    schema: graphPortSchemaOf(port),
    required: port.required,
  };
  const defaultValue = graphPortDefaultValueOf(port);
  if (defaultValue !== undefined) workflowPort.defaultValue = defaultValue;
  const category = port.graph?.["category"];
  if (typeof category === "string") workflowPort.metadata = { category };
  return workflowPort;
}

function graphPortSchemaOf(port: GraphPortDefinition): GraphValueSchema {
  if (port.type && port.type in GRAPH_DATE_TYPE_NAME_FORMATS) {
    return { type: "string", format: GRAPH_DATE_TYPE_NAME_FORMATS[port.type] };
  }
  return graphValueSchemaFromValidation(
    port.validation as GraphValidationRecord | undefined,
    port.type,
    port.model
  );
}

function graphPortDefaultValueOf(port: GraphPortDefinition): GraphJsonValue | undefined {
  const elementValue = port.element?.["props"]?.["value"];
  const source = elementValue ?? port.prop?.["value"] ?? port.validation?.["defaultValue"];
  if (source === undefined || typeof source === "function") return undefined;
  if (!isGraphJsonSafeValue(source)) return undefined;
  try {
    return JSON.parse(JSON.stringify(source)) as GraphJsonValue;
  } catch {
    return undefined;
  }
}

function graphWorkflowUiStateOf(
  options: GraphDecoratedWorkflowCompileOptions
): GraphWorkflowUiState | undefined {
  if (!options.viewport) return undefined;
  return { viewport: { ...options.viewport } };
}

function graphNodeInstanceOf(
  nodeMetadata: GraphWorkflowNodeMetadata,
  workflow: GraphWorkflowDefinition,
  positions: Record<string, { x: number; y: number }>
): GraphNodeInstance {
  const nodeDefinition = isGraphModelLike(nodeMetadata.node)
    ? graphNodeDefinitionSafely(nodeMetadata.node)
    : undefined;
  const instance: GraphNodeInstance = {
    id: nodeMetadata.id,
    kind:
      nodeMetadata.kind ??
      nodeDefinition?.kind ??
      nodeDefinition?.name ??
      nodeMetadata.id,
    parameters: graphNodeParametersOf(nodeMetadata, nodeDefinition),
  };
  if (nodeMetadata.label !== undefined) instance.label = nodeMetadata.label;
  const metadata = graphNodeMetadataCollectionOf(nodeMetadata, nodeDefinition);
  if (metadata) instance.metadata = metadata;
  const loop = graphLoopConfigurationOf(nodeMetadata, nodeDefinition);
  if (loop) instance.loop = loop;
  const position = positions[nodeMetadata.id];
  if (position) {
    const ui: GraphNodeUiState = { position: { x: position.x, y: position.y } };
    instance.ui = ui;
  }
  return instance;
}

function graphNodeMetadataCollectionOf(
  nodeMetadata: GraphWorkflowNodeMetadata,
  nodeDefinition: GraphNodeDefinitionShim | undefined
): Record<string, GraphJsonValue> | undefined {
  const collected: Record<string, GraphJsonValue> = {};
  for (const source of [
    nodeDefinition?.graph?.metadata,
    nodeMetadata.metadata as Record<string, unknown> | undefined,
  ]) {
    if (!source || typeof source !== "object") continue;
    for (const [key, value] of Object.entries(source)) {
      if (key === "loop") continue;
      reflectJsonSafeValue(collected, key, value);
    }
  }
  return Object.keys(collected).length ? collected : undefined;
}

function reflectJsonSafeValue(
  collected: Record<string, GraphJsonValue>,
  key: string,
  value: unknown
): void {
  if (value === undefined || typeof value === "function") return;
  if (!isGraphJsonSafeValue(value)) return;
  try {
    collected[key] = JSON.parse(JSON.stringify(value)) as GraphJsonValue;
  } catch {
    return;
  }
}

/**
 * Loop-configuration fields of legacy `graph.metadata.loop` bags that are
 * node configuration rather than loop-body settings. Per DECAF-50 §4.4.5
 * rule 6 (non-port operation/configuration fields belong in `parameters`),
 * these are carried into the instance's `parameters` so legacy loop
 * round-trips stay lossless; `body`/`maxIterations`/`timeoutMs`/
 * `concurrency` live on {@link GraphLoopConfiguration}.
 */
const GRAPH_LOOP_PARAMETER_KEYS = [
  "condition",
  "inputPort",
  "outputPort",
  "itemPort",
  "resultPort",
  "statePort",
  "slice",
] as const;

function graphNodeParametersOf(
  nodeMetadata: GraphWorkflowNodeMetadata,
  nodeDefinition: GraphNodeDefinitionShim | undefined
): Record<string, GraphJsonValue> {
  const parameters: Record<string, GraphJsonValue> = {};
  if (nodeDefinition) {
    for (const port of graphLeafPortsOf(nodeDefinition.ports)) {
      if (port.direction !== "input") continue;
      const defaultValue = graphPortDefaultValueOf(port);
      if (defaultValue !== undefined) {
        parameters[port.path ?? port.property] = defaultValue;
      }
    }
  }
  const loopMetadata = graphLegacyLoopMetadataOf(nodeMetadata, nodeDefinition);
  if (loopMetadata) {
    for (const key of GRAPH_LOOP_PARAMETER_KEYS) {
      const value = loopMetadata[key];
      if (value === undefined || typeof value === "function") continue;
      if (!isGraphJsonSafeValue(value)) continue;
      try {
        parameters[key] = JSON.parse(JSON.stringify(value)) as GraphJsonValue;
      } catch {
        // skip non-serializable legacy values
      }
    }
  }
  return parameters;
}

/**
 * Reads the legacy `graph.metadata.loop` bag from either the node metadata or
 * the node definition shim, so loop-configuration fields can be split between
 * {@link GraphLoopConfiguration} and instance `parameters` (§4.4.5 rule 6).
 */
function graphLegacyLoopMetadataOf(
  nodeMetadata: GraphWorkflowNodeMetadata,
  nodeDefinition: GraphNodeDefinitionShim | undefined
): Record<string, unknown> | undefined {
  const loopMetadata =
    ((nodeMetadata.metadata as Record<string, unknown> | undefined)?.["loop"] as
      | Record<string, unknown>
      | undefined) ??
    (nodeDefinition?.graph?.metadata as Record<string, unknown> | undefined)?.["loop"];
  return loopMetadata && typeof loopMetadata === "object"
    ? (loopMetadata as Record<string, unknown>)
    : undefined;
}

function graphLoopConfigurationOf(
  nodeMetadata: GraphWorkflowNodeMetadata,
  nodeDefinition: GraphNodeDefinitionShim | undefined
): GraphLoopConfiguration | undefined {
  const loopMetadata =
    ((nodeMetadata.metadata as Record<string, unknown> | undefined)?.["loop"] as
      | Record<string, unknown>
      | undefined) ??
    (nodeDefinition?.graph?.metadata as Record<string, unknown> | undefined)?.["loop"];
  if (!loopMetadata || typeof loopMetadata !== "object") return undefined;
  const config = loopMetadata as Record<string, unknown>;
  const bodySource = config["body"];
  if (!bodySource) return undefined;
  let body: GraphWorkflowDocument;
  try {
    body = graphDecoratedWorkflowCompiler(bodySource as GraphDecoratedWorkflowInput);
  } catch (e) {
    throw new ValidationError(
      `Failed to compile the loop body of node '${nodeMetadata.id}': ${String(e)}`
    );
  }
  return {
    body,
    maxIterations: asNumber(config["maxIterations"]),
    timeoutMs: asNumber(config["timeoutMs"]),
    concurrency: asNumber(config["concurrency"]),
  };
}

function asNumber(value: unknown): number | undefined {
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}

function graphDocumentMetadataOf(
  definition: GraphWorkflowDefinition
): Record<string, GraphJsonValue> | undefined {
  if (!definition.workflow?.metadata) return undefined;
  const collected: Record<string, GraphJsonValue> = {};
  for (const [key, value] of Object.entries(definition.workflow.metadata)) {
    reflectJsonSafeValue(collected, key, value);
  }
  return Object.keys(collected).length ? collected : undefined;
}

function graphEdgeOf(
  relation: GraphWorkflowRelationMetadata,
  workflow: GraphWorkflowDefinition,
  index: number
): GraphEdgeInstance {
  const aliases = [...GRAPH_BOUNDARY_ALIASES, workflow.name];
  const source = graphEndpointOf(relation.source, relation.sourcePort, workflow.nodes, aliases);
  const target = graphEndpointOf(relation.target, relation.targetPort, workflow.nodes, aliases);
  const metadata = graphJsonSafeRecordOf(relation.metadata);
  const edge: GraphEdgeInstance = {
    id: `re${index}`,
    type: graphEdgeTypeOf(relation, workflow),
    source,
    target,
  };
  if (relation.label !== undefined) edge.label = relation.label;
  if (metadata) edge.metadata = metadata;
  return edge;
}

function graphEdgeTypeOf(
  relation: GraphWorkflowRelationMetadata,
  workflow: GraphWorkflowDefinition
): "data" | "connection" {
  const sourceDefinition = graphNodeDefinitionFor(relation.source, workflow);
  const targetDefinition = graphNodeDefinitionFor(relation.target, workflow);
  if (isConnectionPort(sourceDefinition, relation.sourcePort)) return "connection";
  if (isConnectionPort(targetDefinition, relation.targetPort)) return "connection";
  return "data";
}

function isConnectionPort(
  nodeDefinition: GraphNodeDefinitionShim | undefined,
  port: string | undefined
): boolean {
  if (!nodeDefinition || !port) return false;
  return nodeDefinition.ports.some(
    (candidate) =>
      (candidate.path ?? candidate.property) === port && candidate.direction === "connection"
  );
}

function graphNodeDefinitionFor(
  reference: unknown,
  workflow: GraphWorkflowDefinition
): GraphNodeDefinitionShim | undefined {
  const matched = findGraphNodeMatch(
    reference,
    workflow.name ? [...GRAPH_BOUNDARY_ALIASES, workflow.name] : GRAPH_BOUNDARY_ALIASES,
    workflow.nodes
  );
  if (!matched || !isGraphModelLike(matched.node)) return undefined;
  return graphNodeDefinitionSafely(matched.node);
}

function graphJsonSafeRecordOf(
  value:
    | Record<string, unknown>
    | undefined
): Record<string, GraphJsonValue> | undefined {
  if (!value || typeof value !== "object") return undefined;
  const collected: Record<string, GraphJsonValue> = {};
  for (const [key, entry] of Object.entries(value)) {
    reflectJsonSafeValue(collected, key, entry);
  }
  return Object.keys(collected).length ? collected : undefined;
}

function graphEndpointOf(
  value: unknown,
  port: string | undefined,
  nodes: GraphWorkflowNodeMetadata[],
  aliases: string[]
): GraphEndpoint {
  const resolvedPort = port ?? "";
  if (typeof value === "string" && aliases.includes(value)) {
    return { scope: "workflow", port: resolvedPort } satisfies GraphWorkflowEndpoint;
  }
  const matched = findGraphNodeMatch(value, aliases, nodes);
  if (matched) {
    const endpoint: GraphNodeEndpoint = {
      scope: "node",
      nodeId: matched.id,
      port: resolvedPort,
    };
    if (!resolvedPort) {
      throw new ValidationError(
        `Workflow relation references node '${matched.id}' without a port identifier`
      );
    }
    return endpoint;
  }
  throw new ValidationError(
    `Workflow relation references '${String(
      value
    )}' which is neither a node in the workflow nor a workflow boundary (used port identifier: '${resolvedPort}')`
  );
}

function findGraphNodeMatch(
  value: unknown,
  aliases: string[],
  nodes: GraphWorkflowNodeMetadata[]
): GraphWorkflowNodeMetadata | undefined {
  const valueDefinition = isGraphModelLike(value) ? graphNodeDefinitionSafely(value) : undefined;
  for (const metadata of nodes) {
    if (metadata.id === value) return metadata;
    if (metadata.node === value) return metadata;
    if (!valueDefinition || !isGraphModelLike(metadata.node)) continue;
    const nodeDefinition = graphNodeDefinitionSafely(metadata.node);
    if (
      nodeDefinition &&
      (nodeDefinition.name === valueDefinition.name || nodeDefinition.tag === valueDefinition.tag)
    ) {
      return metadata;
    }
  }
  if (typeof value === "string" && aliases.includes(value)) return undefined;
  return undefined;
}

function isGraphModelLike(value: unknown): boolean {
  return typeof value === "function" || (typeof value === "object" && value !== null);
}

function graphNodeDefinitionSafely(node: unknown): GraphNodeDefinitionShim | undefined {
  try {
    return graphWorkflowDefinitionOf(node as never) as unknown as GraphNodeDefinitionShim;
  } catch {
    return undefined;
  }
}

/**
 * Spec-mandated (§4.4.9) compiler facade. Delegates to
 * {@link graphDecoratedWorkflowCompiler}, which performs the actual
 * decorated-definition to {@link GraphWorkflowDocument} mapping.
 */
export class GraphDecoratedWorkflowCompiler {
  compile(
    workflow: GraphDecoratedWorkflowInput,
    options: GraphDecoratedWorkflowCompileOptions = {}
  ): GraphWorkflowDocument {
    return graphDecoratedWorkflowCompiler(workflow, options);
  }
}

export type { GraphWorkflowEndpoint, GraphNodeEndpoint };