Files
flowgraph/docs/architecture/host-configs.md
glm-5.1 da2973e2a6 fix build/distribution spec: npm deps not workspace, align configs with sibling projects, resolve review issues
- Replace workspace:* deps with published npm semver ranges (^0.34.49, ^0.1.0)
- Expand package.json: add description, publishConfig, scripts, engines,
  devDependencies, conditional exports with types/default for import+require
- Fix tsup entry names (path-prefixed like ujsx), add target: es2022,
  remove splitting:true (not used by sibling projects)
- Align tsconfig with sibling projects: add lib, noUncheckedIndexedAccess,
  noUnusedLocals, noUnusedParameters, erasableSyntaxOnly, etc.
- Expand vitest.config.ts with include, coverage, and path alias
- Clarify @preact/signals-core as direct dep (not just transitive via ujsx)
- Clarify @alkdev/pubsub is a consumer dependency, not flowgraph's dep
- Fix edge key convention: document composite key format for call graph's
  multi-edge-type scenario (triggered + depends_on between same pair)
- Align OperationEdgeAttrs field naming: use detail+mismatches consistently
  instead of compatibilityDetail
- Add InvalidInputError to error hierarchy (referenced in flowgraph-api but
  was missing)
- Fix undefined attrs.category reference in reactive-execution.md
- Remove internal drafting note from host-configs.md
- Fix ReactiveHostConfig constructor signature inconsistency across docs
- Constrain TemplateEdgeAttrs.edgeType to sequential|conditional only
2026-05-20 03:09:57 +00:00

465 lines
22 KiB
Markdown

---
status: draft
last_updated: 2026-05-20
---
# 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`, `Map`) 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
```typescript
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.
### Component-to-Tag Mapping
Each `UComponent` function produces a `UElement` with a specific `type` string (the `WorkflowTag`). The mapping is:
| Component function | UElement.type (WorkflowTag) |
|-------------------|---------------------------|
| `Operation` | `"operation"` |
| `Sequential` | `"sequential"` |
| `Parallel` | `"parallel"` |
| `Conditional` | `"conditional"` |
| `Map` | `"map"` |
When ujsx's reconciler calls `HostConfig.createInstance(tag, props, ...)`, the `tag` parameter is the `WorkflowTag` string. For example, `h(Operation, { name: "classify" })` produces `{ type: "operation", props: { name: "classify" }, children: [] }`, and `createInstance("operation", { name: "classify" }, ctx)` is called.
## GraphologyHostConfig
### Type Parameters
```typescript
const graphologyHost: HostConfig<WorkflowTag, GraphNode, GraphContext>
```
- **TTag**: `WorkflowTag`
- **Instance**: `GraphNode` (a logical representation of what each template node becomes in the graph)
- **RootCtx**: `GraphContext` (the root context carrying the graph and metadata)
```typescript
interface GraphNode {
key: string; // The graphology node key
attributes: OperationNodeAttrs | TemplateNodeAttrs;
}
```
Where `TemplateNodeAttrs` is a type alias for `OperationNodeAttrs` (see [schema.md](schema.md#TemplateNodeAttrs)) — template nodes carry the same attributes as operation nodes. Structural containers (`Sequential`, `Parallel`, `Conditional`, `Map`) return a `GraphNode` with an empty `attributes` object and a synthetic key.
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 <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
```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
}
```
### removeChild
```typescript
removeChild(parent: GraphNode, child: GraphNode, ctx: GraphContext): void {
// Remove the edge between parent and child
// Structural containers are transparent, so parent/child are real operation nodes
if (!isStructuralContainer(parent) && !isStructuralContainer(child)) {
ctx.graph.dropEdge(`${parent.key}->${child.key}`);
}
}
```
`removeChild` is called by the ujsx reconciler when a child is removed from a parent. In the `GraphologyHostConfig`, this removes the corresponding DAG edge. The child node itself is NOT removed from the graph — node removal is handled by `removeFromGraph` (see below).
**Note**: The ujsx reconciler is not yet implemented. Currently, `removeChild` is defined but only called in tests. The `GraphologyHostConfig` is mount-only until the reconciler is available.
### removeChildFromHost (node removal)
```typescript
removeChildFromHost?(parent: GraphNode, child: GraphNode, ctx: GraphContext): void {
// Remove the child node from the graph
ctx.graph.dropNode(child.key);
}
```
When the reconciler removes a child entirely (not just moving it to a different parent), it calls `removeChildFromHost`. This removes the node and ALL attached edges (graphology cascading removal). This is important for cleanup when a template is re-rendered and a node no longer exists.
### Cycle Detection
After rendering, the HostConfig checks for cycles using `graphology-dag.hasCycle()`. If a cycle is detected, the rendering throws `CycleError` 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<WorkflowTag, WorkflowNode, ReactiveContext>
```
- **TTag**: `WorkflowTag`
- **Instance**: `WorkflowNode` (carries a `signal<NodeStatus>` 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<NodeStatus>; // Reactive status signal
preconditions: Computed<boolean>; // Computed: true when all preconditions are met
blockedByFailure: Computed<boolean>; // Computed: true when any predecessor failed/aborted (uncaught)
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 `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; // Resolves operation names to specs
nodes: Map<string, WorkflowNode>; // All nodes by key
statusSignals: Map<string, Signal<NodeStatus>>; // Status signals by key (owned by WorkflowReactiveRoot)
preconditions: Map<string, Computed<boolean>>; // Precondition computeds by key (owned by WorkflowReactiveRoot)
blockedByFailure: Map<string, Computed<boolean>>; // blockedByFailure computeds by key (owned by WorkflowReactiveRoot)
parentMap: Map<string, string>; // Child → parent key mapping (for precondition computation)
siblingMap: Map<string, string[]>; // Parent → children keys (for structural context)
results: Map<string, Signal<unknown>>; // Operation output signals by key
}
```
The `ReactiveContext` is constructed during `ReactiveHostConfig` initialization. It receives the `operationRegistry` and empty maps. During `createInstance`, nodes and signals are registered in the context maps. After rendering completes, the context holds a complete index of the reactive workflow tree.
**Important**: `statusSignals`, `preconditions`, and `blockedByFailure` are references to the `WorkflowReactiveRoot`'s maps. The `ReactiveHostConfig` does not own these signals — it looks them up during `createInstance` to wire `WorkflowNode` references. Disposal is the `WorkflowReactiveRoot`'s responsibility.
### createInstance
```typescript
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,
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<unknown>(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.
### removeChild (ReactiveHostConfig)
```typescript
removeChild(parent: WorkflowNode, child: WorkflowNode, ctx: ReactiveContext): void {
// Remove the dependency between parent and child
// The child's preconditions are recomputed automatically (reactive)
parent.children = parent.children.filter(c => c.key !== child.key);
// The child's preconditions and blockedByFailure computeds will re-evaluate
// because the predecessor list changes
}
```
`removeChild` in the reactive host removes the parent-child dependency. Because preconditions and `blockedByFailure` are `computed` values, they automatically re-evaluate when predecessor nodes are removed.
```typescript
removeChildFromHost?(parent: WorkflowNode, child: WorkflowNode, ctx: ReactiveContext): void {
// Dispose the child's reactive state
ctx.nodes.delete(child.key);
ctx.statusSignals.delete(child.key);
ctx.preconditions.delete(child.key);
ctx.blockedByFailure.delete(child.key);
if (child.output) {
// Signal disposal is handled by WorkflowReactiveRoot.dispose()
// Here we just remove the reference from the context maps
}
}
```
For complete reactive teardown (`removeChildFromHost`), the node's signal references are removed from the context maps. The signals themselves (owned by `WorkflowReactiveRoot`) are disposed via `root.dispose()` which is the authoritative cleanup path.
**Important**: Individual node disposal (removing a node mid-execution) is not fully supported until the ujsx reconciler is implemented. Currently, the reactive tree is built once and torn down as a whole via `WorkflowReactiveRoot.dispose()`.
### 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:
```
ujsx Template (UNode tree)
┌─────────┴─────────┐
│ │
GraphologyHostConfig ReactiveHostConfig
│ │
▼ ▼
DirectedGraph (DAG) Reactive Signal Graph
┌──────────────────┐ ┌──────────────────┐
│ Nodes: operations │ │ Nodes: WorkflowNode│
│ Edges: sequential │ │ signal<NodeStatus>│
│ conditional │ │ computed<precond> │
│ typed │ │ computed<blocked> │
└──────────────────┘ └──────────────────┘
│ │
▼ ▼
Structural Runtime Execution
Analysis & Status Tracking &
Validation Abort Propagation
```
```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<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](../../../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<NodeStatus>` 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 `<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](workflow-templates.md)
- Reactive execution: [reactive-execution.md](reactive-execution.md)
- Schema: [schema.md](schema.md)