resolve architecture review round 2: criticals, warnings, suggestions
- C-05: Add flowgraph-api.md with complete public API surface - C-06: Document <Map> component in workflow-templates.md - C-07: Specify Conditional else-branch behavior - C-08: Add lifecycle/ownership section to reactive-execution.md - C-09: Add consumer-integration.md end-to-end walkthrough - W-02: Add reactive error boundary semantics (3 levels) - W-03: Complete ReactiveContext interface definition - W-04: Add template composition rules (8 rules) - W-05: Document removeChild for both HostConfigs - W-06: Document signal/effect disposal lifecycle - W-07: Add ADR-004 (no schema version field) - W-08: Add type compatibility depth/contract to analysis.md - W-11: Add performance characteristics section - S-01: Getting Started merged into consumer-integration.md - S-02: Add flow diagrams for template rendering pipeline - S-03: Add node status state machine diagram - S-04: Add testing strategy section - S-06: Validate source structure cross-references Review round 2 fixes: - Define TemplateNodeAttrs as alias for OperationNodeAttrs - Document CallEventMapValue and CallResult types in schema.md - Standardize CycleError naming (replace CircularDependencyError) - Add function form to Map.over type definition - Define Map aggregate completion/failure semantics - Fix immutability claim for fromCallEvents - Clarify edgeType storage alongside OperationEdgeAttrs - Clarify WorkflowNode.status === statusMap (same Signal) - Add component-to-tag mapping for WorkflowTag
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-05-19
|
||||
last_updated: 2026-05-20
|
||||
---
|
||||
|
||||
# Reactive Execution
|
||||
@@ -87,15 +87,49 @@ For each operation node in the DAG:
|
||||
The signal-based status lifecycle mirrors `CallStatus` with workflow-specific additions:
|
||||
|
||||
```
|
||||
idle → waiting → ready → running → completed
|
||||
↓ ↑
|
||||
failed │
|
||||
↓ │
|
||||
(uncaught) → aborted ←──┘
|
||||
↑
|
||||
(cascade from failed predecessor)
|
||||
↑
|
||||
skipped (conditional)
|
||||
┌──────┐
|
||||
┌────────│ idle │────────────┐
|
||||
│ └──┬───┘ │
|
||||
│ │ predecessor │ (no predecessors —
|
||||
│ │ starts running │ root node)
|
||||
│ ▼ │
|
||||
│ ┌───────┐ │
|
||||
│ │waiting│ │
|
||||
│ └───┬───┘ │
|
||||
│ │ all preds │
|
||||
│ │ completed/ │
|
||||
│ ┌────┤ skipped │
|
||||
│ │ │ ▼
|
||||
│ │ │ ┌──────┐
|
||||
│ │ └──────────►│ready │
|
||||
│ │ └──┬───┘
|
||||
│ │ │ hub starts call
|
||||
│ │ ▼
|
||||
│ │ ┌────────┐
|
||||
│ │ │running │──── ──── ──── ────►
|
||||
│ │ └──┬──┬──┘ │
|
||||
│ │ │ │ │
|
||||
│ │ call │ │ call │ call
|
||||
│ │ completed │ │ failed │ aborted
|
||||
│ │ │ │ │
|
||||
│ │ ▼ ▼ ▼
|
||||
│ │ ┌───────────┐ ┌──────┐ ┌────────┐
|
||||
│ │ │ completed │ │failed│ │aborted │
|
||||
│ │ └───────────┘ └──────┘ └────────┘
|
||||
│ │ │ │ │
|
||||
│ │ │ │ (uncaught) │
|
||||
│ │ │ ▼ │
|
||||
│ │ │ cascades to all │
|
||||
│ │ │ downstream dependents │
|
||||
│ │ │ via blockedByFailure │
|
||||
│ │ │ │
|
||||
└──────┼──────────────┼────────────────────────────┘
|
||||
│ │
|
||||
│ ┌─────────┐│
|
||||
└───►│skipped ││ (Conditional branch
|
||||
└─────────┘│ not taken)
|
||||
│
|
||||
└─── all are terminal states
|
||||
```
|
||||
|
||||
Full transition rules:
|
||||
@@ -442,6 +476,84 @@ abortAll(): void {
|
||||
|
||||
This transitions all non-terminal, non-failed nodes to `aborted`. It's for cases where the entire workflow should stop, regardless of which branches are independent.
|
||||
|
||||
## Reactive Error Boundaries
|
||||
|
||||
The reactive execution layer has three levels of error handling, each with distinct scope and semantics:
|
||||
|
||||
### Level 1: Signal-level errors (per-node)
|
||||
|
||||
When a call fails, the hub coordinator sets the node's status to `"failed"`:
|
||||
|
||||
```typescript
|
||||
status.value = "failed"; // Individual node failure
|
||||
```
|
||||
|
||||
This triggers `blockedByFailure` in all downstream dependents, causing them to transition to `"aborted"`. The failure propagates through the signal graph reactively — no manual error handling is needed.
|
||||
|
||||
### Level 2: Conditional error boundaries (branch-level)
|
||||
|
||||
A `Conditional` node catches failures and redirects to an alternative branch:
|
||||
|
||||
```typescript
|
||||
h(Conditional, {
|
||||
test: (results) => results["fetch-data"].status !== "failed",
|
||||
},
|
||||
// then-branch (happy path)
|
||||
h(Operation, { name: "process" }),
|
||||
// else-branch (fallback)
|
||||
h(Operation, { name: "handle-error" }),
|
||||
)
|
||||
```
|
||||
|
||||
When the `Conditional`'s `test` function evaluates to `false` (because a predecessor failed), the then-branch transitions to `skipped` and the else-branch becomes `ready`. Downstream nodes after the `Conditional` see it as `completed` — the failure is contained.
|
||||
|
||||
This is the reactive equivalent of a `try/catch` block. Without a `Conditional`, failures cascade uncaught through dependency edges.
|
||||
|
||||
### Level 3: Workflow abort (system-level)
|
||||
|
||||
For failures that should cancel everything, the hub calls `workflowRoot.abortAll()`:
|
||||
|
||||
```typescript
|
||||
workflowRoot.abortAll(); // All non-terminal nodes → "aborted"
|
||||
```
|
||||
|
||||
This is for system-level failures: provider outage, authentication failure, or any condition where the workflow cannot meaningfully continue regardless of branch independence.
|
||||
|
||||
### WorkflowErrorBoundary (coordinator-level)
|
||||
|
||||
The hub coordinator wraps the entire reactive execution in a `WorkflowErrorBoundary` — a conceptual boundary, not a signal:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
// Drive the workflow
|
||||
for (const [nodeId, preconditions, blockedByFailure] of workflowRoot.nodes) {
|
||||
effect(() => { /* start calls when ready */ });
|
||||
effect(() => { /* abort when blocked */ });
|
||||
}
|
||||
} catch (error) {
|
||||
// Unhandled reactive error — signal graph inconsistency
|
||||
// This shouldn't happen in normal operation
|
||||
workflowRoot.abortAll();
|
||||
prm.abortAll(pendingRequestIds);
|
||||
}
|
||||
```
|
||||
|
||||
The `WorkflowErrorBoundary` catches errors that escape the signal graph (e.g., a `computed` that throws, an `effect` that errors). These are catastrophic — the reactive state is inconsistent. The boundary's job is to:
|
||||
1. Abort all calls via `prm.abortAll()`
|
||||
2. Set all non-terminal nodes to `"aborted"` via `workflowRoot.abortAll()`
|
||||
3. Dispose the reactive root
|
||||
4. Log the error for diagnostics
|
||||
|
||||
**Error propagation summary**:
|
||||
|
||||
| Error type | Scope | Mechanism | Recovery |
|
||||
|------------|-------|-----------|----------|
|
||||
| Call failure | Single node | `status.value = "failed"` | Cascades to dependents via `blockedByFailure` |
|
||||
| Caught by Conditional | Branch | `Conditional.test` evaluates against failed status | Redirect to else-branch, downstream sees `completed` |
|
||||
| Uncaught cascade | Downstream chain | `blockedByFailure` effects | Downstream nodes transition to `aborted` |
|
||||
| System failure | Entire workflow | `abortAll()` | All non-terminal nodes to `aborted` |
|
||||
| Reactive error | Signal graph | `WorkflowErrorBoundary` catch | Abort everything, dispose, log |
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Signals are in-memory** — `WorkflowReactiveRoot` state is not persisted. If the hub restarts, the reactive state is lost and must be reconstructed from call protocol events + template re-render.
|
||||
@@ -453,6 +565,94 @@ This transitions all non-terminal, non-failed nodes to `aborted`. It's for cases
|
||||
- **`failed` and `aborted` block preconditions** — a `failed` or `aborted` predecessor means the dependent's preconditions can never be met. The `blockedByFailure` effect transitions the dependent to `aborted`.
|
||||
- **`NodeStatus` and `CallStatus` share terminal states** — `running`, `completed`, `failed`, `aborted` map directly. `idle`, `waiting`, `ready`, `skipped` are workflow-specific additions.
|
||||
|
||||
## Lifecycle and Ownership
|
||||
|
||||
The reactive execution pipeline has a clear creation order and ownership model:
|
||||
|
||||
### Creation Order
|
||||
|
||||
```
|
||||
1. Template (UNode tree)
|
||||
↓ GraphologyHostConfig
|
||||
2. DAG (DirectedGraph)
|
||||
↓ WorkflowReactiveRoot constructor
|
||||
3. Signal graph (statusMap, preconditions, blockedByFailure)
|
||||
↓ ReactiveHostConfig.render()
|
||||
4. WorkflowNode tree (with effects registered)
|
||||
```
|
||||
|
||||
1. **Template → DAG**: The consumer provides a template and renders it through `GraphologyHostConfig`. This produces a `DirectedGraph` stored in the `GraphContext`.
|
||||
|
||||
2. **DAG → Signal graph**: The consumer creates a `WorkflowReactiveRoot` from the DAG. The constructor iterates over all operation nodes in the DAG and creates `signal<NodeStatus>`, `computed<boolean>` (preconditions), and `computed<boolean>` (blockedByFailure) for each.
|
||||
|
||||
3. **Signal graph → WorkflowNode tree**: The consumer renders the template through `ReactiveHostConfig`. The `createInstance` call for each `Operation` node looks up the corresponding signal in the `ReactiveRoot` and wires the node's effects.
|
||||
|
||||
### Ownership
|
||||
|
||||
| Object | Owned by | Disposed by |
|
||||
|--------|----------|-------------|
|
||||
| Template (`UNode` tree) | Consumer | Consumer (not a reactive resource) |
|
||||
| DAG (`DirectedGraph`) | GraphologyHostConfig's `GraphContext` | Consumer (static, no disposal needed) |
|
||||
| `WorkflowReactiveRoot` | Consumer (typically the hub coordinator) | Consumer calls `root.dispose()` |
|
||||
| Signal graph (statusMap, preconditions, etc.) | `WorkflowReactiveRoot` | `root.dispose()` clears all maps |
|
||||
| `WorkflowNode` tree | `ReactiveContext` (created by ReactiveHostConfig) | Cleared when `ReactiveContext` is garbage collected |
|
||||
| Effects | `WorkflowReactiveRoot.effectDisposers` | `root.dispose()` calls all disposers |
|
||||
|
||||
**Key ownership rules**:
|
||||
- `WorkflowReactiveRoot` owns the signal graph. It creates every `signal` and `computed`, tracks every `effect` disposer, and is responsible for cleaning them all up.
|
||||
- `ReactiveHostConfig` is stateless after rendering. It creates `WorkflowNode` instances and registers effects, but the effects are tracked by `WorkflowReactiveRoot`, not by the HostConfig.
|
||||
- The consumer owns the `WorkflowReactiveRoot` lifecycle. It creates it, drives execution by setting status values, and disposes it when done.
|
||||
|
||||
### Disposal
|
||||
|
||||
```typescript
|
||||
// When workflow completes or is cancelled:
|
||||
workflowRoot.dispose();
|
||||
```
|
||||
|
||||
`dispose()` performs the following in order:
|
||||
1. Calls every `effect()` disposer, unsubscribing all reactive effects.
|
||||
2. Clears `statusMap`, `preconditions`, and `blockedByFailure` maps, releasing signal references.
|
||||
3. The `WorkflowNode` tree becomes inert — status signals no longer exist, so no updates propagate.
|
||||
|
||||
**When to dispose**:
|
||||
- Workflow completes successfully (all nodes `completed`)
|
||||
- Workflow is aborted (consumer calls `abortAll()`, then `dispose()`)
|
||||
- Template is being re-rendered (dispose the old root before creating a new one — until ujsx reconciler supports re-rendering)
|
||||
|
||||
**What NOT to dispose**:
|
||||
- The DAG (`DirectedGraph`) is not a reactive resource. It doesn't need disposal.
|
||||
- The template (`UNode` tree) is plain data. It doesn't need disposal.
|
||||
|
||||
### Interaction with ReactiveHostConfig
|
||||
|
||||
The `ReactiveHostConfig` does NOT own the reactive state. It creates `WorkflowNode` instances during rendering, but these nodes reference signals that belong to `WorkflowReactiveRoot`. The rendering flow is:
|
||||
|
||||
```typescript
|
||||
// 1. Create ReactiveRoot from DAG
|
||||
const workflowRoot = new WorkflowReactiveRoot(dag);
|
||||
|
||||
// 2. Create ReactiveHostConfig with reference to ReactiveRoot's signals
|
||||
const hostConfig = new ReactiveHostConfig(operationRegistry, workflowRoot);
|
||||
|
||||
// 3. Render template
|
||||
const root = createRoot(hostConfig, {});
|
||||
root.render(template);
|
||||
|
||||
// 4. Drive execution (hub coordinator sets status values)
|
||||
workflowRoot.statusMap.get("architect")!.value = "ready";
|
||||
// ... external code starts the call, eventually:
|
||||
workflowRoot.statusMap.get("architect")!.value = "completed";
|
||||
// ... which triggers downstream preconditions
|
||||
|
||||
// 5. Cleanup
|
||||
workflowRoot.dispose();
|
||||
```
|
||||
|
||||
The `ReactiveContext` passed to `ReactiveHostConfig` includes a reference to `workflowRoot.statusSignals` so that `createInstance` can look up and wire signals for each node. The context does not own these signals — it's a lookup table.
|
||||
|
||||
**Important**: `WorkflowNode.status` and `WorkflowReactiveRoot.statusMap.get(nodeId)` reference the **same** `Signal<NodeStatus>` instance. There is one signal per node, owned by `WorkflowReactiveRoot`, and both the `WorkflowNode` and the `statusMap` hold references to it. Setting `workflowRoot.statusMap.get("architect").value = "running"` and setting `workflowNode.status.value = "running"` (where `workflowNode.key === "architect"`) are equivalent operations on the same signal. Similarly, `WorkflowNode.preconditions` and `WorkflowReactiveRoot.preconditions.get(nodeId)` reference the **same** `Computed<boolean>` instance.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Should preconditions support OR logic?** Currently all predecessors must complete (AND logic). An `anyOf` predicate would allow "start this node as soon as any predecessor completes." This would require an edge attribute or node-level configuration.
|
||||
|
||||
Reference in New Issue
Block a user