- 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
280 lines
11 KiB
Markdown
280 lines
11 KiB
Markdown
---
|
|
status: draft
|
|
last_updated: 2026-05-20
|
|
---
|
|
|
|
# Error Handling
|
|
|
|
FlowgraphError hierarchy, validation error collection, and error boundaries.
|
|
|
|
## Design Principle
|
|
|
|
Flowgraph follows taskgraph's error handling pattern:
|
|
|
|
1. **Programmer errors throw** — invalid arguments, duplicate node IDs, cycles where acyclicity is enforced
|
|
2. **Operational conditions return structured results** — validation errors, type mismatches, unreachable nodes
|
|
3. **Graph mutations throw on constraint violations** — adding a duplicate node throws `DuplicateNodeError`, adding a cycle-creating edge throws `CycleError`
|
|
|
|
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
|
|
|
|
```typescript
|
|
class FlowgraphError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "FlowgraphError";
|
|
}
|
|
}
|
|
```
|
|
|
|
Base class. All flowgraph errors inherit from this.
|
|
|
|
### ConstructionError
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
type AnyValidationError = ValidationError | GraphValidationError | TypeIncompatError;
|
|
```
|
|
|
|
Union type for all validation errors. Consumers use the `type` discriminator to handle each category:
|
|
|
|
```typescript
|
|
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 `requestId`** in a `call.responded` event → 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 with `status: "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** — `CycleError` includes cycle paths, `InvalidTransitionError` includes from/to status, `TypeIncompatError` includes 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](schema.md) |