--- status: draft last_updated: 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` interface: | HostConfig | Target | Purpose | |------------|--------|---------| | GraphologyHostConfig | `DirectedGraph` | Validate templates, check cycles, compute topological order | | ReactiveHostConfig | `Map` | 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`) and registers preconditions. `appendChild` registers the parent-child dependency. ## WorkflowTag Type ```typescript type WorkflowTag = "operation" | "sequential" | "parallel" | "conditional" | "map"; ``` This constrains `HostConfig` 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 ```typescript const graphologyHost: HostConfig ``` - **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: ```typescript interface GraphNode { key: string; // The graphology node key attributes: OperationNodeAttrs | TemplateNodeAttrs; } ``` The `RootCtx` is: ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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: ```typescript // In the rendering of // 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 ```typescript 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 ```typescript const reactiveHost: HostConfig ``` - **TTag**: `WorkflowTag` - **Instance**: `WorkflowNode` (carries a `signal` and computed preconditions) - **RootCtx**: `ReactiveContext` (carries the operation registry and status tracking) ### WorkflowNode ```typescript interface WorkflowNode { key: string; // Operation name or structural container ID type: "operation" | "sequential" | "parallel" | "conditional" | "map"; status: Signal; // Reactive status signal preconditions: Computed; // Computed: true when all preconditions are met blockedByFailure: Computed; // Computed: true when any predecessor failed/aborted (uncaught) operationId?: string; // For operation nodes: the fully qualified ID output?: Signal; // For operation nodes: the call result (when completed) children: WorkflowNode[]; // Child nodes (structural containers have children) } ``` Each `WorkflowNode` holds: - A `signal` that tracks the call's lifecycle (`idle` → `waiting` → `ready` → `running` → `completed`/`failed`/`aborted`/`skipped`) - A `computed` that derives `preconditions` from parent nodes' statuses (true when all predecessors are `completed` or `skipped`) - A `computed` that derives `blockedByFailure` from parent nodes' statuses (true when any predecessor is `failed` or `aborted`) - An optional `output` signal that holds the call result when completed ### ReactiveContext ```typescript interface ReactiveContext { operationRegistry: OperationRegistry; nodes: Map; // All nodes by key statusSignals: Map>; // Status signals by key } ``` ### createInstance ```typescript createInstance(tag: WorkflowTag, props, ctx: ReactiveContext, parent?: WorkflowNode): WorkflowNode { const key = props.key ?? generateKey(); const status = signal("idle"); const node: WorkflowNode = { key, type: tag, status, preconditions: computed(() => computePreconditions(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(undefined); } return node; } ``` ### Prerequisite Computation The `preconditions` computed signal for each node derives from its structural context: - **Sequential child**: preconditions = previous sibling is `completed` - **Parallel child**: preconditions = parent's preconditions are met - **Conditional child**: preconditions = parent's preconditions are met AND condition evaluates to true ```typescript function computePreconditions(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' `preconditions` and `blockedByFailure` computed values automatically re-evaluate. If preconditions are met, the node transitions to `ready`; if blocked by failure, it transitions to `aborted`: ```typescript // Start when preconditions are met effect(() => { if (node.preconditions.value) { if (node.status.value === "idle" || node.status.value === "waiting") { node.status.value = "ready"; } } }); // Abort when a predecessor fails (uncaught failure propagation) effect(() => { if (node.blockedByFailure.value) { if (node.status.value === "idle" || node.status.value === "waiting") { node.status.value = "aborted"; } } }); ``` The reactive engine then starts the call associated with the node (when `ready`), which sets `status` to `running`, and eventually `completed` or `failed`. **Note**: Failure propagation follows dependency edges, not structural scope. A failed node only causes its downstream dependents (via DAG edges) to abort. Sibling branches in a `Parallel` group are independent and continue running. See [reactive-execution.md](reactive-execution.md) for the full failure propagation model. ### Abort Cascading System-level abort (e.g., provider outage) aborts the entire workflow: ```typescript function abortAll(root: WorkflowReactiveRoot): void { for (const [nodeId, status] of root.statusMap) { if (status.value !== "completed" && status.value !== "failed") { status.value = "aborted"; } } } ``` 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: ```typescript 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 ``` 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](../../../ujsx/docs/architecture/host-config.md)). The reconciler research (see [reconciler.md](../../../ujsx/docs/architecture/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` but don't correspond to graph nodes. This makes the reactive engine simpler (every node has a status and preconditions) 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 registry** — `Operation` 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 precondition 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 `` 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](workflow-templates.md) - Reactive execution: [reactive-execution.md](reactive-execution.md) - Schema: [schema.md](schema.md)