Files
flowgraph/docs/architecture/host-configs.md
glm-5.1 d2253099ee add flowgraph architecture docs (Phase 1 SDD)
Draft architecture specification for @alkdev/flowgraph — a workflow graph library providing DAG-based orchestration over operations. Covers two graph types (operation graph, call graph), ujsx workflow templates, GraphologyHost and ReactiveHost configs, signal-driven execution, type-compatibility analysis, error hierarchy, and build/distribution. Includes 3 ADRs: ujsx as template IR, DAG-only enforcement, decoupled storage.
2026-05-19 09:36:22 +00:00

15 KiB

status, last_updated
status last_updated
draft 2026-05-19

Host Configs

The two HostConfig implementations that render workflow templates to different targets: graphology DAG (structural analysis) and reactive execution engine (runtime status tracking).

Overview

Flowgraph uses ujsx's HostConfig pattern to render the same workflow template (UNode tree) to different targets. Each HostConfig implements the HostConfig<WorkflowTag, Instance, RootCtx> interface:

HostConfig Target Purpose
GraphologyHostConfig DirectedGraph Validate templates, check cycles, compute topological order
ReactiveHostConfig Map<string, WorkflowNode> Runtime execution with signal-driven status propagation

Both HostConfigs share the same template components (Operation, Sequential, Parallel, Conditional) and the same tag type. The difference is what createInstance and appendChild do:

  • GraphologyHostConfig: Creates graph nodes and edges. appendChild adds an edge.
  • ReactiveHostConfig: Creates a WorkflowNode (with a signal<NodeStatus>) and registers preconditions. appendChild registers the parent-child dependency.

WorkflowTag Type

type WorkflowTag = "operation" | "sequential" | "parallel" | "conditional" | "map";

This constrains HostConfig<TTag, ...> to only accept workflow-specific element types. Attempting to render an unsupported tag (e.g., "div") is a type error at compile time.

GraphologyHostConfig

Type Parameters

const graphologyHost: HostConfig<WorkflowTag, Graph, GraphContext>
  • TTag: WorkflowTag
  • Instance: Graph (the graphology DirectedGraph instance — every element creates a subgraph reference)
  • RootCtx: GraphContext (the root context carrying the graph and metadata)

Wait — this needs refinement. In graphology, instances aren't subgraphs. Let me reconsider.

Actually, the GraphologyHostConfig's Instance type is a logical representation of what each template node becomes:

interface GraphNode {
  key: string;           // The graphology node key
  attributes: OperationNodeAttrs | TemplateNodeAttrs;
}

The RootCtx is:

interface GraphContext {
  graph: DirectedGraph;   // The graphology DAG being built
  parentStack: string[];   // Stack of parent node keys for edge creation
  operationRegistry?: OperationRegistry;  // Optional, for name resolution
}

createRootContext

createRootContext(container, options, context): GraphContext {
  const graph = new DirectedGraph({ type: "directed", multi: false, allowSelfLoops: false });
  return { graph, parentStack: [], operationRegistry: options?.registry };
}

Creates a fresh DirectedGraph with DAG constraints (no self-loops, no parallel edges). The container parameter is unused — the graph IS the container.

createInstance

createInstance(tag: WorkflowTag, props, ctx: GraphContext, parent?: GraphNode): GraphNode {
  switch (tag) {
    case "operation": {
      const key = props.name as string;
      ctx.graph.addNode(key, { ...operationAttrs, name: key });
      return { key, attributes };
    }
    case "sequential":
    case "parallel":
    case "conditional":
    case "map":
      // Structural containers — no node in the graph, just manage parentStack
      return { key: `__${tag}_${counter++}`, attributes: {} };
  }
}

Operation elements create real graph nodes. Structural containers (Sequential, Parallel, Conditional, Map) do NOT create graph nodes — they manage the parentStack to influence edge creation for their children.

This is a key design decision: structural containers are transparent in the graph. A Sequential node doesn't appear as a node in the DAG. It only affects the edges between its children.

appendChild

appendChild(parent: GraphNode, child: GraphNode, ctx: GraphContext): void {
  // Only add edges between real nodes (not structural containers)
  if (!isStructuralContainer(parent) && !isStructuralContainer(child)) {
    const edgeType = inferEdgeType(ctx, parent.key, child.key);
    ctx.graph.addEdgeWithKey(
      `${parent.key}->${child.key}`,
      parent.key,
      child.key,
      { edgeType, compatible: true }
    );
  }
}

Edge creation depends on the context:

  • Children of a Sequential container: sequential edges between consecutive siblings
  • Children of a Parallel container: no edges between siblings
  • Children of a Conditional container: conditional edge to the test branch

How Sequential edges are created

The Sequential component doesn't create edges itself. Instead, the HostConfig tracks the parentStack and creates edges between consecutive siblings:

// In the rendering of <Sequential>
// After child1 is appended: parentStack = [child1.key]
// After child2 is appended: edge child1→child2 is created, parentStack = [child2.key]
// After child3 is appended: edge child2→child3 is created, parentStack = [child3.key]

The parentStack is managed by the Sequential component's finalizeInstance hook — it pops the last child after rendering all children, replacing it with the overall group's last child.

How Parallel handles edges

The Parallel component renders all children without creating inter-child edges. It pushes a "parallel group" marker onto the parentStack so that the group's successors connect to ALL parallel children, not just the last one.

This requires the HostConfig to understand parent-child relationships for Parallel groups: the group's successors should connect to each parallel child.

finalizeInstance

finalizeInstance?(instance: GraphNode, ctx: GraphContext): void {
  // Pop the structural container from the parentStack after all children are rendered
  // This is important for Sequential and Parallel to clean up their structural state
}

Cycle Detection

After rendering, the HostConfig checks for cycles using graphology-dag.hasCycle(). If a cycle is detected, the rendering throws CircularDependencyError with the cycle paths.

This is the primary validation step: a valid workflow template must produce a valid DAG. Cycles in a template mean infinite loops in execution, which are always design errors.

ReactiveHostConfig

Type Parameters

const reactiveHost: HostConfig<WorkflowTag, WorkflowNode, ReactiveContext>
  • TTag: WorkflowTag
  • Instance: WorkflowNode (carries a signal<NodeStatus> and computed preconditions)
  • RootCtx: ReactiveContext (carries the operation registry and status tracking)

WorkflowNode

interface WorkflowNode {
  key: string;                          // Operation name or structural container ID
  type: "operation" | "sequential" | "parallel" | "conditional" | "map";
  status: Signal<NodeStatus>;           // Reactive status signal
  prerequisites: Computed<boolean>;     // Computed: true when all prerequisites are met
  operationId?: string;                 // For operation nodes: the fully qualified ID
  output?: Signal<unknown>;             // For operation nodes: the call result (when completed)
  children: WorkflowNode[];             // Child nodes (structural containers have children)
}

Each WorkflowNode holds:

  • A signal<NodeStatus> that tracks the call's lifecycle (idlewaitingreadyrunningcompleted/failed/aborted/skipped)
  • A computed that derives prerequisites from parent nodes' statuses
  • An optional output signal that holds the call result when completed

ReactiveContext

interface ReactiveContext {
  operationRegistry: OperationRegistry;
  nodes: Map<string, WorkflowNode>;       // All nodes by key
  statusSignals: Map<string, Signal<NodeStatus>>;  // Status signals by key
}

createInstance

createInstance(tag: WorkflowTag, props, ctx: ReactiveContext, parent?: WorkflowNode): WorkflowNode {
  const key = props.key ?? generateKey();
  const status = signal<NodeStatus>("idle");
  const node: WorkflowNode = {
    key,
    type: tag,
    status,
    prerequisites: computed(() => computePrerequisites(node, ctx)),
    children: [],
  };

  ctx.nodes.set(key, node);
  ctx.statusSignals.set(key, status);

  if (tag === "operation") {
    node.operationId = props.name as string;
    node.output = signal<unknown>(undefined);
  }

  return node;
}

Prerequisite Computation

The prerequisites computed signal for each node derives from its structural context:

  • Sequential child: prerequisites = previous sibling is completed
  • Parallel child: prerequisites = parent's prerequisites are met
  • Conditional child: prerequisites = parent's prerequisites are met AND condition evaluates to true
function computePrerequisites(node: WorkflowNode, ctx: ReactiveContext): boolean {
  // Sequential: previous sibling must be completed
  // Parallel: parent must be ready
  // Conditional: condition must evaluate to true
  const predecessorKeys = getPredecessorKeys(node, ctx);
  return predecessorKeys.every(key => {
    const status = ctx.statusSignals.get(key)?.value;
    return status === "completed" || status === "skipped";
  });
}

Status Propagation

When a node's status signal changes, its dependents' prerequisites computed automatically re-evaluate. If prerequisites are met, the node transitions to ready:

effect(() => {
  if (node.prerequisites.value) {
    node.status.value = "ready";
  }
});

The reactive engine then starts the call associated with the node, which sets status to running, and eventually completed or failed.

Abort Cascading

When a node is aborted, all its descendants are also aborted:

function cascadeAbort(node: WorkflowNode): void {
  if (node.status.value === "running" || node.status.value === "ready" || node.status.value === "waiting") {
    node.status.value = "aborted";
  }
  for (const child of node.children) {
    cascadeAbort(child);
  }
}

This is reactive — when a parent node's status changes to aborted, the effect on each child evaluates and cascades the abort.

Two HostConfigs, One Template

The key insight: the same ujsx template renders to both targets:

const template = h(Sequential, {},
  h(Operation, { name: "architect" }),
  h(Operation, { name: "reviewer" }),
);

// Validate structure
const dagRoot = createRoot(graphologyHost, new DirectedGraph());
dagRoot.render(template);
dagRoot.ctx.graph.hasCycles();  // → false (valid DAG)

// Execute reactively
const reactiveRoot = createRoot(reactiveHost, { registry });
reactiveRoot.render(template);
// Each operation node now has a signal<NodeStatus>

No template-specific logic is needed in either HostConfig. The same UNode tree, the same components, the same rendering pipeline — just different createInstance/appendChild implementations.

Known Gaps

ujsx Reconciler Not Yet Available

The current ujsx HostConfig is mount-only (see host-configs.md). The reconciler research (see reconciler.md) has not been implemented yet. This means:

  • render() can only be called once per root
  • No incremental template updates
  • No prepareUpdate/commitUpdate flow

For flowgraph, this is acceptable in v1 because:

  • Template rendering is typically done once at startup
  • Runtime status updates flow through signals, not through template re-rendering
  • When the reconciler is implemented, flowgraph gains incremental template updates "for free"

Structural Container Handling

The current design where Sequential, Parallel, and Conditional don't create graph nodes is clean for the DAG, but creates complexity for the reactive engine — the "previous sibling" precondition depends on understanding the structural context, which isn't stored on the node itself.

Alternative: Create "virtual" nodes for structural containers that hold signal<NodeStatus> but don't correspond to graph nodes. This makes the reactive engine simpler (every node has a status and prerequisites) at the cost of a slightly larger node tree.

Conditional Test Evaluation

The Conditional.test prop can be a function or a string. At the template level, it's stored as a prop. At runtime, the reactive engine evaluates it as a computed that depends on referenced nodes' outputs. This evaluation needs access to the WorkflowContext (which holds the results of previous steps), which means the reactive engine must have a reference to the call graph or a results map.

Constraints

  • Both HostConfigs share the same WorkflowTag type — element types that workflow templates use. Non-workflow tags ("div", "span", etc.) are type errors.
  • GraphologyHostConfig produces a static DAG — the rendered DAG is immutable after rendering. No re-rendering until the reconciler is available.
  • ReactiveHostConfig requires an operation registryOperation nodes reference operations by name, and the registry resolves them at render time.
  • Template rendering is one-shot — until the reconciler is implemented, createRoot(host, container).render(template) can only be called once per root.
  • Structural containers are transparent in the DAG — Sequential, Parallel, Conditional create edges between children, not nodes for themselves.
  • HostConfigs must follow ujsx's post-order append contract — children are appended to parents after all descendants are created. This guarantees that edges are created bottom-up.

Open Questions

  1. Should structural containers create "virtual" nodes in the reactive engine? This would simplify prerequisite computation (every node has a status) but adds nodes that don't correspond to calls or operations.

  2. Should the GraphologyHostConfig produce a separate graph for edge types? Currently all edge types (sequential, conditional, typed) share the same graph. An alternative is a separate graph per edge type, enabling type-specific queries without filtering.

  3. How does the ReactiveHostConfig interact with the call protocol? When a node transitions to ready, the reactive engine needs to call registry.execute() or PendingRequestMap.call(). This bridges the reactive layer to the operation execution layer. The HostConfig's createInstance callback is one option; a separate ExecutionEngine class is another.

  4. Should the reactive engine own the call graph? Currently the call graph (from call-graph.md) and the reactive engine (from this doc) are separate concepts. But at runtime, every <Operation> in a template becomes a call graph node. Should the reactive engine populate the call graph as a side effect?

References

  • ujsx HostConfig: @alkdev/ujsx/docs/architecture/host-config.md
  • ujsx reconciler research: @alkdev/ujsx/docs/research/reconciler/05-flowgraph-host-configs.md
  • Workflow templates: workflow-templates.md
  • Reactive execution: reactive-execution.md
  • Schema: schema.md