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.
This commit is contained in:
348
docs/architecture/host-configs.md
Normal file
348
docs/architecture/host-configs.md
Normal file
@@ -0,0 +1,348 @@
|
||||
---
|
||||
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<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
|
||||
|
||||
```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.
|
||||
|
||||
## GraphologyHostConfig
|
||||
|
||||
### Type Parameters
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```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 <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
|
||||
}
|
||||
```
|
||||
|
||||
### 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<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
|
||||
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 `computed` that derives `prerequisites` from parent nodes' statuses
|
||||
- An optional `output` signal that holds the call result when completed
|
||||
|
||||
### ReactiveContext
|
||||
|
||||
```typescript
|
||||
interface ReactiveContext {
|
||||
operationRegistry: OperationRegistry;
|
||||
nodes: Map<string, WorkflowNode>; // All nodes by key
|
||||
statusSignals: Map<string, Signal<NodeStatus>>; // Status signals by key
|
||||
}
|
||||
```
|
||||
|
||||
### 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,
|
||||
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
|
||||
|
||||
```typescript
|
||||
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`:
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```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 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 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 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](workflow-templates.md)
|
||||
- Reactive execution: [reactive-execution.md](reactive-execution.md)
|
||||
- Schema: [schema.md](schema.md)
|
||||
Reference in New Issue
Block a user