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:
288
docs/architecture/workflow-templates.md
Normal file
288
docs/architecture/workflow-templates.md
Normal file
@@ -0,0 +1,288 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-05-19
|
||||
---
|
||||
|
||||
# Workflow Templates
|
||||
|
||||
ujsx-based workflow definition — compose operations as declarative template trees, render them to DAGs or reactive execution engines.
|
||||
|
||||
## Overview
|
||||
|
||||
Workflow templates are ujsx trees that define reusable call patterns. Instead of hardcoding operation sequences in the hub coordinator, templates provide a declarative, composable way to define "what should happen in what order":
|
||||
|
||||
```typescript
|
||||
import { h, createRoot } from "@alkdev/ujsx";
|
||||
import { Operation, Sequential, Parallel, Conditional } from "@alkdev/flowgraph/component";
|
||||
import { GraphologyHostConfig } from "@alkdev/flowgraph/host/graphology";
|
||||
|
||||
const sddPipeline = h(Sequential, {},
|
||||
h(Operation, { name: "architect" }),
|
||||
h(Operation, { name: "architecture-reviewer" }),
|
||||
h(Conditional, {
|
||||
test: (results) => results["architecture-reviewer"].approved
|
||||
},
|
||||
h(Sequential, {},
|
||||
h(Operation, { name: "decomposer" }),
|
||||
h(Operation, { name: "coordinator" }),
|
||||
h(Operation, { name: "specialist" }),
|
||||
),
|
||||
),
|
||||
h(Operation, { name: "code-reviewer" }),
|
||||
);
|
||||
```
|
||||
|
||||
The template is a `UNode` tree — a plain data structure that can be:
|
||||
- **Serialized** to JSON for storage and transmission
|
||||
- **Validated** against the operation graph (are all referenced operations registered? are there type mismatches?)
|
||||
- **Rendered to a graphology DAG** via the `GraphologyHostConfig` for structural analysis
|
||||
- **Rendered to a reactive execution engine** via the `ReactiveHostConfig` for runtime status tracking
|
||||
|
||||
This is the same `UNode` tree that ujsx defines, with flowgraph-specific component functions (`Operation`, `Sequential`, `Parallel`, `Conditional`) that produce `UElement` nodes with workflow-specific props and meaning.
|
||||
|
||||
## Why ujsx as Template IR
|
||||
|
||||
The alternative to ujsx would be a custom template format — an array of step objects with type discriminators:
|
||||
|
||||
```typescript
|
||||
// Alternative: custom template format
|
||||
const template = [
|
||||
{ type: "operation", name: "architect" },
|
||||
{ type: "sequential", steps: [
|
||||
{ type: "operation", name: "decomposer" },
|
||||
{ type: "operation", name: "coordinator" },
|
||||
]},
|
||||
];
|
||||
```
|
||||
|
||||
ujsx is better for several reasons:
|
||||
|
||||
1. **Composability** — Nested elements are the natural representation of hierarchical workflows. `Sequential({ children: [...] })` is cleaner than a recursive type discriminator.
|
||||
|
||||
2. **No new format** — ujsx already defines the tree structure, type guards, reactive layer, and reconciler. We don't need to design, implement, and maintain a template parser/serializer.
|
||||
|
||||
3. **Host target switching** — The `HostConfig` pattern means the same template renders to different targets without template-specific logic. Graphology for analysis, reactive engine for runtime. No template→IR→DAG compilation step.
|
||||
|
||||
4. **Incremental updates** — The ujsx reconciler enables incremental template updates. Add a step, remove a step, reorder steps — the reconciler computes the diff and applies minimal mutations to the DAG, rather than rebuilding the entire graph.
|
||||
|
||||
5. **Reactive props** — `@preact/signals-core` enables signal-driven prop updates. An `Operation` node's `name` could be a signal, enabling dynamic workflow modification at runtime.
|
||||
|
||||
See [ADR-001](decisions/001-ujsx-as-template-ir.md) for the full decision record.
|
||||
|
||||
## Component Definitions
|
||||
|
||||
### `<Operation>`
|
||||
|
||||
Represents a single operation invocation in the workflow:
|
||||
|
||||
```typescript
|
||||
const Operation: UComponent<{
|
||||
name: string; // Operation name (namespace.name or just name if namespace is inherited)
|
||||
input?: unknown; // Static input for the call
|
||||
retries?: number; // Number of retries on failure (default: 0)
|
||||
timeout?: number; // Deadline in ms from call start
|
||||
}>;
|
||||
```
|
||||
|
||||
`Operation` produces a `UElement` with `type: "operation"` and the given props. When rendered to a graphology DAG, it creates a node with the operation's attributes. When rendered to the reactive engine, it creates a `signal<NodeStatus>` that tracks the call's lifecycle.
|
||||
|
||||
### `<Sequential>`
|
||||
|
||||
Represents sequential execution — children run in order, each child waits for the previous to complete:
|
||||
|
||||
```typescript
|
||||
const Sequential: UComponent<{
|
||||
id?: string; // Optional identifier for the sequence
|
||||
}>;
|
||||
```
|
||||
|
||||
`Sequential` children are rendered in order. In the graphology DAG, each child has a `sequential` edge to the next child. In the reactive engine, each child's precondition is "previous child is `completed`".
|
||||
|
||||
### `<Parallel>`
|
||||
|
||||
Represents parallel execution — all children start simultaneously:
|
||||
|
||||
```typescript
|
||||
const Parallel: UComponent<{
|
||||
id?: string; // Optional identifier for the parallel group
|
||||
maxConcurrency?: number; // Maximum concurrent children (default: unlimited)
|
||||
}>;
|
||||
```
|
||||
|
||||
`Parallel` children have no ordering edges between them. In the reactive engine, all children's preconditions are "parent's prerequisites are met", so they all become `ready` at the same time.
|
||||
|
||||
`maxConcurrency` is a runtime hint, not a structural constraint. The DAG doesn't encode it — it's a scheduling hint for the execution engine.
|
||||
|
||||
### `<Conditional>`
|
||||
|
||||
Represents conditional branching — children only execute if the test passes:
|
||||
|
||||
```typescript
|
||||
const Conditional: UComponent<{
|
||||
test: ((results: Record<string, CallResult>) => boolean) | string;
|
||||
// If string: operation name whose result to check
|
||||
// If function: receives results of previous steps, returns boolean
|
||||
else?: UNode; // Alternative branch if test fails
|
||||
}>;
|
||||
```
|
||||
|
||||
When rendered to a graphology DAG, `Conditional` creates an edge with `edgeType: "conditional"` and `condition` attribute. When rendered to the reactive engine, the condition is evaluated as a `computed` that depends on the referenced step's status and output.
|
||||
|
||||
If the test evaluates to `false`, the branch is marked `skipped` in `NodeStatus`.
|
||||
|
||||
## Template → DAG Conversion
|
||||
|
||||
The `GraphologyHostConfig` renders a template to a graphology DAG:
|
||||
|
||||
```typescript
|
||||
import { createRoot } from "@alkdev/ujsx";
|
||||
import { GraphologyHostConfig } from "@alkdev/flowgraph/host/graphology";
|
||||
|
||||
const host = new GraphologyHostConfig();
|
||||
const root = createRoot(host, new DirectedGraph());
|
||||
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "architect" }),
|
||||
h(Operation, { name: "reviewer" }),
|
||||
h(Operation, { name: "decomposer" }),
|
||||
);
|
||||
|
||||
root.render(template);
|
||||
// Now root.ctx is a DirectedGraph with:
|
||||
// - nodes: "architect", "reviewer", "decomposer"
|
||||
// - edges: "architect" → "reviewer" → "decomposer" (sequential)
|
||||
```
|
||||
|
||||
The HostConfig maps ujsx component types to graphology operations:
|
||||
|
||||
| UElement type | Graphology operation |
|
||||
|---------------|---------------------|
|
||||
| `"operation"` | Add node with `OperationNodeAttrs` |
|
||||
| `"sequential"` | Add `sequential` edges between consecutive children |
|
||||
| `"parallel"` | No edges between children (they run concurrently) |
|
||||
| `"conditional"` | Add `conditional` edge with test attribute |
|
||||
|
||||
### Edge creation rules
|
||||
|
||||
- **Sequential**: For children C1, C2, ..., Cn, edges C1→C2, C2→C3, ..., C(n-1)→Cn are added. Within a sequential group, children have implicit `depends_on` edges.
|
||||
- **Parallel**: No edges between children. All children have the same prerequisites as the parallel group itself.
|
||||
- **Conditional**: Edge from the conditional node's prerequisite to the first child of the branch, with `edgeType: "conditional"` and `condition` attribute.
|
||||
- **Nested**: A `Sequential` inside a `Parallel` has its own internal edges. A `Parallel` inside a `Sequential` creates a subgraph where all parallel children share the same predecessor.
|
||||
|
||||
### Root node handling
|
||||
|
||||
The template's root `URoot` is transparent — its children are mounted directly into the graph. `Sequential` and `Parallel` component functions are also transparent in terms of graph structure — they produce edges between their children, but do not create nodes for themselves.
|
||||
|
||||
This means a template like:
|
||||
|
||||
```typescript
|
||||
h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Parallel, {},
|
||||
h(Operation, { name: "B" }),
|
||||
h(Operation, { name: "C" }),
|
||||
),
|
||||
h(Operation, { name: "D" }),
|
||||
);
|
||||
```
|
||||
|
||||
Produces a DAG with nodes A, B, C, D and edges A→B, A→C, B→D, C→D. No "parallel" or "sequential" nodes.
|
||||
|
||||
## Template → Reactive Execution
|
||||
|
||||
The `ReactiveHostConfig` renders a template to a reactive execution engine:
|
||||
|
||||
```typescript
|
||||
import { createRoot } from "@alkdev/ujsx";
|
||||
import { ReactiveHostConfig } from "@alkdev/flowgraph/host/reactive";
|
||||
|
||||
const host = new ReactiveHostConfig(operationRegistry);
|
||||
const root = createRoot(host, {});
|
||||
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "architect" }),
|
||||
h(Operation, { name: "reviewer" }),
|
||||
);
|
||||
|
||||
root.render(template);
|
||||
// Now each operation node has a signal<NodeStatus>:
|
||||
// - "architect": signal("idle")
|
||||
// - "reviewer": signal("idle")
|
||||
// The reviewer's precondition is: architect.status === "completed"
|
||||
```
|
||||
|
||||
See [reactive-execution.md](reactive-execution.md) for the full reactive execution architecture.
|
||||
|
||||
## Serialization
|
||||
|
||||
Since workflow templates are ujsx `UNode` trees, they are JSON-serializable by design:
|
||||
|
||||
```typescript
|
||||
import { Value } from "@alkdev/typebox/value";
|
||||
import { UJSX } from "@alkdev/ujsx";
|
||||
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "architect" }),
|
||||
h(Operation, { name: "reviewer" }),
|
||||
);
|
||||
|
||||
// Serialize
|
||||
const json = JSON.stringify(template);
|
||||
// → {"type":"sequential","props":{"name":"sequential"},"children":[...]}
|
||||
|
||||
// Deserialize
|
||||
const parsed = JSON.parse(json);
|
||||
if (Value.Check(UJSX.Import("UElement"), parsed)) {
|
||||
// Valid UElement — can render to any HostConfig
|
||||
}
|
||||
```
|
||||
|
||||
Note: function-valued props (like `Conditional.test` with a function) are not serializable. For storage, conditional tests must be expressed as strings (operation references) rather than functions. The HostConfig resolves string references to functions at render time.
|
||||
|
||||
## Validation
|
||||
|
||||
A workflow template can be validated against an operation graph before execution:
|
||||
|
||||
```typescript
|
||||
function validateTemplate(
|
||||
template: UNode,
|
||||
operationGraph: FlowGraph<OperationNodeAttrs, OperationEdgeAttrs>,
|
||||
): ValidationError[]
|
||||
```
|
||||
|
||||
Validation checks:
|
||||
|
||||
1. **All operation names exist in the registry** — every `<Operation name="X">` must have a matching node in the operation graph
|
||||
2. **Type compatibility** — sequential operations have type-compatible edges in the operation graph
|
||||
3. **No cycles** — the rendered DAG has no cycles (inherited from FlowGraph's DAG enforcement)
|
||||
4. **Reachability from start** — all operations in the template are reachable from the first operation
|
||||
|
||||
Validation returns an array of `ValidationError` objects (never throws). See [analysis.md](analysis.md) for the full validation algorithm.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Templates are ujsx trees** — no custom format, no parser, no compiler. Components are `UComponent` functions that produce `UElement` nodes.
|
||||
- **`Operation` props are workflow metadata** — `name`, `input`, `retries`, `timeout` are NOT passed to the HostConfig's `createInstance`. They're workflow-level configuration that the reactive execution engine uses to configure the call.
|
||||
- **Function props are not serializable** — `Conditional.test` with a function cannot be round-tripped through JSON. Use string references for stored templates.
|
||||
- **Sequential ordering is structural, not temporal** — a `Sequential` group means "these operations should complete in order", not "start the next only after the previous completes" (though the reactive engine implements this via preconditions).
|
||||
- **Parallel has no structural edges** — a `Parallel` group produces no DAG edges between its children. The execution engine starts them concurrently when the group's prerequisites are met.
|
||||
- **Conditional branches are either/or** — a `Conditional` node renders to one branch or the `else` branch. There's no "both" evaluation.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Should `Sequential` and `Parallel` be transparent in the graph?** Currently they produce edges, not nodes. An alternative is to create "virtual" grouping nodes (like a "parallel gateway" in BPMN). This would make the graph structure richer but adds complexity.
|
||||
|
||||
2. **Should templates support loops?** A `<ForEach>` component that iterates over an array and produces a child for each element. This would enable dynamic workflows where the number of parallel calls isn't known at template definition time.
|
||||
|
||||
3. **Should templates support `depends_on` edges explicitly?** Currently dependencies are inferred from structure (sequential implies dependency). An explicit `<DependsOn target="operation-name" />` component would make data dependencies visible in the template without relying on sequential ordering.
|
||||
|
||||
4. **How does template instantiation interact with the call protocol?** When a template is instantiated as a call graph, each `<Operation>` becomes a call. But the call protocol's `call.requested` events include `parentRequestId` — who is the parent? The template itself? The hub coordinator? This needs a clear answer.
|
||||
|
||||
## References
|
||||
|
||||
- ujsx architecture: `@alkdev/ujsx/docs/architecture/`
|
||||
- ujsx HostConfig: `@alkdev/ujsx/docs/architecture/host-config.md`
|
||||
- ujsx reactive layer: `@alkdev/ujsx/docs/architecture/reactive-layer.md`
|
||||
- Host configs: [host-configs.md](host-configs.md)
|
||||
- Reactive execution: [reactive-execution.md](reactive-execution.md)
|
||||
- Analysis and validation: [analysis.md](analysis.md)
|
||||
Reference in New Issue
Block a user