diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 77464ad..0ac4528 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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 diff --git a/docs/architecture/build-distribution.md b/docs/architecture/build-distribution.md index 034ee0a..1e45ba0 100644 --- a/docs/architecture/build-distribution.md +++ b/docs/architecture/build-distribution.md @@ -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 diff --git a/docs/architecture/call-graph.md b/docs/architecture/call-graph.md index 25c9c93..187b0e6 100644 --- a/docs/architecture/call-graph.md +++ b/docs/architecture/call-graph.md @@ -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 diff --git a/docs/architecture/error-handling.md b/docs/architecture/error-handling.md index 1561c4f..0cabedd 100644 --- a/docs/architecture/error-handling.md +++ b/docs/architecture/error-handling.md @@ -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 | diff --git a/docs/architecture/host-configs.md b/docs/architecture/host-configs.md index b509391..19ffd5c 100644 --- a/docs/architecture/host-configs.md +++ b/docs/architecture/host-configs.md @@ -48,17 +48,13 @@ When ujsx's reconciler calls `HostConfig.createInstance(tag, props, ...)`, the ` ### Type Parameters ```typescript -const graphologyHost: HostConfig +const graphologyHost: HostConfig ``` - **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 diff --git a/docs/architecture/operation-graph.md b/docs/architecture/operation-graph.md index d99f828..c85d999 100644 --- a/docs/architecture/operation-graph.md +++ b/docs/architecture/operation-graph.md @@ -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). diff --git a/docs/architecture/reactive-execution.md b/docs/architecture/reactive-execution.md index 53ca5f7..b80ee5c 100644 --- a/docs/architecture/reactive-execution.md +++ b/docs/architecture/reactive-execution.md @@ -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("idle"); diff --git a/docs/architecture/schema.md b/docs/architecture/schema.md index b598915..ae5dbfb 100644 --- a/docs/architecture/schema.md +++ b/docs/architecture/schema.md @@ -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; ``` -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 `` component. | -| `conditional` | Template → DAG | Conditional branch from `` component. | +| `sequential` | Template DAG | Sequential ordering from `` component. | +| `conditional` | Template DAG | Conditional branch from `` 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; ``` -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; @@ -294,6 +304,8 @@ type TemplateEdgeAttrs = Static; 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 diff --git a/docs/architecture/workflow-templates.md b/docs/architecture/workflow-templates.md index aa2f2e1..46a2056 100644 --- a/docs/architecture/workflow-templates.md +++ b/docs/architecture/workflow-templates.md @@ -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, {},