- 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
465 lines
22 KiB
Markdown
465 lines
22 KiB
Markdown
---
|
|
status: draft
|
|
last_updated: 2026-05-20
|
|
---
|
|
|
|
# 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`, `Map`) 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.
|
|
|
|
### Component-to-Tag Mapping
|
|
|
|
Each `UComponent` function produces a `UElement` with a specific `type` string (the `WorkflowTag`). The mapping is:
|
|
|
|
| Component function | UElement.type (WorkflowTag) |
|
|
|-------------------|---------------------------|
|
|
| `Operation` | `"operation"` |
|
|
| `Sequential` | `"sequential"` |
|
|
| `Parallel` | `"parallel"` |
|
|
| `Conditional` | `"conditional"` |
|
|
| `Map` | `"map"` |
|
|
|
|
When ujsx's reconciler calls `HostConfig.createInstance(tag, props, ...)`, the `tag` parameter is the `WorkflowTag` string. For example, `h(Operation, { name: "classify" })` produces `{ type: "operation", props: { name: "classify" }, children: [] }`, and `createInstance("operation", { name: "classify" }, ctx)` is called.
|
|
|
|
## GraphologyHostConfig
|
|
|
|
### Type Parameters
|
|
|
|
```typescript
|
|
const graphologyHost: HostConfig<WorkflowTag, GraphNode, GraphContext>
|
|
```
|
|
|
|
- **TTag**: `WorkflowTag`
|
|
- **Instance**: `GraphNode` (a logical representation of what each template node becomes in the graph)
|
|
- **RootCtx**: `GraphContext` (the root context carrying the graph and metadata)
|
|
|
|
```typescript
|
|
interface GraphNode {
|
|
key: string; // The graphology node key
|
|
attributes: OperationNodeAttrs | TemplateNodeAttrs;
|
|
}
|
|
```
|
|
|
|
Where `TemplateNodeAttrs` is a type alias for `OperationNodeAttrs` (see [schema.md](schema.md#TemplateNodeAttrs)) — template nodes carry the same attributes as operation nodes. Structural containers (`Sequential`, `Parallel`, `Conditional`, `Map`) return a `GraphNode` with an empty `attributes` object and a synthetic key.
|
|
|
|
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
|
|
}
|
|
```
|
|
|
|
### removeChild
|
|
|
|
```typescript
|
|
removeChild(parent: GraphNode, child: GraphNode, ctx: GraphContext): void {
|
|
// Remove the edge between parent and child
|
|
// Structural containers are transparent, so parent/child are real operation nodes
|
|
if (!isStructuralContainer(parent) && !isStructuralContainer(child)) {
|
|
ctx.graph.dropEdge(`${parent.key}->${child.key}`);
|
|
}
|
|
}
|
|
```
|
|
|
|
`removeChild` is called by the ujsx reconciler when a child is removed from a parent. In the `GraphologyHostConfig`, this removes the corresponding DAG edge. The child node itself is NOT removed from the graph — node removal is handled by `removeFromGraph` (see below).
|
|
|
|
**Note**: The ujsx reconciler is not yet implemented. Currently, `removeChild` is defined but only called in tests. The `GraphologyHostConfig` is mount-only until the reconciler is available.
|
|
|
|
### removeChildFromHost (node removal)
|
|
|
|
```typescript
|
|
removeChildFromHost?(parent: GraphNode, child: GraphNode, ctx: GraphContext): void {
|
|
// Remove the child node from the graph
|
|
ctx.graph.dropNode(child.key);
|
|
}
|
|
```
|
|
|
|
When the reconciler removes a child entirely (not just moving it to a different parent), it calls `removeChildFromHost`. This removes the node and ALL attached edges (graphology cascading removal). This is important for cleanup when a template is re-rendered and a node no longer exists.
|
|
|
|
### Cycle Detection
|
|
|
|
After rendering, the HostConfig checks for cycles using `graphology-dag.hasCycle()`. If a cycle is detected, the rendering throws `CycleError` 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
|
|
preconditions: Computed<boolean>; // Computed: true when all preconditions are met
|
|
blockedByFailure: Computed<boolean>; // Computed: true when any predecessor failed/aborted (uncaught)
|
|
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 `preconditions` from parent nodes' statuses (true when all predecessors are `completed` or `skipped`)
|
|
- A `computed` that derives `blockedByFailure` from parent nodes' statuses (true when any predecessor is `failed` or `aborted`)
|
|
- An optional `output` signal that holds the call result when completed
|
|
|
|
### ReactiveContext
|
|
|
|
```typescript
|
|
interface ReactiveContext {
|
|
operationRegistry: OperationRegistry; // Resolves operation names to specs
|
|
nodes: Map<string, WorkflowNode>; // All nodes by key
|
|
statusSignals: Map<string, Signal<NodeStatus>>; // Status signals by key (owned by WorkflowReactiveRoot)
|
|
preconditions: Map<string, Computed<boolean>>; // Precondition computeds by key (owned by WorkflowReactiveRoot)
|
|
blockedByFailure: Map<string, Computed<boolean>>; // blockedByFailure computeds by key (owned by WorkflowReactiveRoot)
|
|
parentMap: Map<string, string>; // Child → parent key mapping (for precondition computation)
|
|
siblingMap: Map<string, string[]>; // Parent → children keys (for structural context)
|
|
results: Map<string, Signal<unknown>>; // Operation output signals by key
|
|
}
|
|
```
|
|
|
|
The `ReactiveContext` is constructed during `ReactiveHostConfig` initialization. It receives the `operationRegistry` and empty maps. During `createInstance`, nodes and signals are registered in the context maps. After rendering completes, the context holds a complete index of the reactive workflow tree.
|
|
|
|
**Important**: `statusSignals`, `preconditions`, and `blockedByFailure` are references to the `WorkflowReactiveRoot`'s maps. The `ReactiveHostConfig` does not own these signals — it looks them up during `createInstance` to wire `WorkflowNode` references. Disposal is the `WorkflowReactiveRoot`'s responsibility.
|
|
|
|
### 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,
|
|
preconditions: computed(() => computePreconditions(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 `preconditions` computed signal for each node derives from its structural context:
|
|
|
|
- **Sequential child**: preconditions = previous sibling is `completed`
|
|
- **Parallel child**: preconditions = parent's preconditions are met
|
|
- **Conditional child**: preconditions = parent's preconditions are met AND condition evaluates to true
|
|
|
|
```typescript
|
|
function computePreconditions(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' `preconditions` and `blockedByFailure` computed values automatically re-evaluate. If preconditions are met, the node transitions to `ready`; if blocked by failure, it transitions to `aborted`:
|
|
|
|
```typescript
|
|
// Start when preconditions are met
|
|
effect(() => {
|
|
if (node.preconditions.value) {
|
|
if (node.status.value === "idle" || node.status.value === "waiting") {
|
|
node.status.value = "ready";
|
|
}
|
|
}
|
|
});
|
|
|
|
// Abort when a predecessor fails (uncaught failure propagation)
|
|
effect(() => {
|
|
if (node.blockedByFailure.value) {
|
|
if (node.status.value === "idle" || node.status.value === "waiting") {
|
|
node.status.value = "aborted";
|
|
}
|
|
}
|
|
});
|
|
```
|
|
|
|
The reactive engine then starts the call associated with the node (when `ready`), which sets `status` to `running`, and eventually `completed` or `failed`.
|
|
|
|
**Note**: Failure propagation follows dependency edges, not structural scope. A failed node only causes its downstream dependents (via DAG edges) to abort. Sibling branches in a `Parallel` group are independent and continue running. See [reactive-execution.md](reactive-execution.md) for the full failure propagation model.
|
|
|
|
### removeChild (ReactiveHostConfig)
|
|
|
|
```typescript
|
|
removeChild(parent: WorkflowNode, child: WorkflowNode, ctx: ReactiveContext): void {
|
|
// Remove the dependency between parent and child
|
|
// The child's preconditions are recomputed automatically (reactive)
|
|
parent.children = parent.children.filter(c => c.key !== child.key);
|
|
// The child's preconditions and blockedByFailure computeds will re-evaluate
|
|
// because the predecessor list changes
|
|
}
|
|
```
|
|
|
|
`removeChild` in the reactive host removes the parent-child dependency. Because preconditions and `blockedByFailure` are `computed` values, they automatically re-evaluate when predecessor nodes are removed.
|
|
|
|
```typescript
|
|
removeChildFromHost?(parent: WorkflowNode, child: WorkflowNode, ctx: ReactiveContext): void {
|
|
// Dispose the child's reactive state
|
|
ctx.nodes.delete(child.key);
|
|
ctx.statusSignals.delete(child.key);
|
|
ctx.preconditions.delete(child.key);
|
|
ctx.blockedByFailure.delete(child.key);
|
|
if (child.output) {
|
|
// Signal disposal is handled by WorkflowReactiveRoot.dispose()
|
|
// Here we just remove the reference from the context maps
|
|
}
|
|
}
|
|
```
|
|
|
|
For complete reactive teardown (`removeChildFromHost`), the node's signal references are removed from the context maps. The signals themselves (owned by `WorkflowReactiveRoot`) are disposed via `root.dispose()` which is the authoritative cleanup path.
|
|
|
|
**Important**: Individual node disposal (removing a node mid-execution) is not fully supported until the ujsx reconciler is implemented. Currently, the reactive tree is built once and torn down as a whole via `WorkflowReactiveRoot.dispose()`.
|
|
|
|
### Abort Cascading
|
|
|
|
System-level abort (e.g., provider outage) aborts the entire workflow:
|
|
|
|
```typescript
|
|
function abortAll(root: WorkflowReactiveRoot): void {
|
|
for (const [nodeId, status] of root.statusMap) {
|
|
if (status.value !== "completed" && status.value !== "failed") {
|
|
status.value = "aborted";
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
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:
|
|
|
|
```
|
|
ujsx Template (UNode tree)
|
|
│
|
|
┌─────────┴─────────┐
|
|
│ │
|
|
GraphologyHostConfig ReactiveHostConfig
|
|
│ │
|
|
▼ ▼
|
|
DirectedGraph (DAG) Reactive Signal Graph
|
|
┌──────────────────┐ ┌──────────────────┐
|
|
│ Nodes: operations │ │ Nodes: WorkflowNode│
|
|
│ Edges: sequential │ │ signal<NodeStatus>│
|
|
│ conditional │ │ computed<precond> │
|
|
│ typed │ │ computed<blocked> │
|
|
└──────────────────┘ └──────────────────┘
|
|
│ │
|
|
▼ ▼
|
|
Structural Runtime Execution
|
|
Analysis & Status Tracking &
|
|
Validation Abort Propagation
|
|
```
|
|
|
|
```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 preconditions) 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 precondition 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) |