fix build/distribution spec: npm deps not workspace, align configs with sibling projects, resolve review issues

- 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
This commit is contained in:
2026-05-20 03:09:57 +00:00
parent eaeba38e71
commit da2973e2a6
9 changed files with 251 additions and 92 deletions

View File

@@ -40,7 +40,7 @@ The operation graph provides static type checking and structural validation. The
| `@alkdev/ujsx` | **Direct dependency**. Workflow templates are `UNode` trees rendered through `HostConfig`s. Flowgraph provides the workflow-specific host configurations (graphology DAG, reactive execution). |
| `@alkdev/taskgraph` | **Pattern reference**. Flowgraph follows the same graphology-wrapping pattern (`FlowGraph` class like `TaskGraph` class) but enforces DAG invariants instead of allowing cycles. |
| `@alkdev/typebox` | **Direct dependency**. All schemas are TypeBox Modules. Runtime validation, JSON Schema export, and `Value.Check`/`Value.Errors`. |
| `@alkdev/pubsub` | **Optional peer dependency**. For event-driven call graph population. Flowgraph works in-memory; pubsub connects it to the call protocol. |
| `@alkdev/pubsub` | **Consumer dependency, not flowgraph's dep**. For event-driven call graph population. The hub coordinator uses pubsub to subscribe to call events and feed them to flowgraph — flowgraph itself works in-memory with no pubsub import. |
| `@alkdev/cograph` | **Future consumer**. The cognitive graph depends on flowgraph for workflow templates and execution tracking. |
## Current State

View File

@@ -74,59 +74,159 @@ Package structure, exports map, dependencies, and platform targets.
## Package JSON
Following the same pattern as `@alkdev/ujsx` and `@alkdev/operations` — conditional exports with explicit `types` and `default` for both import and require:
```json
{
"name": "@alkdev/flowgraph",
"version": "0.1.0",
"description": "Workflow graph library — DAG-based operation orchestration over graphology, with ujsx template composition and reactive execution",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./component": {
"import": "./dist/component/index.js",
"require": "./dist/component/index.cjs"
"/component": {
"import": {
"types": "./dist/component/index.d.ts",
"default": "./dist/component/index.js"
},
"require": {
"types": "./dist/component/index.d.cts",
"default": "./dist/component/index.cjs"
}
},
"./host": {
"import": "./dist/host/index.js",
"require": "./dist/host/index.cjs"
"/host": {
"import": {
"types": "./dist/host/index.d.ts",
"default": "./dist/host/index.js"
},
"require": {
"types": "./dist/host/index.d.cts",
"default": "./dist/host/index.cjs"
}
},
"./schema": {
"import": "./dist/schema/index.js",
"require": "./dist/schema/index.cjs"
"/schema": {
"import": {
"types": "./dist/schema/index.d.ts",
"default": "./dist/schema/index.js"
},
"require": {
"types": "./dist/schema/index.d.cts",
"default": "./dist/schema/index.cjs"
}
},
"./graph": {
"import": "./dist/graph/index.js",
"require": "./dist/graph/index.cjs"
"/graph": {
"import": {
"types": "./dist/graph/index.d.ts",
"default": "./dist/graph/index.js"
},
"require": {
"types": "./dist/graph/index.d.cts",
"default": "./dist/graph/index.cjs"
}
},
"./reactive": {
"import": "./dist/reactive/index.js",
"require": "./dist/reactive/index.cjs"
"/reactive": {
"import": {
"types": "./dist/reactive/index.d.ts",
"default": "./dist/reactive/index.js"
},
"require": {
"types": "./dist/reactive/index.d.cts",
"default": "./dist/reactive/index.cjs"
}
},
"./analysis": {
"import": "./dist/analysis/index.js",
"require": "./dist/analysis/index.cjs"
"/analysis": {
"import": {
"types": "./dist/analysis/index.d.ts",
"default": "./dist/analysis/index.js"
},
"require": {
"types": "./dist/analysis/index.d.cts",
"default": "./dist/analysis/index.cjs"
}
},
"./error": {
"import": "./dist/error/index.js",
"require": "./dist/error/index.cjs"
"/error": {
"import": {
"types": "./dist/error/index.d.ts",
"default": "./dist/error/index.js"
},
"require": {
"types": "./dist/error/index.d.cts",
"default": "./dist/error/index.cjs"
}
}
},
"typesVersions": {
"*": {
"component": ["./dist/component/index.d.ts"],
"host": ["./dist/host/index.d.ts"],
"schema": ["./dist/schema/index.d.ts"],
"graph": ["./dist/graph/index.d.ts"],
"reactive": ["./dist/reactive/index.d.ts"],
"analysis": ["./dist/analysis/index.d.ts"],
"error": ["./dist/error/index.d.ts"]
}
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"build:tsc": "tsc",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "tsc --noEmit",
"prepublishOnly": "npm run build"
},
"keywords": [
"flowgraph",
"dag",
"workflow",
"graphology",
"ujsx",
"operations"
],
"license": "MIT OR Apache-2.0",
"dependencies": {
"@alkdev/typebox": "^0.34.49",
"@alkdev/ujsx": "^0.1.0",
"@preact/signals-core": "^1.14.1",
"graphology": "^0.26.0",
"graphology-dag": "^0.4.1"
},
"peerDependencies": {
"@alkdev/operations": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@vitest/coverage-v8": "^3.2.4",
"tsup": "^8.5.1",
"typescript": "^5.7.0",
"vitest": "^3.1.0"
},
"engines": {
"node": ">=18.0.0"
}
}
```
### Exports Map: Sub-path Imports
The exports map uses the `"/subpath"` format (with leading slash) to match the pattern used by `@alkdev/ujsx`. This is the Node.js subpath exports convention — consumers import as:
```typescript
import { FlowGraph } from "@alkdev/flowgraph/graph";
import { typeCompat } from "@alkdev/flowgraph/analysis";
import { h } from "@alkdev/ujsx";
import { createRoot } from "@alkdev/ujsx/reactive";
```
Each sub-path has conditional exports for both `import` (ESM) and `require` (CJS), with separate `types` files (`.d.ts` and `.d.cts`). The `typesVersions` field is NOT used — conditional exports with `types` entries replace it for modern Node.js resolution.
## Exports Map
Following the taskgraph pattern, each module has a sub-path export:
@@ -149,14 +249,14 @@ Following the taskgraph pattern, each module has a sub-path export:
```json
{
"dependencies": {
"@alkdev/typebox": "workspace:*",
"@alkdev/ujsx": "workspace:*",
"@preact/signals-core": "^1.x",
"graphology": "^0.25",
"graphology-dag": "^0.4"
"@alkdev/typebox": "^0.34.49",
"@alkdev/ujsx": "^0.1.0",
"@preact/signals-core": "^1.14.1",
"graphology": "^0.26.0",
"graphology-dag": "^0.4.1"
},
"peerDependencies": {
"@alkdev/operations": "workspace:*"
"@alkdev/operations": "^0.1.0"
}
}
```
@@ -165,7 +265,7 @@ Following the taskgraph pattern, each module has a sub-path export:
|---------|------|-----|
| `@alkdev/typebox` | Schema definitions, validation, `Value.Check`, `Value.Errors` | Direct dependency — all schemas are TypeBox |
| `@alkdev/ujsx` | UNode, HostConfig, createRoot, h(), ReactiveRoot | Direct dependency — workflow templates are ujsx trees |
| `@preact/signals-core` | `signal`, `computed`, `effect`, `batch` | Transitive via ujsx, re-exported for flowgraph's reactive layer |
| `@preact/signals-core` | `signal`, `computed`, `effect`, `batch` | Direct dependency — flowgraph's reactive layer uses signals directly for WorkflowReactiveRoot |
| `graphology` | `DirectedGraph` data structure | Core graph engine — same as taskgraph |
| `graphology-dag` | `topologicalSort`, `hasCycle`, `parallelGroups` | DAG-specific algorithms |
| `@alkdev/operations` | `OperationSpec`, `CallEventMap`, `CallStatus` | Peer dependency — type imports only, no runtime dependency |
@@ -178,42 +278,49 @@ Flowgraph imports `OperationSpec`, `CallEventMap`, and `CallStatus` types from `
2. Allows flowgraph to work with any version of operations that provides the right types
3. Reduces bundle size for consumers that don't use operations
### Why `@preact/signals-core` via `@alkdev/ujsx`
### Why `@preact/signals-core` is a Direct Dependency
Flowgraph's reactive layer uses `signal()`, `computed()`, and `effect()` from `@preact/signals-core`. These are re-exported from `@alkdev/ujsx/reactive` so consumers don't need to import directly from Preact. If ujsx ever changes its reactive primitive library, only ujsx's re-export needs updating.
While `@preact/signals-core` is also a dependency of `@alkdev/ujsx`, flowgraph lists it as a direct dependency because `WorkflowReactiveRoot` creates `signal()` and `computed()` instances directly. This makes the dependency explicit and ensures version alignment. Consumers import signals from flowgraph's reactive exports, not directly from Preact — the `@alkdev/ujsx/reactive` module re-exports the same signal primitives for consumers who need them, but flowgraph's own reactive module depends on `@preact/signals-core` directly for its internal signal graph construction.
### No `workspace:*` — Published npm Packages
All `@alkdev` packages are independently published to npm. The dependency versions use semver ranges (`^0.34.49`, `^0.1.0`) matching the pattern used by all sibling packages. During local development, the packages exist as local repos on disk, but they are not linked via a monorepo workspace protocol. Each package is built and published independently.
## Build Configuration
### tsup.config.ts
Following taskgraph's build pattern:
Following the same pattern as `@alkdev/taskgraph` and `@alkdev/ujsx` — named entry points matching source file paths, no `splitting` flag (ujsx doesn't use it either):
```typescript
import { defineConfig } from "tsup";
import { defineConfig } from 'tsup';
export default defineConfig({
entry: {
index: "src/index.ts",
component: "src/component/index.ts",
host: "src/host/index.ts",
schema: "src/schema/index.ts",
graph: "src/graph/index.ts",
reactive: "src/reactive/index.ts",
analysis: "src/analysis/index.ts",
error: "src/error/index.ts",
index: 'src/index.ts',
'component/index': 'src/component/index.ts',
'host/index': 'src/host/index.ts',
'schema/index': 'src/schema/index.ts',
'graph/index': 'src/graph/index.ts',
'reactive/index': 'src/reactive/index.ts',
'analysis/index': 'src/analysis/index.ts',
'error/index': 'src/error/index.ts',
},
format: ["esm", "cjs"],
format: ['esm', 'cjs'],
dts: true,
clean: true,
splitting: true,
sourcemap: true,
clean: true,
target: 'es2022',
});
```
- **ESM + CJS dual output** — matches all sibling packages
- **Code splitting** — enables tree-shaking for sub-path imports
- **Source maps** — for debugging
- **Type declarations** — `.d.ts` files for all exports
- **`target: 'es2022'`** — matches tsconfig target, ensures consistent output
- **Named entry points with path prefixes** — matches ujsx pattern (e.g., `'core/schema': 'src/core/schema.ts'`), producing output like `dist/component/index.js` consistent with the exports map
Note: The `splitting: true` option was in an earlier draft but has been removed. The sibling projects (taskgraph, ujsx, operations) don't use splitting, and the named entry points with sub-paths already provide natural code boundaries for tree-shaking.
### tsconfig.json
@@ -221,31 +328,55 @@ export default defineConfig({
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"types": ["vitest/globals"]
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"erasableSyntaxOnly": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "test"]
}
```
Matches the tsconfig pattern of all `@alkdev` packages: ES2022 target, Node16 module resolution, strict mode.
Matches the tsconfig patterns of all `@alkdev` packages: ES2022 target, Node16 module resolution, strict mode with additional safety checks (`noUncheckedIndexedAccess`, `noUnusedLocals`, `noUnusedParameters`, `noFallthroughCasesInSwitch`, `erasableSyntaxOnly`).
### vitest.config.ts
Following the same pattern as `@alkdev/taskgraph` and `@alkdev/ujsx`:
```typescript
import { defineConfig } from "vitest/config";
import { defineConfig } from 'vitest/config';
import path from 'node:path';
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
test: {
globals: true,
include: ['test/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['src/**/index.ts'],
},
},
});
```
@@ -354,8 +485,8 @@ All error paths should be tested:
- **No network access** — no HTTP clients, WebSocket connections, or Redis clients. All data comes in through constructor arguments.
- **`@alkdev/operations` is a peer dependency** — type imports only, no runtime dependency on the operations registry or call protocol.
- **ESM-first** — the package is authored in ESM with CJS output generated by tsup. All internal imports use `.js` extensions for Node16 module resolution.
- **Code splitting enabled** — tsup's `splitting: true` enables optimal code splitting for sub-path imports.
- **Vitest for testing** — following the monorepo convention.
- **Sub-path exports provide natural code boundaries** — named entry points in tsup match source file paths, enabling effective tree-shaking without `splitting: true`.
- **Vitest for testing** — following the convention of all `@alkdev` packages.
## References

View File

@@ -99,7 +99,7 @@ Call graph edges carry an `edgeType` attribute:
`triggered` edges use `${parentRequestId}->${childRequestId}` as the edge key. `depends_on` edges use `${sourceRequestId}->${targetRequestId}:depends_on` to distinguish from `triggered` edges between the same pair.
Since `multi: false`, there can be at most one `triggered` and one `depends_on` edge between the same pair. The edge key convention ensures deterministic keys.
This composite key format is necessary because `multi: false` allows at most one edge per key between a given (source, target) pair. Since a call graph can have both a `triggered` edge (parent→child) and a `depends_on` edge (data dependency) between the same pair of calls, the edge type suffix in the key disambiguates them. See [schema.md#edge-key-convention](schema.md) for the general key convention and the discussion of multi-edge support.
## Status Lifecycle

View File

@@ -25,7 +25,8 @@ FlowgraphError # Base class for all flowgraph errors
│ ├── DuplicateNodeError # Duplicate node key
│ ├── DuplicateEdgeError # Duplicate edge key
│ ├── NodeNotFoundError # Referenced node doesn't exist
── CycleError # Adding an edge would create a cycle
── 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
@@ -117,6 +118,19 @@ Thrown when adding an edge would create a cycle. The `cycles` field contains the
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
@@ -231,6 +245,7 @@ The distinction between thrown errors and returned errors:
| `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 |

View File

@@ -48,17 +48,13 @@ When ujsx's reconciler calls `HostConfig.createInstance(tag, props, ...)`, the `
### Type Parameters
```typescript
const graphologyHost: HostConfig<WorkflowTag, Graph, GraphContext>
const graphologyHost: HostConfig<WorkflowTag, GraphNode, GraphContext>
```
- **TTag**: `WorkflowTag`
- **Instance**: `Graph` (the graphology `DirectedGraph` instance — every element creates a subgraph reference)
- **Instance**: `GraphNode` (a logical representation of what each template node becomes in the graph)
- **RootCtx**: `GraphContext` (the root context carrying the graph and metadata)
Wait — this needs refinement. In graphology, instances aren't subgraphs. Let me reconsider.
Actually, the GraphologyHostConfig's `Instance` type is a logical representation of what each template node becomes:
```typescript
interface GraphNode {
key: string; // The graphology node key

View File

@@ -54,7 +54,7 @@ Round-trip: `fromSpecs()` → `export()` → `fromJSON()` is lossless.
```typescript
const graph = new FlowGraph();
graph.addOperation(spec);
graph.addTypedEdge("task.classify", "task.enrich", { compatible: true, detail: "output → input" });
graph.addTypedEdge("task.classify", "task.enrich", { compatible: true, detail: "output → input", mismatches: [] });
```
`addOperation` adds a node. `addTypedEdge` adds a type-compatibility edge. Both throw on duplicates (matching taskgraph's behavior).

View File

@@ -47,7 +47,9 @@ class WorkflowReactiveRoot {
private initializeSignals(): void {
for (const node of this.graph.nodes()) {
const attrs = this.graph.getNodeAttributes(node);
if (attrs.category !== "operation") continue; // Skip structural nodes (already flattened)
// In the flattened DAG from GraphologyHostConfig, all nodes represent
// operations (structural containers like Sequential/Parallel are transparent
// and create no nodes). No filtering needed — every node gets a signal.
const status = signal<NodeStatus>("idle");

View File

@@ -148,7 +148,7 @@ Flowgraph's `fromCallEvents()` and `updateFromEvent()` accept this type directly
### EdgeType
The type of edge in a flowgraph. Matches the call graph storage schema's `edgeType` column:
The type of edge in a flowgraph. Matches the call graph storage schema's `edgeType` column. This is a universal enum that covers all graph modes (operation, call, template), but each graph mode uses only a subset:
```typescript
const EdgeTypeEnum = Type.Union([
@@ -161,15 +161,19 @@ const EdgeTypeEnum = Type.Union([
type EdgeType = Static<typeof EdgeTypeEnum>;
```
The first three (`triggered`, `depends_on`) match the call graph storage schema. The last two (`sequential`, `conditional`) are template-specific and only exist in workflow template DAGs.
| Edge Type | Graph Type | Meaning |
| Edge Type | Graph Mode | Meaning |
|-----------|------------|---------|
| `triggered` | Call graph | Parent call triggered child call. Corresponds to `parentRequestId`. |
| `depends_on` | Call graph | Data dependency — source needs target's result. |
| `typed` | Operation graph | Type compatibility — source's output schema is compatible with target's input schema. |
| `sequential` | Template DAG | Sequential ordering from `<Sequential>` component. |
| `conditional` | Template DAG | Conditional branch from `<Conditional>` component. |
| `sequential` | Template DAG | Sequential ordering from `<Sequential>` component. |
| `conditional` | Template DAG | Conditional branch from `<Conditional>` component. |
`EdgeTypeEnum` is the universal enumeration. Each graph mode constrains its edge types through its specific edge attribute schemas:
- **Operation graphs** only use `typed` edges (`OperationEdgeAttrs`)
- **Call graphs** use `triggered` and `depends_on` edges (`CallEdgeAttrs`)
- **Template DAGs** use `sequential` and `conditional` edges (`TemplateEdgeAttrs`)
## Node Attribute Schemas
@@ -236,21 +240,27 @@ The node key is `requestId`. This matches the call protocol's correlation mechan
```typescript
const OperationEdgeAttrs = Type.Object({
compatible: Type.Boolean({ description: "Whether the source output schema is compatible with the target input schema" }),
compatibilityDetail: Type.Optional(Type.String({ description: "Human-readable description of compatibility or mismatch" })),
detail: Type.Optional(Type.String({ description: "Human-readable description of compatibility or mismatch" })),
mismatches: Type.Optional(Type.Array(Type.Object({ // Structured mismatch details (populated when compatible: false)
path: Type.String(),
expected: Type.String(),
actual: Type.String(),
}))),
});
type OperationEdgeAttrs = Static<typeof OperationEdgeAttrs>;
```
Type-compatibility edges carry a boolean `compatible` flag and optional detail. This allows the operation graph to include both compatible edges (green paths) and incompatible edges (red paths) for diagnostics.
Type-compatibility edges carry a boolean `compatible` flag, an optional `detail` string, and optional structured `mismatches`. This allows the operation graph to include both compatible edges (green paths) and incompatible edges (red paths) for diagnostics. The `detail` field provides a human-readable summary, while `mismatches` provides machine-readable field-level diagnostics. The `TypeCompatResult` from `typeCompat()` populates both fields: `detail` for compatible edges and `mismatches` for incompatible ones.
**Edge type storage**: Operation graph edges always have `edgeType: "typed"` stored on the edge as a separate attribute alongside `OperationEdgeAttrs`. Graphology edges carry both the `OperationEdgeAttrs` (compatible, compatibilityDetail) and the `edgeType` field. The `edgeType` is not inside `OperationEdgeAttrs` because it's a universal edge classification that applies to all edge types across all graph modes (operation, call, template). The `OperationEdgeAttrs` schema only defines the mode-specific attributes.
**Edge type storage**: Operation graph edges always have `edgeType: "typed"` stored on the edge as a separate attribute alongside `OperationEdgeAttrs`. Graphology edges carry both the `OperationEdgeAttrs` (compatible, detail, mismatches) and the `edgeType` field. The `edgeType` is not inside `OperationEdgeAttrs` because it's a universal edge classification that applies to all edge types across all graph modes (operation, call, template). The `OperationEdgeAttrs` schema only defines the mode-specific attributes.
```typescript
// How operation graph edges are stored in graphology:
{
edgeType: "typed", // Universal classification (stored alongside attrs)
compatible: true, // OperationEdgeAttrs field
compatibilityDetail: "..." // OperationEdgeAttrs field
detail: "classify.output → enrich.input", // OperationEdgeAttrs field
mismatches: [] // Empty when compatible
}
```
@@ -286,7 +296,7 @@ A union type used as the edge attribute type parameter for call graphs (`FlowGra
```typescript
const TemplateEdgeAttrs = Type.Object({
edgeType: EdgeTypeEnum, // "sequential" or "conditional"
edgeType: Type.Union([Type.Literal("sequential"), Type.Literal("conditional")]),
condition: Type.Optional(Type.Unknown()), // For conditional edges: the condition function or expression
});
type TemplateEdgeAttrs = Static<typeof TemplateEdgeAttrs>;
@@ -294,6 +304,8 @@ type TemplateEdgeAttrs = Static<typeof TemplateEdgeAttrs>;
Template edges carry an `edgeType` to distinguish sequential flow from conditional branching. Conditional edges optionally store a `condition` that determines whether the target node executes.
**Note**: `TemplateEdgeAttrs.edgeType` uses a constrained union of `"sequential" | "conditional"` rather than the full `EdgeTypeEnum`. Template DAGs never have `triggered`, `depends_on`, or `typed` edges — those belong to call graphs and operation graphs respectively.
### TemplateNodeAttrs (Workflow Templates)
Template DAGs use `OperationNodeAttrs` for their operation nodes — the template doesn't need a separate node type because every node in a template DAG corresponds to an operation invocation. The template's structural information (`Sequential`, `Parallel`, `Conditional`, `Map`) is expressed through edges, not through special node types.
@@ -372,14 +384,17 @@ ${source}->${target}
For the operation graph, this means keys like `"task.classify->task.enrich"`. For the call graph, keys like `"req_abc123->req_def456"`.
Since `multi: false`, there can be at most one edge between any (source, target) pair. When multiple edge types are needed between the same pair (e.g., both `triggered` and `depends_on` between two calls), the graph stores a single edge whose `edgeType` attribute captures the semantic relationship. This is a simplification from the storage schema, which allows multiple edges per (source, target, edgeType) triple — the in-memory graph collapses these into a single edge per (source, target) pair.
When multiple edge types exist between the same (source, target) pair (e.g., in the call graph where both `triggered` and `depends_on` edges can connect the same calls), a composite key format is used:
This is acceptable because:
- Operation graphs only have `typed` edges, so no multi-edge concern.
- Call graphs rarely have both `triggered` and `depends_on` between the same pair.
- Template DAGs only have `sequential` or `conditional` edges.
```
${source}->${target}:${edgeType}
```
If multi-edge support becomes necessary, the `allowSelfLoops: false` constraint can be relaxed and a composite key format (`${source}->${target}:${edgeType}`) adopted.
For example, a `depends_on` edge in the call graph uses `"req_abc123->req_def456:depends_on"` while the `triggered` edge between the same pair uses `"req_abc123->req_def456"`.
Since `multi: false`, there can be at most one edge per key. The composite key format ensures deterministic keys even when multiple edge types connect the same pair.
This is an exception to the simple `${source}->${target}` pattern, but it's necessary for the call graph's dual-edge-type scenario. If multi-edge support becomes more broadly needed, the constraint can be relaxed and a uniform composite key format adopted.
## Constraints

View File

@@ -271,7 +271,7 @@ The `ReactiveHostConfig` renders a template to a reactive execution engine:
import { createRoot } from "@alkdev/ujsx";
import { ReactiveHostConfig } from "@alkdev/flowgraph/host/reactive";
const host = new ReactiveHostConfig(operationRegistry);
const host = new ReactiveHostConfig(operationRegistry, workflowRoot);
const root = createRoot(host, {});
const template = h(Sequential, {},