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.
9.7 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-05-19 |
Error Handling
FlowgraphError hierarchy, validation error collection, and error boundaries.
Design Principle
Flowgraph follows taskgraph's error handling pattern:
- Programmer errors throw — invalid arguments, duplicate node IDs, cycles where acyclicity is enforced
- Operational conditions return structured results — validation errors, type mismatches, unreachable nodes
- Graph mutations throw on constraint violations — adding a duplicate node throws
DuplicateNodeError, adding a cycle-creating edge throwsCycleError
This means validation functions (validateGraph(), validateSchema(), validateTemplate()) never throw — they collect issues and return them. But construction functions (fromSpecs(), addNode(), addEdge()) do throw on constraint violations.
Error Hierarchy
FlowgraphError # Base class for all flowgraph errors
├── ConstructionError # Errors during graph construction
│ ├── DuplicateNodeError # Duplicate node key
│ ├── DuplicateEdgeError # Duplicate edge key
│ ├── NodeNotFoundError # Referenced node doesn't exist
│ └── CycleError # Adding an edge would create a cycle
├── ValidationError # Schema validation failed (single field)
├── GraphValidationError # Graph-level validation issue
│ ├── CycleValidationError # Cycle detected in the graph
│ └── DanglingReferenceError # Edge references non-existent node
├── TypeIncompatError # Type compatibility check failed
└── InvalidTransitionError # Invalid call status transition
FlowgraphError
class FlowgraphError extends Error {
constructor(message: string) {
super(message);
this.name = "FlowgraphError";
}
}
Base class. All flowgraph errors inherit from this.
ConstructionError
class ConstructionError extends FlowgraphError {
constructor(message: string) {
super(message);
this.name = "ConstructionError";
}
}
Base class for errors that occur during graph construction (fromSpecs(), addNode(), addEdge(), etc.).
DuplicateNodeError
class DuplicateNodeError extends ConstructionError {
constructor(public readonly key: string) {
super(`Node with key "${key}" already exists`);
this.name = "DuplicateNodeError";
}
}
Thrown when adding a node with a key that already exists in the graph.
DuplicateEdgeError
class DuplicateEdgeError extends ConstructionError {
constructor(
public readonly source: string,
public readonly target: string,
) {
super(`Edge "${source} -> ${target}" already exists`);
this.name = "DuplicateEdgeError";
}
}
Thrown when adding an edge between two nodes that already have an edge between them.
NodeNotFoundError
class NodeNotFoundError extends ConstructionError {
constructor(public readonly key: string) {
super(`Node "${key}" not found in graph`);
this.name = "NodeNotFoundError";
}
}
Thrown when referencing a node that doesn't exist (e.g., adding an edge with a non-existent endpoint).
CycleError
class CycleError extends ConstructionError {
constructor(public readonly cycles: string[][]) {
super(`Adding this edge would create a cycle: ${JSON.stringify(cycles)}`);
this.name = "CycleError";
}
}
Thrown when adding an edge would create a cycle. The cycles field contains the cycle paths that would be created.
Note: Unlike CircularDependencyError in taskgraph (which is thrown by topologicalOrder()), CycleError is thrown by addEdge() during construction. Taskgraph allows cycles and detects them later; flowgraph prevents them at construction time.
ValidationError
interface ValidationError {
type: "schema";
nodeKey: string;
field: string;
message: string;
value?: unknown;
}
Returned by validateSchema() when a node's attributes don't match the TypeBox schema. This is a structured result, not a thrown error.
GraphValidationError
interface GraphValidationError {
type: "graph";
category: "cycle" | "dangling-reference" | "orphan-node" | "status-inconsistency";
details: unknown;
}
Returned by validateGraph() for graph-level issues:
| Category | Meaning | Details |
|---|---|---|
cycle |
The graph contains cycles | { cycles: string[][] } |
dangling-reference |
An edge references a non-existent node | { source: string, target: string } |
orphan-node |
A node has no incoming or outgoing edges | { nodeKey: string } |
status-inconsistency |
A call node has incompatible status with its parent (e.g., parent completed but child still running) | { nodeKey: string, parentKey: string, nodeStatus: string, parentStatus: string } |
InvalidTransitionError
class InvalidTransitionError extends FlowgraphError {
constructor(
public readonly requestId: string,
public readonly from: CallStatus,
public readonly to: CallStatus,
) {
super(`Invalid status transition for call ${requestId}: ${from} → ${to}`);
this.name = "InvalidTransitionError";
}
}
Thrown when updateNodeStatus() is called with an invalid transition (e.g., completed → running).
TypeIncompatError
interface TypeIncompatError {
type: "type-compat";
sourceKey: string;
targetKey: string;
compatible: false;
mismatches: TypeMismatch[];
}
interface TypeMismatch {
path: string;
expected: string;
actual: string;
}
Returned by validateTemplate() and analyzeTypeCompat() when an edge between operations has incompatible type schemas. This is a structured result, not a thrown error.
Error Collection
Validation functions collect all issues into an array and return them. They do not throw on the first error:
const errors = graph.validate();
// errors is AnyValidationError[], which may be empty
for (const error of errors) {
if (error.type === "schema") {
console.log(`Node ${error.nodeKey} has invalid field ${error.field}: ${error.message}`);
} else if (error.type === "graph" && error.category === "cycle") {
console.log(`Graph has cycles: ${error.details.cycles}`);
}
}
This "collect all errors" pattern allows consumers to see all issues at once, rather than fixing them one at a time.
AnyValidationError
type AnyValidationError = ValidationError | GraphValidationError | TypeIncompatError;
Union type for all validation errors. Consumers use the type discriminator to handle each category:
switch (error.type) {
case "schema": // ValidationError
case "graph": // GraphValidationError
case "type-compat": // TypeIncompatError
}
Throwing vs. Returning
The distinction between thrown errors and returned errors:
| Function | Behavior | Rationale |
|---|---|---|
addNode(key, attrs) |
Throws DuplicateNodeError on duplicate key |
Adding a duplicate is a programmer error |
addEdge(source, target) |
Throws NodeNotFoundError on missing endpoint |
Edge without endpoints is invalid |
addEdge(source, target) |
Throws CycleError if edge creates cycle |
DAG invariant must be maintained |
updateNodeStatus(id, status) |
Throws InvalidTransitionError on invalid transition |
State machine must be enforced |
validateSchema() |
Returns ValidationError[] |
Schema issues are validations, not crashes |
validateGraph() |
Returns GraphValidationError[] |
Graph issues are validations, not crashes |
validateTemplate() |
Returns AnyValidationError[] |
Template issues are validations, not crashes |
analyzeTypeCompat() |
Returns TypeCompatResult (includes mismatches) |
Type incompatibility is advisory, not blocking |
topologicalOrder() |
Throws CircularDependencyError on cycles |
No valid ordering exists from a cyclic graph |
This matches taskgraph's pattern: construction enforces invariants (throwing on violations), validation reports issues (returning error arrays).
Error Boundaries in the Call Graph
The call graph has an additional error boundary: updateFromEvent(). Call events arrive from the pub/sub layer and may reference unknown operations or have invalid transitions. The error boundary handles these gracefully:
- Unknown
requestIdin acall.respondedevent → log warning, ignore the event (the call may have been created by a different process) - Invalid status transition → log warning, ignore the event (the call may have transitioned in a different order)
- Unknown
operationId→ create the node anyway withstatus: "pending"(the operation may be registered later)
This makes updateFromEvent() resilient to out-of-order, duplicate, and partial events. Errors are logged but don't crash the process.
Constraints
- Validation functions never throw — they collect errors and return them. This is a contract.
- Construction functions throw on invariant violations — adding a cycle-creating edge is a programming error, not a validation finding.
- All errors have structured data —
CycleErrorincludes cycle paths,InvalidTransitionErrorincludes from/to status,TypeIncompatErrorincludes mismatch details. - Error messages are descriptive — errors include enough context to diagnose the problem without additional lookups.
- Error classes follow the taskgraph pattern — naming, structure, and behavior match
@alkdev/taskgraph_ts/src/error/.
References
- Taskgraph errors:
@alkdev/taskgraph_ts/src/error/ - Call protocol events:
@alkdev/operations/src/call.ts - Schema: schema.md