--- status: draft last_updated: 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": ```typescript 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 `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`, `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: ```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 ### `` 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` that tracks the call's lifecycle. ### `` 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`". ### `` 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. ### `` Represents conditional branching — children only execute if the test passes: ```typescript const Conditional: UComponent<{ test: ((results: Record) => 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 `then` branch (child) renders with an edge from the conditional's predecessor to the first child, with `edgeType: "conditional"` and `condition: `. - The `else` branch renders as a separate subgraph with `edgeType: "conditional"` and `condition: `. The negated condition is derived automatically. - Both branches share the same predecessor — the `Conditional` node's structural position in the template determines the common starting point. **Reactive rendering (ReactiveHostConfig)**: - When `test` evaluates to `true`: `then`-branch nodes become `ready` (preconditions met). `else`-branch nodes transition to `skipped`. Their `preconditions` are satisfied by the `skipped` state — downstream nodes see the `Conditional` as completed regardless of which branch was taken. - When `test` evaluates to `false`: `else`-branch nodes become `ready`. `then`-branch nodes transition to `skipped`. Downstream nodes after the `Conditional` see all branches as resolved. - When no `else` prop is provided: the `false` branch simply doesn't exist. Nodes after the `Conditional` that depend on it still see it as `completed` because the `Conditional` itself 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. ### `` Represents mapping over an array — creates one child instance per array item: ```typescript const Map: UComponent<{ over: Signal | unknown[] | ((results: Record) => 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 `` 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 `sequential` edge from the `Map`'s predecessor (all mapped nodes start at the same point, like `Parallel`). - Mapped nodes are named with a composite key: `${parentKey}.${as}[${index}]`. For example, `` with 3 items creates nodes `item[0]`, `item[1]`, `item[2]`. - The `Map` container itself is transparent in the graph (no node for the container). **Reactive rendering (ReactiveHostConfig)**: - For each item in `over`, creates a `WorkflowNode` with its own `signal` and `computed` preconditions. - All mapped nodes' preconditions are identical: the `Map`'s predecessor must be `completed` (same as `Parallel`). - Each mapped node's `output` signal holds the result of its corresponding call. - The `Map` result is available as an aggregated signal containing all mapped nodes' outputs. **Example**: ```typescript 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 (`completed` or `skipped`), the `Map` is considered `completed`. - If **any** mapped node reaches `failed`, the `Map` is considered `failed` (unless caught by a `Conditional`). - If **any** mapped node reaches `aborted`, the `Map` is considered `aborted`. - Downstream nodes whose preconditions include the `Map` will see `blockedByFailure = true` if the `Map` has any `failed` or `aborted` children. 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 `ready` simultaneously when the `Map`'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 with `Parallel` behavior). - Mapped nodes participate in failure propagation like any other node: downstream dependents see `blockedByFailure` if a mapped node fails and they depend on it. ## 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 preconditions 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: // - "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, ): ValidationError[] ``` Validation checks: 1. **All operation names exist in the registry** — every `` 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. ## 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 1. **`Operation` has no children** — an `Operation` is a leaf node. Nesting inside `Operation` is a template validation error. 2. **`Conditional` takes a single then-child via children, and optional else via `else` prop** — the `children` of `Conditional` are the then-branch. The `else` prop is the alternative branch. Both branches can be single `Operation` nodes or structural containers (`Sequential`, `Parallel`, `Map`). 3. **`Conditional.test` cannot reference an `Operation` inside the Conditional** — the test evaluates results from predecessor operations, not from the conditional branch itself. This would create a circular dependency. 4. **`Map.over` must 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-valued `over` props don't survive JSON round-trips (same limitation as `Conditional.test`). 5. **`Sequential` with one child is valid but degenerate** — it produces no edges (no sequential ordering needed). A single-child `Sequential` is equivalent to the child alone. 6. **`Parallel` with one child is valid but degenerate** — it produces no edges (no concurrency needed). A single-child `Parallel` is equivalent to the child alone. 7. **Nesting is allowed to any depth** — `Sequential` inside `Parallel` inside `Sequential` is valid. The DAG flattens nesting into edges between leaf `Operation` nodes. 8. **Template root must be a structural container** — the root element must be `Sequential`, `Parallel`, or `Map`. A bare `Operation` as 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 `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 preconditions 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?**~~ **Resolved**: The `` component provides array iteration — one child per array element. It does NOT support general loops (while, do-while). For repeated execution with conditional exit, use `Conditional` inside a `Sequential` group. General-purpose loops with arbitrary termination conditions are not supported because they would require cycle-supporting templates, which conflicts with the DAG-only invariant. 3. **Should templates support `depends_on` edges explicitly?** Currently dependencies are inferred from structure (sequential implies dependency). An explicit `` 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 `` 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)