- 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
21 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-05-20 |
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":
import { h, createRoot } from "@alkdev/ujsx";
import { Operation, Sequential, Parallel, Conditional, Map } 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
GraphologyHostConfigfor structural analysis - Rendered to a reactive execution engine via the
ReactiveHostConfigfor runtime status tracking
This is the same UNode tree that ujsx defines, with flowgraph-specific component functions (Operation, Sequential, Parallel, Conditional, Map) 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:
// 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:
-
Composability — Nested elements are the natural representation of hierarchical workflows.
Sequential({ children: [...] })is cleaner than a recursive type discriminator. -
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.
-
Host target switching — The
HostConfigpattern 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. -
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.
-
Reactive props —
@preact/signals-coreenables signal-driven prop updates. AnOperationnode'snamecould be a signal, enabling dynamic workflow modification at runtime.
See ADR-001 for the full decision record.
Component Definitions
<Operation>
Represents a single operation invocation in the workflow:
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:
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:
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:
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 and no else branch is provided, the branch nodes transition to skipped in NodeStatus.
Else-branch behavior
When the else prop is provided, the Conditional renders two subgraphs:
DAG rendering (GraphologyHostConfig):
- The
thenbranch (child) renders with an edge from the conditional's predecessor to the first child, withedgeType: "conditional"andcondition: <test>. - The
elsebranch renders as a separate subgraph withedgeType: "conditional"andcondition: <negated test>. The negated condition is derived automatically. - Both branches share the same predecessor — the
Conditionalnode's structural position in the template determines the common starting point.
Reactive rendering (ReactiveHostConfig):
- When
testevaluates totrue:then-branch nodes becomeready(preconditions met).else-branch nodes transition toskipped. Theirpreconditionsare satisfied by theskippedstate — downstream nodes see theConditionalas completed regardless of which branch was taken. - When
testevaluates tofalse:else-branch nodes becomeready.then-branch nodes transition toskipped. Downstream nodes after theConditionalsee all branches as resolved. - When no
elseprop is provided: thefalsebranch simply doesn't exist. Nodes after theConditionalthat depend on it still see it ascompletedbecause theConditionalitself resolves regardless of which path is taken.
This means a Conditional with an else branch acts as a complete error boundary — downstream nodes are insulated from the branch choice. The Conditional is completed whether the then or else branch executed.
<Map>
Represents mapping over an array — creates one child instance per array item:
const Map: UComponent<{
over: Signal<unknown[]> | unknown[] | ((results: Record<string, CallResult>) => unknown[]);
// Static array, signal, or function that resolves against predecessor results
as: string; // Variable name for each item
children: UNode; // Template rendered per item
}>;
The <Map> component dynamically replicates its child template for each element in the over array. Each replica gets the current element bound to the variable named by as.
DAG rendering (GraphologyHostConfig):
- For each item in
over, renders a copy of the child template as a node. - Each mapped node has a
sequentialedge from theMap's predecessor (all mapped nodes start at the same point, likeParallel). - Mapped nodes are named with a composite key:
${parentKey}.${as}[${index}]. For example,<Map over={items} as="item">with 3 items creates nodesitem[0],item[1],item[2]. - The
Mapcontainer itself is transparent in the graph (no node for the container).
Reactive rendering (ReactiveHostConfig):
- For each item in
over, creates aWorkflowNodewith its ownsignal<NodeStatus>andcomputedpreconditions. - All mapped nodes' preconditions are identical: the
Map's predecessor must becompleted(same asParallel). - Each mapped node's
outputsignal holds the result of its corresponding call. - The
Mapresult is available as an aggregated signal containing all mapped nodes' outputs.
Example:
h(Sequential, {},
h(Operation, { name: "fetch-items" }),
h(Map, {
over: (results) => results["fetch-items"].output.items,
as: "item"
},
h(Operation, { name: "process-item", input: (results, { item }) => item }),
),
)
This creates a Sequential where process-item is called once per item returned by fetch-items. Each call gets its corresponding item as input.
Edge type: Mapped children use the same TemplateEdgeAttrs as Parallel children (no sequential edges between siblings). The Map component is structurally equivalent to a Parallel group where the children are dynamically generated from an array.
Aggregate completion semantics: A Map node's status follows "worst-case" semantics:
- If all mapped nodes reach a satisfying terminal state (
completedorskipped), theMapis consideredcompleted. - If any mapped node reaches
failed, theMapis consideredfailed(unless caught by aConditional). - If any mapped node reaches
aborted, theMapis consideredaborted. - Downstream nodes whose preconditions include the
Mapwill seeblockedByFailure = trueif theMaphas anyfailedorabortedchildren.
This means a Map with partial failure (some nodes succeeded, one failed) propagates as a failure to downstream dependents. If partial success is needed, the template author should use a Conditional to handle the failure case, or process the Map results individually rather than treating the Map as a single dependency.
Reactive behavior for mapped nodes:
- All mapped nodes become
readysimultaneously when theMap's predecessor completes (parallel start). - If any mapped node fails, only that node transitions to
failed. Other mapped nodes continue independently (failure follows dependency edges, not structural scope, consistent withParallelbehavior). - Mapped nodes participate in failure propagation like any other node: downstream dependents see
blockedByFailureif a mapped node fails and they depend on it.
Template → DAG Conversion
The GraphologyHostConfig renders a template to a graphology DAG:
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_onedges. - Parallel: No edges between children. All children have the same preconditions as the parallel group itself.
- Conditional: Edge from the conditional node's prerequisite to the first child of the branch, with
edgeType: "conditional"andconditionattribute. - Nested: A
Sequentialinside aParallelhas its own internal edges. AParallelinside aSequentialcreates 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:
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:
import { createRoot } from "@alkdev/ujsx";
import { ReactiveHostConfig } from "@alkdev/flowgraph/host/reactive";
const host = new ReactiveHostConfig(operationRegistry, workflowRoot);
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 for the full reactive execution architecture.
Serialization
Since workflow templates are ujsx UNode trees, they are JSON-serializable by design:
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:
function validateTemplate(
template: UNode,
operationGraph: FlowGraph<OperationNodeAttrs, OperationEdgeAttrs>,
): ValidationError[]
Validation checks:
- All operation names exist in the registry — every
<Operation name="X">must have a matching node in the operation graph - Type compatibility — sequential operations have type-compatible edges in the operation graph
- No cycles — the rendered DAG has no cycles (inherited from FlowGraph's DAG enforcement)
- 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 for the full validation algorithm.
Composition Rules
Not all component combinations are valid. The following rules govern which components can appear as children of which:
| Parent | Valid children | Notes |
|---|---|---|
Sequential |
Operation, Sequential, Parallel, Conditional, Map |
Children execute in order |
Parallel |
Operation, Sequential, Parallel, Conditional, Map |
Children execute concurrently |
Conditional (then) |
Operation, Sequential, Parallel, Map |
Single child or wrapped in structural container |
Conditional (else) |
Operation, Sequential, Parallel, Map |
Single child or wrapped in structural container |
Map |
Operation, Sequential, Parallel, Conditional |
Template rendered per item |
Rules
-
Operationhas no children — anOperationis a leaf node. Nesting insideOperationis a template validation error. -
Conditionaltakes a single then-child via children, and optional else viaelseprop — thechildrenofConditionalare the then-branch. Theelseprop is the alternative branch. Both branches can be singleOperationnodes or structural containers (Sequential,Parallel,Map). -
Conditional.testcannot reference anOperationinside the Conditional — the test evaluates results from predecessor operations, not from the conditional branch itself. This would create a circular dependency. -
Map.overmust be a serializable expression or signal — the array can be a static value, a signal, or a function that receives results from predecessor operations. Function-valuedoverprops don't survive JSON round-trips (same limitation asConditional.test). -
Sequentialwith one child is valid but degenerate — it produces no edges (no sequential ordering needed). A single-childSequentialis equivalent to the child alone. -
Parallelwith one child is valid but degenerate — it produces no edges (no concurrency needed). A single-childParallelis equivalent to the child alone. -
Nesting is allowed to any depth —
SequentialinsideParallelinsideSequentialis valid. The DAG flattens nesting into edges between leafOperationnodes. -
Template root must be a structural container — the root element must be
Sequential,Parallel, orMap. A bareOperationas root is technically valid but produces a single-node DAG with no edges.
Constraints
- Templates are ujsx trees — no custom format, no parser, no compiler. Components are
UComponentfunctions that produceUElementnodes. Operationprops are workflow metadata —name,input,retries,timeoutare NOT passed to the HostConfig'screateInstance. They're workflow-level configuration that the reactive execution engine uses to configure the call.- Function props are not serializable —
Conditional.testwith a function cannot be round-tripped through JSON. Use string references for stored templates. - Sequential ordering is structural, not temporal — a
Sequentialgroup 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
Parallelgroup produces no DAG edges between its children. The execution engine starts them concurrently when the group's preconditions are met. - Conditional branches are either/or — a
Conditionalnode renders to one branch or theelsebranch. There's no "both" evaluation.
Open Questions
-
Should
SequentialandParallelbe 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. -
Should templates support loops?Resolved: The<Map>component provides array iteration — one child per array element. It does NOT support general loops (while, do-while). For repeated execution with conditional exit, useConditionalinside aSequentialgroup. General-purpose loops with arbitrary termination conditions are not supported because they would require cycle-supporting templates, which conflicts with the DAG-only invariant. -
Should templates support
depends_onedges 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. -
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'scall.requestedevents includeparentRequestId— 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
- Reactive execution: reactive-execution.md
- Analysis and validation: analysis.md