- 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
11 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-05-20 |
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
│ └── InvalidInputError # Invalid input to fromJSON() or constructor
├── 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: CycleError is flowgraph's cycle error, thrown by addEdge() during construction. Taskgraph uses a different error name (CircularDependencyError, thrown by topologicalOrder()). The two are distinct errors for distinct contexts — flowgraph prevents cycles at construction time, taskgraph allows cycles and detects them later.
InvalidInputError
class InvalidInputError extends ConstructionError {
constructor(public readonly errors: ValidationError[]) {
super(`Invalid input: ${errors.length} validation error(s)`);
this.name = "InvalidInputError";
}
}
Thrown by fromJSON() when the input data fails schema validation. The errors field contains the array of ValidationError objects describing which fields failed validation. This is distinct from ValidationError (which is a returned result) — InvalidInputError is thrown because fromJSON() enforces that deserialized data is structurally valid.
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 |
fromJSON(data) |
Throws InvalidInputError on validation failure |
Deserialized data must be structurally valid |
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 CycleError 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