Files
flowgraph/docs/architecture/workflow-templates.md
glm-5.1 d2253099ee 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.
2026-05-19 09:36:22 +00:00

13 KiB

status, last_updated
status last_updated
draft 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":

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:

// 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 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, the branch is marked skipped in NodeStatus.

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_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:

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);
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:

  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 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 metadataname, 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 serializableConditional.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
  • Reactive execution: reactive-execution.md
  • Analysis and validation: analysis.md