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.
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.
appendChildadds an edge. - ReactiveHostConfig: Creates a
WorkflowNode(with asignal<NodeStatus>) and registers preconditions.appendChildregisters 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 graphologyDirectedGraphinstance — 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
Sequentialcontainer: sequential edges between consecutive siblings - Children of a
Parallelcontainer: no edges between siblings - Children of a
Conditionalcontainer: 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 asignal<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 (idle→waiting→ready→running→completed/failed/aborted/skipped) - A
computedthat derivesprerequisitesfrom parent nodes' statuses - An optional
outputsignal 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/commitUpdateflow
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
WorkflowTagtype — 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 —
Operationnodes 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
-
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.
-
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. -
How does the ReactiveHostConfig interact with the call protocol? When a node transitions to
ready, the reactive engine needs to callregistry.execute()orPendingRequestMap.call(). This bridges the reactive layer to the operation execution layer. The HostConfig'screateInstancecallback is one option; a separateExecutionEngineclass is another. -
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