Compare commits
31 Commits
feat/graph
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 058b437c3f | |||
| 6fb633c05b | |||
| e45b8c0cc0 | |||
| fab0b64f0b | |||
| dd96ceb4f8 | |||
| 4fcd544261 | |||
| 1233266ffd | |||
| b297203a5d | |||
| 4df8698a3d | |||
| 1a12410229 | |||
| 72356b9758 | |||
| 793b6e513f | |||
| 8b7f4f985a | |||
| a72c3173e7 | |||
| ee3da90b63 | |||
| 270bd7cd69 | |||
| e8b62c0c58 | |||
| 000a1e04c5 | |||
| e98204161d | |||
| 4cf644f734 | |||
| d63a301cea | |||
| 18999fb38e | |||
| 7a4e430aa9 | |||
| c7c6d13d6b | |||
| c57a8558c7 | |||
| fa2223b90b | |||
| d7fea47214 | |||
| 67907dc0f3 | |||
| fb921f9a29 | |||
| 3b52998f20 | |||
| 5cfc8882bd |
114
AGENTS.md
114
AGENTS.md
@@ -1,60 +1,86 @@
|
||||
## Memory Tools (via @alkdev/open-memory plugin)
|
||||
# @alkdev/flowgraph — Agent Guide
|
||||
|
||||
You have access to two tools for managing your context and accessing session history:
|
||||
Project-specific context for AI agents working on this codebase.
|
||||
|
||||
### memory({tool: "...", args: {...}})
|
||||
## What This Is
|
||||
|
||||
Read-only tool for introspecting your session history and context state. Available operations:
|
||||
- `memory({tool: "help"})` — full reference with examples
|
||||
- `memory({tool: "summary"})` — quick counts of projects, sessions, messages, todos
|
||||
- `memory({tool: "sessions"})` — list recent sessions (useful for finding past work)
|
||||
- `memory({tool: "children", args: {sessionId: "ses_..."}})` — list sub-agent sessions spawned from a parent
|
||||
- `memory({tool: "messages", args: {sessionId: "..."}})` — read a session's conversation
|
||||
- `memory({tool: "messages", args: {sessionId: "...", role: "assistant"}})` — read only assistant messages
|
||||
- `memory({tool: "messages", args: {sessionId: "...", showTools: true}})` — include tool-call output
|
||||
- `memory({tool: "message", args: {messageId: "msg_..."}})` — read a single message by ID
|
||||
- `memory({tool: "search", args: {query: "..."}})` — search across all conversations
|
||||
- `memory({tool: "compactions", args: {sessionId: "..."}})` — view compaction checkpoints
|
||||
- `memory({tool: "context"})` — check your current context window usage
|
||||
DAG-based workflow orchestration library. Wraps graphology `DirectedGraph`, enforces DAG invariants, provides ujsx template composition and reactive signal-driven execution. Sits between `@alkdev/operations` (what can be called) and `@alkdev/alkhub` (what was called) — defines *how calls are orchestrated*.
|
||||
|
||||
### memory_compact()
|
||||
## Build & Test Commands
|
||||
|
||||
Trigger compaction on the current session. This summarizes the conversation so far to free context space.
|
||||
```bash
|
||||
npm run build # tsup (ESM + CJS + dts + sourcemaps)
|
||||
npm run build:tsc # tsc type-check only
|
||||
npm run lint # tsc --noEmit
|
||||
npm run test # vitest run
|
||||
npm run test:watch # vitest watch
|
||||
npm run test:coverage # vitest run --coverage
|
||||
```
|
||||
|
||||
**When to use memory_compact:**
|
||||
- When context is above 80% (check with `memory({tool: "context"})`)
|
||||
- When you notice you're losing track of earlier conversation details
|
||||
- At natural breakpoints in multi-step tasks (after completing a subtask, before starting a new one)
|
||||
- When the system prompt shows a yellow/red/critical context warning
|
||||
- Proactively, rather than waiting for automatic compaction at 92%
|
||||
Always run `npm run lint && npm run test` after making changes.
|
||||
|
||||
**When NOT to use memory_compact:**
|
||||
- When context is below 50% (it wastes a compaction cycle)
|
||||
- In the middle of a complex edit that you need immediate context for
|
||||
- When the task is nearly complete (just finish the task instead)
|
||||
## Source Structure
|
||||
|
||||
Compaction preserves your most important context in a structured summary — you will continue the session with the summary as your starting point.
|
||||
```
|
||||
src/
|
||||
index.ts # Barrel — re-exports all sub-modules
|
||||
component/ # ujsx workflow components (Operation, Sequential, Parallel, Conditional, Map)
|
||||
host/ # HostConfig implementations (GraphologyHostConfig, ReactiveHostConfig)
|
||||
schema/ # TypeBox schemas, enums, node/edge attribute types
|
||||
graph/ # FlowGraph class (construction, mutation, query, serialization)
|
||||
reactive/ # WorkflowReactiveRoot, signal-driven status, event log projection
|
||||
analysis/ # Pure functions: typeCompat, validate, topologicalOrder, parallelGroups, criticalPath
|
||||
error/ # FlowgraphError hierarchy
|
||||
```
|
||||
|
||||
## Worktree Tool (via @alkimiadev/open-coordinator plugin)
|
||||
## Subpath Exports
|
||||
|
||||
You have access to the `worktree` tool for git worktree management and session coordination. Call with `{action, args}`:
|
||||
The package has 8 export subpaths. Root `@alkdev/flowgraph` re-exports everything. Subpath imports make dependencies explicit:
|
||||
|
||||
### Coordinator Operations (available when session is not spawned by another session)
|
||||
| Subpath | Key Exports |
|
||||
|---------|-------------|
|
||||
| `/graph` | `FlowGraph`, `FlowGraphOptions`, `OperationSpec`, `CallEventMapValue` |
|
||||
| `/schema` | `CallStatus`, `NodeStatus`, `EdgeType`, `OperationType`, all node/edge attribute types |
|
||||
| `/component` | `Operation`, `Sequential`, `Parallel`, `Conditional`, `Map` |
|
||||
| `/host` | `GraphologyHostConfig`, `ReactiveHostConfig` |
|
||||
| `/analysis` | `typeCompat`, `buildTypeEdges`, `validateGraph`, `validateSchema`, `validate`, `validateTemplate`, `topologicalOrder`, `parallelGroups`, `criticalPath` |
|
||||
| `/reactive` | `WorkflowReactiveRoot`, `EventLogProjection`, `WorkflowNode`, `FailurePolicy` |
|
||||
| `/error` | `FlowgraphError`, `ConstructionError`, `CycleError`, `InvalidInputError`, `InvalidTransitionError` |
|
||||
|
||||
- `worktree({action: "list"})` — List git worktrees
|
||||
- `worktree({action: "dashboard"})` — Worktree dashboard with session info
|
||||
- `worktree({action: "spawn", args: {tasks: [...], prompt: "..."}})` — Spawn parallel worktrees + sessions
|
||||
- `worktree({action: "sessions"})` — Query spawned session status
|
||||
- `worktree({action: "message", args: {sessionID: "...", message: "..."}})` — Message a session
|
||||
- `worktree({action: "abort", args: {sessionID: "..."}})` — Abort a session
|
||||
- `worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "..."}})` — Remove worktree
|
||||
- `worktree({action: "help"})` — Show all available operations
|
||||
## Key Patterns
|
||||
|
||||
### Implementation Operations (available when session is spawned by a coordinator)
|
||||
- **TypeBox schema + Static type pairs**: Every schema is exported as both a const (runtime) and an inferred type. `const FooSchema = Type.Object({...}); type Foo = Static<typeof FooSchema>;`
|
||||
- **Delegation model**: `FlowGraph` wraps a graphology `DirectedGraph` (does not extend it). `flowGraph.graph` is an escape hatch that bypasses validation.
|
||||
- **DAG enforcement**: `addEdge()` throws `CycleError` if the edge would create a cycle. `fromJSON()` validates DAG invariants on deserialization.
|
||||
- **Event log as source of truth**: Call protocol events (`call.requested`, `call.responded`, `call.error`, `call.aborted`, `call.completed`) are appended to `WorkflowReactiveRoot`. Status/results are derived projections.
|
||||
- **Signal-driven execution**: `@preact/signals-core` powers `WorkflowReactiveRoot`. `preconditions`, `canStart`, `blockedByFailure` are `ReadonlySignal<boolean>` computed from predecessor status.
|
||||
- **`dispose()` is mandatory**: `WorkflowReactiveRoot.dispose()` must be called to release signal subscriptions.
|
||||
|
||||
- `worktree({action: "current"})` — Show your worktree mapping
|
||||
- `worktree({action: "notify", args: {message: "...", level: "info|blocking"}})` — Report to coordinator
|
||||
- `worktree({action: "status"})` — Show worktree git status
|
||||
## Architecture Docs
|
||||
|
||||
The plugin auto-injects `workdir` for bash commands when the session is mapped to a worktree.
|
||||
`docs/architecture/` contains detailed specs (all `status: reviewed`):
|
||||
|
||||
- `README.md` — overview, relationship to sibling packages, design decisions
|
||||
- `flowgraph-api.md` — FlowGraph class full API
|
||||
- `consumer-integration.md` — end-to-end integration walkthrough (5 phases)
|
||||
- `schema.md` — TypeBox Module, all node/edge attribute schemas
|
||||
- `operation-graph.md` — static graph from OperationSpecs
|
||||
- `call-graph.md` — dynamic graph from call events
|
||||
- `workflow-templates.md` — ujsx components, composition rules, template→DAG hydration
|
||||
- `host-configs.md` — GraphologyHostConfig, ReactiveHostConfig
|
||||
- `reactive-execution.md` — signal-driven status propagation, lifecycle, error boundaries
|
||||
- `analysis.md` — type-compatibility checking, precondition validation, execution ordering
|
||||
- `error-handling.md` — FlowgraphError hierarchy
|
||||
- `build-distribution.md` — package structure, exports map
|
||||
- `decisions/` — ADRs 001–006
|
||||
|
||||
Consult these for anything non-trivial. The README is surface-level; architecture docs are the specification.
|
||||
|
||||
## Constraints for Agent Modifications
|
||||
|
||||
- **Never add cycles** — this is a DAG-only library. Any edge that would create a cycle must throw `CycleError`.
|
||||
- **Never mutate operation graphs after construction** — `fromSpecs()` graphs are conventionally immutable.
|
||||
- **Keep schema as pure data** — `src/schema/` contains TypeBox schemas and types only, no runtime logic.
|
||||
- **Keep analysis as pure functions** — `src/analysis/` functions take a `FlowGraph` or `DirectedGraph` as input and return results without side effects.
|
||||
- **Maintain the delegation model** — `FlowGraph` delegates to graphology. Don't expose raw graphology methods that could violate DAG invariants without explicit validation.
|
||||
- **tsup builds all subpath entries** — `tsup.config.ts` lists every subpath. If you add a new top-level module, add the entry there and update `package.json` exports.
|
||||
6
LICENSE
Normal file
6
LICENSE
Normal file
@@ -0,0 +1,6 @@
|
||||
This project is dual-licensed under either of the following:
|
||||
|
||||
- MIT License (LICENSE-MIT)
|
||||
- Apache License 2.0 (LICENSE-APACHE)
|
||||
|
||||
You may choose either license at your option.
|
||||
190
LICENSE-APACHE
Normal file
190
LICENSE-APACHE
Normal file
@@ -0,0 +1,190 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable by
|
||||
such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding any notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed as
|
||||
modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limitation damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2026 alkdev
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
21
LICENSE-MIT
Normal file
21
LICENSE-MIT
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 alkdev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
320
README.md
Normal file
320
README.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# @alkdev/flowgraph
|
||||
|
||||
DAG-based workflow orchestration over graphology, with ujsx template composition and reactive signal-driven execution.
|
||||
|
||||
## What This Does
|
||||
|
||||
Flowgraph sits between `@alkdev/operations` (which defines *what can be called*) and `@alkdev/alkhub` (which records *what was called*). Flowgraph defines **how calls are orchestrated** — the structure, validation, and execution of workflows.
|
||||
|
||||
Three conceptual graphs, each for a different purpose:
|
||||
|
||||
1. **Operation Graph** — static graph built from `OperationSpec`s at startup. Nodes are operations, edges are type-compatibility relationships. Enables cycle detection, topological ordering, and validation.
|
||||
2. **Call Graph** — dynamic graph built from call protocol events at runtime. Nodes are call invocations with status/timestamps, edges are parent-child relationships. Enables abort cascading and observability.
|
||||
3. **Workflow Template** — declarative ujsx tree defining a reusable workflow structure. A validated path through the operation graph, instantiated as a call graph at runtime.
|
||||
|
||||
**The graph is the specification. The template is the authoring surface. The call graph is the execution record.**
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @alkdev/flowgraph
|
||||
```
|
||||
|
||||
Peer dependency: `@alkdev/operations ^0.1.0`
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Build an Operation Graph
|
||||
|
||||
```typescript
|
||||
import { FlowGraph } from "@alkdev/flowgraph/graph";
|
||||
import type { OperationSpec } from "@alkdev/flowgraph/graph";
|
||||
|
||||
const specs: OperationSpec[] = [
|
||||
{ namespace: "task", name: "classify", type: "query", version: "1.0.0", inputSchema: {...}, outputSchema: {...} },
|
||||
{ namespace: "task", name: "enrich", type: "query", version: "1.0.0", inputSchema: {...}, outputSchema: {...} },
|
||||
{ namespace: "task", name: "summarize", type: "mutation", version: "1.0.0", inputSchema: {...}, outputSchema: {...} },
|
||||
];
|
||||
|
||||
const graph = FlowGraph.fromSpecs(specs);
|
||||
// Type-compatibility edges added automatically
|
||||
graph.hasEdge("task.classify", "task.enrich");
|
||||
```
|
||||
|
||||
### Define a Workflow Template
|
||||
|
||||
```typescript
|
||||
import { h } from "@alkdev/ujsx";
|
||||
import { Operation, Sequential, Parallel, Conditional } from "@alkdev/flowgraph/component";
|
||||
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "task.classify" }),
|
||||
h(Conditional, {
|
||||
test: (results) => results["task.classify"].output.confidence > 0.8,
|
||||
},
|
||||
h(Parallel, {},
|
||||
h(Operation, { name: "task.enrich" }),
|
||||
h(Operation, { name: "task.summarize" }),
|
||||
),
|
||||
h(Operation, { name: "task.classify" }),
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
### Validate the Template
|
||||
|
||||
```typescript
|
||||
import { validateTemplate } from "@alkdev/flowgraph/analysis";
|
||||
|
||||
const errors = validateTemplate(template, graph);
|
||||
if (errors.length > 0) {
|
||||
for (const error of errors) {
|
||||
console.error(`[${error.type}]`, error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Populate a Call Graph from Events
|
||||
|
||||
```typescript
|
||||
import { FlowGraph } from "@alkdev/flowgraph/graph";
|
||||
|
||||
const callGraph = FlowGraph.fromCallEvents(eventArray);
|
||||
callGraph.filterByStatus("running");
|
||||
callGraph.children("req_abc123");
|
||||
callGraph.lineage("req_xyz789");
|
||||
callGraph.duration("req_abc123");
|
||||
```
|
||||
|
||||
### Drive Reactive Execution
|
||||
|
||||
```typescript
|
||||
import { WorkflowReactiveRoot } from "@alkdev/flowgraph/reactive";
|
||||
|
||||
const workflow = new WorkflowReactiveRoot(dag, {
|
||||
failurePolicy: "abort-dependents",
|
||||
});
|
||||
|
||||
// Append call protocol events — status derives reactively
|
||||
workflow.append({ type: "call.requested", requestId: "req_1", operationId: "task.classify", input: {}, timestamp: "..." });
|
||||
workflow.append({ type: "call.responded", requestId: "req_1", output: { confidence: 0.95 }, timestamp: "..." });
|
||||
|
||||
// Read reactive state
|
||||
workflow.getStatus("task.enrich");
|
||||
workflow.getResult("task.classify");
|
||||
|
||||
// Abort cascading
|
||||
workflow.abortAll();
|
||||
workflow.dispose();
|
||||
```
|
||||
|
||||
## Subpath Exports
|
||||
|
||||
| Subpath | Purpose | Key Exports |
|
||||
|---------|---------|-------------|
|
||||
| `@alkdev/flowgraph` | Root — re-exports everything | All public types and functions |
|
||||
| `@alkdev/flowgraph/graph` | Core DAG class | `FlowGraph`, `FlowGraphOptions`, `OperationSpec`, `CallEventMapValue` |
|
||||
| `@alkdev/flowgraph/schema` | TypeBox schemas and types | `CallStatus`, `NodeStatus`, `EdgeType`, `OperationType`, `CallNodeAttrs`, `OperationNodeAttrs`, `OperationEdgeAttrs`, `CallEdgeAttrs`, `TemplateEdgeAttrs`, `CallResult`, `FlowGraphSerialized` |
|
||||
| `@alkdev/flowgraph/component` | ujsx workflow components | `Operation`, `Sequential`, `Parallel`, `Conditional`, `Map` |
|
||||
| `@alkdev/flowgraph/host` | Rendering backends | `GraphologyHostConfig`, `ReactiveHostConfig` |
|
||||
| `@alkdev/flowgraph/analysis` | Validation and analysis functions | `typeCompat`, `buildTypeEdges`, `validateGraph`, `validateSchema`, `validate`, `validateTemplate`, `validatePreconditions`, `topologicalOrder`, `parallelGroups`, `criticalPath`, `reachableFrom` |
|
||||
| `@alkdev/flowgraph/reactive` | Reactive execution engine | `WorkflowReactiveRoot`, `EventLogProjection`, `WorkflowNode`, `ReactiveContext`, `FailurePolicy`, `AggregateStatus` |
|
||||
| `@alkdev/flowgraph/error` | Error hierarchy | `FlowgraphError`, `ConstructionError`, `DuplicateNodeError`, `DuplicateEdgeError`, `NodeNotFoundError`, `CycleError`, `InvalidInputError`, `InvalidTransitionError` |
|
||||
|
||||
## Core API: FlowGraph Class
|
||||
|
||||
`FlowGraph<NodeAttrs, EdgeAttrs>` wraps a graphology `DirectedGraph` and enforces DAG invariants. It delegates graph operations to graphology while adding flowgraph-specific construction, mutation, and query methods.
|
||||
|
||||
### Factory Methods
|
||||
|
||||
```typescript
|
||||
FlowGraph.fromSpecs(specs: OperationSpec[]): OperationGraph
|
||||
FlowGraph.fromCallEvents(events: CallEventMapValue[]): CallGraph
|
||||
FlowGraph.fromJSON(data: FlowGraphSerialized): FlowGraph
|
||||
```
|
||||
|
||||
### Node Operations
|
||||
|
||||
```typescript
|
||||
graph.addNode(key, attrs) // throws DuplicateNodeError
|
||||
graph.removeNode(key) // throws NodeNotFoundError
|
||||
graph.updateNode(key, partialAttrs) // throws NodeNotFoundError
|
||||
graph.hasNode(key): boolean
|
||||
graph.getNodeAttributes(key): NodeAttrs
|
||||
graph.forEachNode(callback): void
|
||||
```
|
||||
|
||||
### Edge Operations
|
||||
|
||||
```typescript
|
||||
graph.addEdge(source, target, attrs?) // throws NodeNotFoundError, DuplicateEdgeError, CycleError
|
||||
graph.removeEdge(source, target) // no-op if not found
|
||||
graph.hasEdge(source, target): boolean
|
||||
graph.getEdgeAttributes(source, target): EdgeAttrs
|
||||
graph.forEachEdge(callback): void
|
||||
```
|
||||
|
||||
### Traversal
|
||||
|
||||
```typescript
|
||||
graph.topologicalOrder(): string[]
|
||||
graph.ancestors(nodeId): string[]
|
||||
graph.descendants(nodeId): string[]
|
||||
graph.predecessors(nodeId): string[]
|
||||
graph.successors(nodeId): string[]
|
||||
graph.reachableFrom(nodeIds): Set<string>
|
||||
graph.hasCycles(): boolean
|
||||
graph.findCycles(): string[][]
|
||||
```
|
||||
|
||||
### Call Graph Convenience
|
||||
|
||||
```typescript
|
||||
graph.addCall(attrs: CallNodeAttrs): void
|
||||
graph.addDependency(source, target): void
|
||||
graph.updateStatus(requestId, status, extra?): void // throws InvalidTransitionError
|
||||
graph.updateCall(requestId, partialAttrs): void
|
||||
graph.removeCall(requestId): void
|
||||
graph.updateFromEvent(event: CallEventMapValue): void
|
||||
graph.filterByStatus(status: CallStatus): string[]
|
||||
graph.getRoots(): string[]
|
||||
graph.children(requestId): string[]
|
||||
graph.duration(requestId): number
|
||||
graph.lineage(requestId): string[]
|
||||
```
|
||||
|
||||
### Serialization
|
||||
|
||||
```typescript
|
||||
graph.export(): FlowGraphSerialized
|
||||
graph.toJSON(): FlowGraphSerialized
|
||||
graph.toString(): string
|
||||
```
|
||||
|
||||
### Escape Hatch
|
||||
|
||||
```typescript
|
||||
graph.graph // → DirectedGraph (raw graphology instance)
|
||||
```
|
||||
|
||||
Direct mutation via `graph.graph` bypasses flowgraph validation. Use with caution.
|
||||
|
||||
## Schema Enums
|
||||
|
||||
| Enum | Values |
|
||||
|------|--------|
|
||||
| `CallStatus` | `pending`, `running`, `completed`, `failed`, `aborted` |
|
||||
| `NodeStatus` | `idle`, `waiting`, `ready`, `running`, `completed`, `failed`, `skipped`, `aborted` |
|
||||
| `EdgeType` | `triggered`, `depends_on`, `typed`, `sequential`, `conditional` |
|
||||
| `OperationType` | `query`, `mutation`, `subscription` |
|
||||
|
||||
Call status transitions: `pending → running → completed|failed|aborted`. Terminal states are immutable. `InvalidTransitionError` is thrown on invalid transitions.
|
||||
|
||||
## Workflow Components
|
||||
|
||||
| Component | Props | Behavior |
|
||||
|-----------|-------|----------|
|
||||
| `<Operation>` | `name`, `input?`, `retries?`, `timeout?` | Declares an operation node in the workflow |
|
||||
| `<Sequential>` | `id?` | Children execute in order; edges are `sequential` |
|
||||
| `<Parallel>` | `id?`, `maxConcurrency?` | Children execute concurrently |
|
||||
| `<Conditional>` | `test`, `else?` | Branches on `test(results)`. Children = then-branch, `else` prop = else-branch |
|
||||
| `<Map>` | `over`, `as` | Iterates over `over` collection, binding each item as `as` variable |
|
||||
|
||||
## Analysis Functions
|
||||
|
||||
```typescript
|
||||
import { typeCompat, validateTemplate, topologicalOrder, parallelGroups, criticalPath } from "@alkdev/flowgraph/analysis";
|
||||
|
||||
typeCompat(outputSchema, inputSchema): TypeCompatResult | undefined
|
||||
validateTemplate(template, operationGraph): AnyValidationError[]
|
||||
topologicalOrder(graph): string[]
|
||||
parallelGroups(graph): string[][] // topological generations
|
||||
criticalPath(graph): string[] // longest path
|
||||
validateGraph(graph): GraphValidationError[]
|
||||
validateSchema(graph, schema): ValidationError[]
|
||||
validate(graph, schema): AnyValidationError[] // combined
|
||||
```
|
||||
|
||||
## Reactive Execution
|
||||
|
||||
`WorkflowReactiveRoot` implements `EventLogProjection` — call protocol events are the source of truth, status/results are derived projections.
|
||||
|
||||
```typescript
|
||||
const workflow = new WorkflowReactiveRoot(dag, {
|
||||
failurePolicy: "abort-dependents", // or "continue-running"
|
||||
parallelGroups: { group1: { siblings: ["a", "b"], maxConcurrency: 2 } },
|
||||
});
|
||||
|
||||
// Per-node reactive signals
|
||||
workflow.statusMap // Map<string, Signal<NodeStatus>>
|
||||
workflow.preconditions // Map<string, ReadonlySignal<boolean>> — all predecessors completed/skipped
|
||||
workflow.canStart // Map<string, ReadonlySignal<boolean>> — preconditions + concurrency
|
||||
workflow.blockedByFailure // Map<string, ReadonlySignal<boolean>> — any predecessor failed/aborted
|
||||
workflow.resultMap // Map<string, ReadonlySignal<CallResult | undefined>>
|
||||
|
||||
// Event-driven updates
|
||||
workflow.append(event: CallEventMapValue): void
|
||||
|
||||
// Queries
|
||||
workflow.getStatus(nodeId): NodeStatus
|
||||
workflow.getResult(nodeId): CallResult | undefined
|
||||
workflow.isComplete(): boolean
|
||||
workflow.getAggregateStatus(): AggregateStatus
|
||||
|
||||
// Lifecycle
|
||||
workflow.abortAll(): void
|
||||
workflow.abortNode(nodeId): void
|
||||
workflow.dispose(): void // mandatory cleanup — releases signal subscriptions
|
||||
```
|
||||
|
||||
## Error Hierarchy
|
||||
|
||||
```
|
||||
FlowgraphError (base)
|
||||
├── ConstructionError
|
||||
│ ├── DuplicateNodeError (readonly key)
|
||||
│ ├── DuplicateEdgeError (readonly source, target)
|
||||
│ ├── NodeNotFoundError (readonly key)
|
||||
│ ├── CycleError (readonly cycles: string[][])
|
||||
│ └── InvalidInputError (readonly errors: ValidationError[])
|
||||
└── InvalidTransitionError (readonly requestId, from, to)
|
||||
```
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **DAG-only, no cycles** — `addEdge()` rejects cycle-creating edges at mutation time (ADR-002). This differs from taskgraph, which allows cycles and detects them after the fact.
|
||||
|
||||
2. **Storage is decoupled** — flowgraph handles in-memory graph construction, validation, and analysis. Persistence is the caller's concern. `export()`/`fromJSON()` provides the serialization boundary.
|
||||
|
||||
3. **Template → DAG → Execution is a pipeline** — each representation serves a different phase and can exist independently. Validate a template without executing it. Build a call graph from events without a template. Run reactive execution directly from a DAG.
|
||||
|
||||
4. **Event log as source of truth** — call protocol events (`call.requested`, `call.responded`, `call.error`, `call.aborted`, `call.completed`) are the ground truth. Status, results, and the call graph are projections derived from the event log (ADR-005).
|
||||
|
||||
5. **Delegation, not inheritance** — `FlowGraph` wraps a graphology `DirectedGraph`, exposing a curated API. The raw graphology instance is available via the `.graph` escape hatch.
|
||||
|
||||
## For AI Agents
|
||||
|
||||
When working with this library programmatically:
|
||||
|
||||
- **Use subpath imports** — `@alkdev/flowgraph/graph`, `@alkdev/flowgraph/analysis`, etc. The root export re-exports everything but subpath imports make dependencies explicit.
|
||||
- **Always call `dispose()` on `WorkflowReactiveRoot`** — signal subscriptions leak without it.
|
||||
- **Function-valued props don't survive JSON serialization** — `Conditional.test` and `Map.over` with function values need runtime resolution. Use string references for stored templates.
|
||||
- **`fromSpecs()` graphs are conventionally immutable** — don't mutate operation graphs after construction. If the registry changes, rebuild via `fromSpecs()`.
|
||||
- **Call graph mutation uses event protocol** — use `updateFromEvent()` or `addCall()`/`updateStatus()`, not direct node mutation.
|
||||
- **`typeCompat()` returns `undefined` for `unknown`/`any` schemas** — this means "no meaningful check possible", not "incompatible".
|
||||
- **Architecture specs are in `docs/architecture/`** — detailed design decisions, ADRs, and open questions live there. This README is a surface-level guide. Consult the architecture docs for anything non-trivial.
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Package | Relationship |
|
||||
|---------|-------------|
|
||||
| `graphology` | Direct — the underlying directed graph data structure |
|
||||
| `graphology-dag` | Direct — topological sort, cycle detection, DAG traversal |
|
||||
| `@alkdev/ujsx` | Direct — `UNode` trees and `HostConfig` for workflow template rendering |
|
||||
| `@alkdev/typebox` | Direct — all schemas are TypeBox Modules |
|
||||
| `@preact/signals-core` | Direct — reactive state management for `WorkflowReactiveRoot` |
|
||||
| `@alkdev/operations` | **Peer** — provides `OperationSpec`, `OperationRegistry`, call event types |
|
||||
|
||||
## License
|
||||
|
||||
Dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE) at your option.
|
||||
23
package.json
23
package.json
@@ -17,7 +17,7 @@
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"/component": {
|
||||
"./component": {
|
||||
"import": {
|
||||
"types": "./dist/component/index.d.ts",
|
||||
"default": "./dist/component/index.js"
|
||||
@@ -27,7 +27,7 @@
|
||||
"default": "./dist/component/index.cjs"
|
||||
}
|
||||
},
|
||||
"/host": {
|
||||
"./host": {
|
||||
"import": {
|
||||
"types": "./dist/host/index.d.ts",
|
||||
"default": "./dist/host/index.js"
|
||||
@@ -37,7 +37,7 @@
|
||||
"default": "./dist/host/index.cjs"
|
||||
}
|
||||
},
|
||||
"/schema": {
|
||||
"./schema": {
|
||||
"import": {
|
||||
"types": "./dist/schema/index.d.ts",
|
||||
"default": "./dist/schema/index.js"
|
||||
@@ -47,7 +47,7 @@
|
||||
"default": "./dist/schema/index.cjs"
|
||||
}
|
||||
},
|
||||
"/graph": {
|
||||
"./graph": {
|
||||
"import": {
|
||||
"types": "./dist/graph/index.d.ts",
|
||||
"default": "./dist/graph/index.js"
|
||||
@@ -57,7 +57,7 @@
|
||||
"default": "./dist/graph/index.cjs"
|
||||
}
|
||||
},
|
||||
"/reactive": {
|
||||
"./reactive": {
|
||||
"import": {
|
||||
"types": "./dist/reactive/index.d.ts",
|
||||
"default": "./dist/reactive/index.js"
|
||||
@@ -67,7 +67,7 @@
|
||||
"default": "./dist/reactive/index.cjs"
|
||||
}
|
||||
},
|
||||
"/analysis": {
|
||||
"./analysis": {
|
||||
"import": {
|
||||
"types": "./dist/analysis/index.d.ts",
|
||||
"default": "./dist/analysis/index.js"
|
||||
@@ -77,7 +77,7 @@
|
||||
"default": "./dist/analysis/index.cjs"
|
||||
}
|
||||
},
|
||||
"/error": {
|
||||
"./error": {
|
||||
"import": {
|
||||
"types": "./dist/error/index.d.ts",
|
||||
"default": "./dist/error/index.js"
|
||||
@@ -112,6 +112,15 @@
|
||||
"operations"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://git.alk.dev/alkdev/flowgraph.git"
|
||||
},
|
||||
"homepage": "https://git.alk.dev/alkdev/flowgraph",
|
||||
"bugs": {
|
||||
"url": "https://git.alk.dev/alkdev/flowgraph/issues"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"dependencies": {
|
||||
"@alkdev/typebox": "^0.34.49",
|
||||
"@alkdev/ujsx": "^0.1.0",
|
||||
|
||||
@@ -4,10 +4,18 @@ export {
|
||||
defaultEdgeType,
|
||||
resolveDefaultNodeAttrs,
|
||||
} from "./defaults.js";
|
||||
export { typeCompat, type TypeCompatResult, type TypeMismatch } from "./type-compat.js";
|
||||
export { buildTypeEdges } from "../graph/construction.js";
|
||||
export { typeCompat, buildTypeEdges, type TypeCompatResult, type TypeMismatch } from "./type-compat.js";
|
||||
export {
|
||||
topologicalOrder,
|
||||
parallelGroups,
|
||||
criticalPath,
|
||||
reachableFrom,
|
||||
ancestors,
|
||||
descendants,
|
||||
} from "./ordering.js";
|
||||
export {
|
||||
validateSchema,
|
||||
validateGraph,
|
||||
validate,
|
||||
} from "../graph/validation.js";
|
||||
} from "../graph/validation.js";
|
||||
export { validatePreconditions, validateTemplate } from "./workflow.js";
|
||||
86
src/analysis/ordering.ts
Normal file
86
src/analysis/ordering.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { topologicalSort, topologicalGenerations, hasCycle } from "graphology-dag";
|
||||
import type { FlowGraph } from "../graph/construction.js";
|
||||
import { CycleError, NodeNotFoundError } from "../error/index.js";
|
||||
import { findCycles as findCyclesQuery, reachableFrom as reachableFromQuery, ancestors as ancestorsQuery, descendants as descendantsQuery } from "../graph/queries.js";
|
||||
|
||||
export function topologicalOrder(graph: FlowGraph): string[] {
|
||||
if (hasCycle(graph.graph)) {
|
||||
const cycles = findCyclesQuery(graph.graph);
|
||||
throw new CycleError(cycles);
|
||||
}
|
||||
return topologicalSort(graph.graph);
|
||||
}
|
||||
|
||||
export function parallelGroups(graph: FlowGraph): string[][] {
|
||||
if (hasCycle(graph.graph)) {
|
||||
const cycles = findCyclesQuery(graph.graph);
|
||||
throw new CycleError(cycles);
|
||||
}
|
||||
return topologicalGenerations(graph.graph);
|
||||
}
|
||||
|
||||
export function criticalPath(graph: FlowGraph): string[] {
|
||||
if (hasCycle(graph.graph)) {
|
||||
const cycles = findCyclesQuery(graph.graph);
|
||||
throw new CycleError(cycles);
|
||||
}
|
||||
const dg = graph.graph;
|
||||
const depth = new Map<string, number>();
|
||||
const topo = topologicalSort(dg);
|
||||
for (const node of topo) {
|
||||
const inNeighbors = dg.inNeighbors(node) ?? [];
|
||||
let maxPredDepth = -1;
|
||||
for (const pred of inNeighbors) {
|
||||
const predDepth = depth.get(pred!) ?? 0;
|
||||
if (predDepth > maxPredDepth) {
|
||||
maxPredDepth = predDepth;
|
||||
}
|
||||
}
|
||||
depth.set(node, maxPredDepth + 1);
|
||||
}
|
||||
let maxDepth = -1;
|
||||
let endNode = "";
|
||||
for (const node of topo) {
|
||||
const d = depth.get(node) ?? 0;
|
||||
if (d > maxDepth) {
|
||||
maxDepth = d;
|
||||
endNode = node;
|
||||
}
|
||||
}
|
||||
if (topo.length === 0) return [];
|
||||
const path: string[] = [endNode];
|
||||
let current = endNode;
|
||||
while (depth.get(current)! > 0) {
|
||||
const inNeighbors = dg.inNeighbors(current) ?? [];
|
||||
let bestPred = "";
|
||||
let bestDepth = -1;
|
||||
for (const pred of inNeighbors) {
|
||||
const predDepth = depth.get(pred!) ?? 0;
|
||||
if (predDepth > bestDepth) {
|
||||
bestDepth = predDepth;
|
||||
bestPred = pred!;
|
||||
}
|
||||
}
|
||||
path.unshift(bestPred);
|
||||
current = bestPred;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function reachableFrom(graph: FlowGraph, nodeIds: string[]): Set<string> {
|
||||
return reachableFromQuery(graph.graph, nodeIds);
|
||||
}
|
||||
|
||||
export function ancestors(graph: FlowGraph, nodeId: string): string[] {
|
||||
if (!graph.hasNode(nodeId)) {
|
||||
throw new NodeNotFoundError(nodeId);
|
||||
}
|
||||
return ancestorsQuery(graph.graph, nodeId);
|
||||
}
|
||||
|
||||
export function descendants(graph: FlowGraph, nodeId: string): string[] {
|
||||
if (!graph.hasNode(nodeId)) {
|
||||
throw new NodeNotFoundError(nodeId);
|
||||
}
|
||||
return descendantsQuery(graph.graph, nodeId);
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
import { KindGuard, Kind, type TSchema } from "@alkdev/typebox";
|
||||
import { willCreateCycle } from "graphology-dag";
|
||||
import type { FlowGraph } from "../graph/construction.js";
|
||||
import {
|
||||
OperationNodeAttrs as OperationNodeAttrsSchema,
|
||||
OperationEdgeAttrs as OperationEdgeAttrsSchema,
|
||||
} from "../schema/index.js";
|
||||
import type { OperationNodeAttrs } from "../schema/index.js";
|
||||
|
||||
export interface TypeMismatch {
|
||||
path: string;
|
||||
@@ -275,4 +282,25 @@ export function typeCompat(outputSchema: TSchema, inputSchema: TSchema): TypeCom
|
||||
}
|
||||
|
||||
return { compatible: false, mismatches };
|
||||
}
|
||||
|
||||
export function buildTypeEdges(graph: FlowGraph<typeof OperationNodeAttrsSchema, typeof OperationEdgeAttrsSchema>): void {
|
||||
const nodeKeys = graph.nodes();
|
||||
for (const source of nodeKeys) {
|
||||
for (const target of nodeKeys) {
|
||||
if (source === target) continue;
|
||||
const sourceAttrs = graph.getNodeAttributes(source as never) as unknown as OperationNodeAttrs;
|
||||
const targetAttrs = graph.getNodeAttributes(target as never) as unknown as OperationNodeAttrs;
|
||||
const result = typeCompat(sourceAttrs.outputSchema as TSchema, targetAttrs.inputSchema as TSchema);
|
||||
if (result === undefined) continue;
|
||||
if (graph.hasEdge(source, target)) continue;
|
||||
if (willCreateCycle(graph.graph, source, target)) continue;
|
||||
const detail = result.detail ?? `${sourceAttrs.namespace}.${sourceAttrs.name}.output → ${targetAttrs.namespace}.${targetAttrs.name}.input`;
|
||||
graph.addTypedEdge(source, target, {
|
||||
compatible: result.compatible,
|
||||
detail,
|
||||
...(result.mismatches !== undefined ? { mismatches: result.mismatches } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1,197 @@
|
||||
export {};
|
||||
import type { TSchema } from "@alkdev/typebox";
|
||||
import { KindGuard } from "@alkdev/typebox";
|
||||
import type { UNode } from "@alkdev/ujsx";
|
||||
import { createHostRoot } from "@alkdev/ujsx";
|
||||
import { hasCycle } from "graphology-dag";
|
||||
import { DirectedGraph } from "graphology";
|
||||
import type { FlowGraph } from "../graph/construction.js";
|
||||
import type { OperationNodeAttrs } from "../schema/node.js";
|
||||
import type { OperationEdgeAttrs } from "../schema/edge.js";
|
||||
import type { ValidationError, AnyValidationError } from "../error/index.js";
|
||||
import { GraphologyHostConfig } from "../host/graphology.js";
|
||||
import { reachableFrom } from "../graph/queries.js";
|
||||
|
||||
function getRequiredTopLevelFields(schema: unknown): Set<string> {
|
||||
const fields = new Set<string>();
|
||||
if (schema === null || schema === undefined || typeof schema !== "object") return fields;
|
||||
const s = schema as TSchema;
|
||||
if (!KindGuard.IsObject(s)) return fields;
|
||||
const props = s.properties as Record<string, TSchema> | undefined;
|
||||
const required = s.required as string[] | undefined;
|
||||
if (props && required) {
|
||||
for (const key of required) {
|
||||
fields.add(key);
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function getProvidedFields(schema: unknown): Set<string> {
|
||||
const fields = new Set<string>();
|
||||
if (schema === null || schema === undefined || typeof schema !== "object") return fields;
|
||||
const s = schema as TSchema;
|
||||
if (!KindGuard.IsObject(s)) return fields;
|
||||
const props = s.properties as Record<string, TSchema> | undefined;
|
||||
if (props) {
|
||||
for (const key of Object.keys(props)) {
|
||||
fields.add(key);
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function validatePreconditions(
|
||||
graph: FlowGraph<typeof import("../schema/node.js").OperationNodeAttrs, typeof import("../schema/edge.js").OperationEdgeAttrs>,
|
||||
): ValidationError[] {
|
||||
const errors: ValidationError[] = [];
|
||||
const nodeKeys = graph.nodes();
|
||||
|
||||
for (const nodeKey of nodeKeys) {
|
||||
const attrs = graph.getNodeAttributes(nodeKey) as unknown as OperationNodeAttrs;
|
||||
const inputSchema = attrs.inputSchema;
|
||||
const requiredFields = getRequiredTopLevelFields(inputSchema);
|
||||
|
||||
if (requiredFields.size === 0) continue;
|
||||
|
||||
const predecessors = graph.predecessors(nodeKey);
|
||||
if (predecessors.length === 0) {
|
||||
for (const field of requiredFields) {
|
||||
errors.push({
|
||||
type: "schema",
|
||||
nodeKey,
|
||||
field,
|
||||
message: `Required input field "${field}" has no predecessor providing it`,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const providedFields = new Set<string>();
|
||||
for (const predKey of predecessors) {
|
||||
const predAttrs = graph.getNodeAttributes(predKey) as unknown as OperationNodeAttrs;
|
||||
const predProvided = getProvidedFields(predAttrs.outputSchema);
|
||||
for (const field of predProvided) {
|
||||
providedFields.add(field);
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (!providedFields.has(field)) {
|
||||
errors.push({
|
||||
type: "schema",
|
||||
nodeKey,
|
||||
field,
|
||||
message: `Required input field "${field}" is not provided by any predecessor`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function collectOperationNodeKeys(dag: DirectedGraph): string[] {
|
||||
const names: string[] = [];
|
||||
dag.forEachNode((key) => {
|
||||
if (!key.startsWith("__")) {
|
||||
names.push(key);
|
||||
}
|
||||
});
|
||||
return names;
|
||||
}
|
||||
|
||||
export function validateTemplate(
|
||||
template: UNode,
|
||||
operationGraph: FlowGraph<typeof import("../schema/node.js").OperationNodeAttrs, typeof import("../schema/edge.js").OperationEdgeAttrs>,
|
||||
): AnyValidationError[] {
|
||||
const errors: AnyValidationError[] = [];
|
||||
|
||||
let renderedDag: DirectedGraph;
|
||||
try {
|
||||
const root = createHostRoot(GraphologyHostConfig, null);
|
||||
root.render(template);
|
||||
renderedDag = root.ctx.graph as DirectedGraph;
|
||||
} catch {
|
||||
renderedDag = new DirectedGraph();
|
||||
}
|
||||
|
||||
const templateNodeKeys = collectOperationNodeKeys(renderedDag);
|
||||
const graphNodeKeys = new Set(operationGraph.nodes());
|
||||
|
||||
for (const opKey of templateNodeKeys) {
|
||||
if (!graphNodeKeys.has(opKey)) {
|
||||
errors.push({
|
||||
type: "graph",
|
||||
category: "orphan-node",
|
||||
details: { operation: opKey, message: `Operation "${opKey}" not found in operation graph` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (hasCycle(renderedDag)) {
|
||||
errors.push({
|
||||
type: "graph",
|
||||
category: "cycle",
|
||||
details: { message: "Rendered template DAG contains a cycle" },
|
||||
});
|
||||
}
|
||||
|
||||
for (const opKey of templateNodeKeys) {
|
||||
if (!graphNodeKeys.has(opKey)) continue;
|
||||
const outEdges = renderedDag.outEdges(opKey) ?? [];
|
||||
for (const edge of outEdges) {
|
||||
const target = renderedDag.target(edge);
|
||||
if (target.startsWith("__")) continue;
|
||||
if (!graphNodeKeys.has(target)) continue;
|
||||
if (operationGraph.hasEdge(opKey, target)) {
|
||||
const edgeAttrs = operationGraph.getEdgeAttributes(opKey, target) as unknown as OperationEdgeAttrs;
|
||||
if (!edgeAttrs.compatible) {
|
||||
errors.push({
|
||||
type: "type-compat",
|
||||
sourceKey: opKey,
|
||||
targetKey: target,
|
||||
compatible: false,
|
||||
mismatches: edgeAttrs.mismatches ?? [],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (templateNodeKeys.length > 1) {
|
||||
const roots: string[] = [];
|
||||
for (const key of templateNodeKeys) {
|
||||
const inDegree = renderedDag.inDegree(key);
|
||||
if (inDegree === 0) {
|
||||
roots.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (roots.length > 0) {
|
||||
const reachable = reachableFrom(renderedDag, roots);
|
||||
for (const nodeKey of templateNodeKeys) {
|
||||
if (!reachable.has(nodeKey)) {
|
||||
errors.push({
|
||||
type: "graph",
|
||||
category: "orphan-node",
|
||||
details: { nodeKey, message: `Operation "${nodeKey}" is not reachable from start` },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const nodeKey of templateNodeKeys) {
|
||||
const inDegree = renderedDag.inDegree(nodeKey);
|
||||
const outDegree = renderedDag.outDegree(nodeKey);
|
||||
if (inDegree === 0 && outDegree === 0 && templateNodeKeys.length > 1) {
|
||||
errors.push({
|
||||
type: "graph",
|
||||
category: "orphan-node",
|
||||
details: { nodeKey, message: `Operation "${nodeKey}" has no edges (orphan node)` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
type CallStatus = "pending" | "running" | "completed" | "failed" | "aborted";
|
||||
import type { CallStatus } from "../schema/enums.js";
|
||||
|
||||
interface TypeMismatch {
|
||||
path: string;
|
||||
expected: string;
|
||||
actual: string;
|
||||
}
|
||||
|
||||
class FlowgraphError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -88,12 +94,6 @@ interface GraphValidationError {
|
||||
details: unknown;
|
||||
}
|
||||
|
||||
interface TypeMismatch {
|
||||
path: string;
|
||||
expected: string;
|
||||
actual: string;
|
||||
}
|
||||
|
||||
interface TypeIncompatError {
|
||||
type: "type-compat";
|
||||
sourceKey: string;
|
||||
@@ -116,10 +116,8 @@ export {
|
||||
};
|
||||
|
||||
export type {
|
||||
CallStatus,
|
||||
ValidationError,
|
||||
GraphValidationError,
|
||||
TypeMismatch,
|
||||
TypeIncompatError,
|
||||
AnyValidationError,
|
||||
};
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
InvalidInputError,
|
||||
InvalidTransitionError,
|
||||
} from "../error/index.js";
|
||||
import type { CallStatus, AnyValidationError, ValidationError } from "../error/index.js";
|
||||
import type { AnyValidationError, ValidationError } from "../error/index.js";
|
||||
import type { CallStatus } from "../schema/enums.js";
|
||||
import {
|
||||
findCycles,
|
||||
reachableFrom as reachableFromFn,
|
||||
@@ -24,8 +25,10 @@ import {
|
||||
CallNodeAttrs as CallNodeAttrsSchema,
|
||||
CallEdgeAttrs as CallEdgeAttrsSchema,
|
||||
} from "../schema/index.js";
|
||||
import type { OperationNodeAttrs, FlowGraphSerialized, CallNodeAttrs } from "../schema/index.js";
|
||||
import { typeCompat, type TypeCompatResult } from "../analysis/type-compat.js";
|
||||
import type { FlowGraphSerialized, CallNodeAttrs } from "../schema/index.js";
|
||||
import { buildTypeEdges, type TypeCompatResult } from "../analysis/type-compat.js";
|
||||
|
||||
export { buildTypeEdges } from "../analysis/type-compat.js";
|
||||
|
||||
export interface FlowGraphOptions {
|
||||
type?: "directed";
|
||||
@@ -654,25 +657,4 @@ export class FlowGraph<
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTypeEdges(graph: OperationGraph): void {
|
||||
const nodeKeys = graph.nodes();
|
||||
for (const source of nodeKeys) {
|
||||
for (const target of nodeKeys) {
|
||||
if (source === target) continue;
|
||||
const sourceAttrs = graph.getNodeAttributes(source as never) as unknown as OperationNodeAttrs;
|
||||
const targetAttrs = graph.getNodeAttributes(target as never) as unknown as OperationNodeAttrs;
|
||||
const result = typeCompat(sourceAttrs.outputSchema as TSchema, targetAttrs.inputSchema as TSchema);
|
||||
if (result === undefined) continue;
|
||||
if (graph.hasEdge(source, target)) continue;
|
||||
if (willCreateCycle(graph.graph, source, target)) continue;
|
||||
const detail = result.detail ?? `${sourceAttrs.namespace}.${sourceAttrs.name}.output → ${targetAttrs.namespace}.${targetAttrs.name}.input`;
|
||||
graph.addTypedEdge(source, target, {
|
||||
compatible: result.compatible,
|
||||
detail,
|
||||
...(result.mismatches !== undefined ? { mismatches: result.mismatches } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,5 @@
|
||||
export { FlowGraph, buildTypeEdges, type FlowGraphOptions, type OperationSpec, type CallEventMapValue, type CallRequestedEvent, type CallRespondedEvent, type CallErrorEvent, type CallAbortedEvent, type CallCompletedEvent } from "./construction.js";
|
||||
export {
|
||||
topologicalOrder,
|
||||
hasCycles,
|
||||
findCycles,
|
||||
ancestors,
|
||||
descendants,
|
||||
reachableFrom,
|
||||
} from "./queries.js";
|
||||
export {
|
||||
validateSchema,
|
||||
validateGraph,
|
||||
validate,
|
||||
} from "./validation.js";
|
||||
} from "./queries.js";
|
||||
@@ -1,2 +1,4 @@
|
||||
export { GraphologyHostConfig } from "./graphology.js";
|
||||
export type { WorkflowTag, GraphNode, GraphContext, OperationRegistry } from "./graphology.js";
|
||||
export type { WorkflowTag, GraphNode, GraphContext, OperationRegistry as GraphOperationRegistry } from "./graphology.js";
|
||||
export { ReactiveHostConfig } from "./reactive.js";
|
||||
export type { WorkflowNode, ReactiveContext, OperationRegistry } from "./reactive.js";
|
||||
@@ -1 +1,250 @@
|
||||
export {};
|
||||
import { signal, computed } from "@preact/signals-core";
|
||||
import type { Signal, ReadonlySignal } from "@preact/signals-core";
|
||||
import type { HostConfig } from "@alkdev/ujsx";
|
||||
import type { NodeStatus } from "../schema/enums.js";
|
||||
import type { CallResult } from "../schema/edge.js";
|
||||
import type { EventLogProjection } from "../reactive/workflow.js";
|
||||
import type { WorkflowTag } from "./graphology.js";
|
||||
|
||||
export interface OperationRegistry {
|
||||
resolve(name: string): unknown;
|
||||
}
|
||||
|
||||
export interface WorkflowNode {
|
||||
key: string;
|
||||
type: WorkflowTag;
|
||||
status: Signal<NodeStatus>;
|
||||
preconditions: ReadonlySignal<boolean>;
|
||||
blockedByFailure: ReadonlySignal<boolean>;
|
||||
operationId?: string;
|
||||
output?: Signal<unknown>;
|
||||
children: WorkflowNode[];
|
||||
}
|
||||
|
||||
export interface ReactiveContext {
|
||||
operationRegistry: OperationRegistry;
|
||||
nodes: Map<string, WorkflowNode>;
|
||||
statusSignals: Map<string, Signal<NodeStatus>>;
|
||||
preconditions: Map<string, ReadonlySignal<boolean>>;
|
||||
blockedByFailure: Map<string, ReadonlySignal<boolean>>;
|
||||
resultProjection: EventLogProjection;
|
||||
parentMap: Map<string, string>;
|
||||
siblingMap: Map<string, string[]>;
|
||||
results: Map<string, ReadonlySignal<CallResult | undefined>>;
|
||||
_containerCounter: number;
|
||||
}
|
||||
|
||||
function collectLeafPredecessors(
|
||||
nodeKey: string,
|
||||
ctx: ReactiveContext,
|
||||
): string[] {
|
||||
const parentKey = ctx.parentMap.get(nodeKey);
|
||||
if (!parentKey) return [];
|
||||
|
||||
const parentNode = ctx.nodes.get(parentKey);
|
||||
if (!parentNode) return [];
|
||||
|
||||
const siblings = ctx.siblingMap.get(parentKey);
|
||||
if (!siblings) return [];
|
||||
|
||||
const idx = siblings.indexOf(nodeKey);
|
||||
|
||||
switch (parentNode.type) {
|
||||
case "sequential": {
|
||||
if (idx > 0) {
|
||||
const prevKey = siblings[idx - 1]!;
|
||||
const prevNode = ctx.nodes.get(prevKey);
|
||||
if (prevNode && prevNode.type === "operation") {
|
||||
return [prevKey];
|
||||
}
|
||||
return collectLeafPredecessors(prevKey, ctx);
|
||||
}
|
||||
return collectLeafPredecessors(parentKey, ctx);
|
||||
}
|
||||
case "parallel":
|
||||
case "map":
|
||||
case "conditional": {
|
||||
return collectLeafPredecessors(parentKey, ctx);
|
||||
}
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function computePreconditions(
|
||||
node: WorkflowNode,
|
||||
ctx: ReactiveContext,
|
||||
): boolean {
|
||||
const predecessors = collectLeafPredecessors(node.key, ctx);
|
||||
if (predecessors.length === 0) return true;
|
||||
return predecessors.every((predKey) => {
|
||||
const predStatus = ctx.statusSignals.get(predKey);
|
||||
if (!predStatus) return true;
|
||||
return predStatus.value === "completed" || predStatus.value === "skipped";
|
||||
});
|
||||
}
|
||||
|
||||
function computeBlockedByFailure(
|
||||
node: WorkflowNode,
|
||||
ctx: ReactiveContext,
|
||||
): boolean {
|
||||
const predecessors = collectLeafPredecessors(node.key, ctx);
|
||||
return predecessors.some((predKey) => {
|
||||
const predStatus = ctx.statusSignals.get(predKey);
|
||||
if (!predStatus) return false;
|
||||
return predStatus.value === "failed" || predStatus.value === "aborted";
|
||||
});
|
||||
}
|
||||
|
||||
export const ReactiveHostConfig: HostConfig<WorkflowTag, WorkflowNode, ReactiveContext> = {
|
||||
name: "reactive",
|
||||
|
||||
createRootContext(_container, options) {
|
||||
const ctx: ReactiveContext = {
|
||||
operationRegistry: options?.registry as OperationRegistry ?? { resolve: () => undefined },
|
||||
nodes: new Map(),
|
||||
statusSignals: new Map(),
|
||||
preconditions: new Map(),
|
||||
blockedByFailure: new Map(),
|
||||
resultProjection: options?.resultProjection as EventLogProjection ?? {
|
||||
append() {},
|
||||
getStatus: () => "idle" as NodeStatus,
|
||||
getResult: () => undefined,
|
||||
getEvents: () => [],
|
||||
},
|
||||
parentMap: new Map(),
|
||||
siblingMap: new Map(),
|
||||
results: new Map(),
|
||||
_containerCounter: 0,
|
||||
};
|
||||
return ctx;
|
||||
},
|
||||
|
||||
createInstance(tag, props, ctx, parent) {
|
||||
if (tag === "operation") {
|
||||
const key = props.name as string;
|
||||
const status = signal<NodeStatus>("idle");
|
||||
|
||||
const node: WorkflowNode = {
|
||||
key,
|
||||
type: "operation",
|
||||
status,
|
||||
preconditions: computed(() => computePreconditions(node, ctx)),
|
||||
blockedByFailure: computed(() => computeBlockedByFailure(node, ctx)),
|
||||
operationId: key,
|
||||
output: signal<unknown>(undefined),
|
||||
children: [],
|
||||
};
|
||||
|
||||
ctx.nodes.set(key, node);
|
||||
ctx.statusSignals.set(key, status);
|
||||
ctx.preconditions.set(key, node.preconditions);
|
||||
ctx.blockedByFailure.set(key, node.blockedByFailure);
|
||||
|
||||
if (parent) {
|
||||
ctx.parentMap.set(key, parent.key);
|
||||
const siblings = ctx.siblingMap.get(parent.key);
|
||||
if (siblings) {
|
||||
siblings.push(key);
|
||||
} else {
|
||||
ctx.siblingMap.set(parent.key, [key]);
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
const counter = ctx._containerCounter++;
|
||||
const key = `__${tag}_${counter}`;
|
||||
const status = signal<NodeStatus>("idle");
|
||||
|
||||
const node: WorkflowNode = {
|
||||
key,
|
||||
type: tag,
|
||||
status,
|
||||
preconditions: computed(() => computePreconditions(node, ctx)),
|
||||
blockedByFailure: computed(() => computeBlockedByFailure(node, ctx)),
|
||||
children: [],
|
||||
};
|
||||
|
||||
ctx.nodes.set(key, node);
|
||||
ctx.statusSignals.set(key, status);
|
||||
ctx.preconditions.set(key, node.preconditions);
|
||||
ctx.blockedByFailure.set(key, node.blockedByFailure);
|
||||
|
||||
if (parent) {
|
||||
ctx.parentMap.set(key, parent.key);
|
||||
const siblings = ctx.siblingMap.get(parent.key);
|
||||
if (siblings) {
|
||||
siblings.push(key);
|
||||
} else {
|
||||
ctx.siblingMap.set(parent.key, [key]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ctx.siblingMap.has(key)) {
|
||||
ctx.siblingMap.set(key, []);
|
||||
}
|
||||
|
||||
return node;
|
||||
},
|
||||
|
||||
createTextInstance(_text, ctx, _parent) {
|
||||
const counter = ctx._containerCounter++;
|
||||
const key = `__text_${counter}`;
|
||||
const status = signal<NodeStatus>("idle");
|
||||
const node: WorkflowNode = {
|
||||
key,
|
||||
type: "sequential" as WorkflowTag,
|
||||
status,
|
||||
preconditions: computed(() => true),
|
||||
blockedByFailure: computed(() => false),
|
||||
children: [],
|
||||
};
|
||||
ctx.nodes.set(key, node);
|
||||
ctx.statusSignals.set(key, status);
|
||||
return node;
|
||||
},
|
||||
|
||||
appendChild(parent, child, _ctx) {
|
||||
if (!parent.children.includes(child)) {
|
||||
parent.children.push(child);
|
||||
}
|
||||
},
|
||||
|
||||
insertBefore(parent, child, before, _ctx) {
|
||||
const idx = parent.children.indexOf(before);
|
||||
if (idx === -1) {
|
||||
parent.children.push(child);
|
||||
} else {
|
||||
if (!parent.children.includes(child)) {
|
||||
parent.children.splice(idx, 0, child);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
removeChild(parent, child, ctx) {
|
||||
parent.children = parent.children.filter((c) => c.key !== child.key);
|
||||
|
||||
const siblings = ctx.siblingMap.get(parent.key);
|
||||
if (siblings) {
|
||||
const idx = siblings.indexOf(child.key);
|
||||
if (idx !== -1) {
|
||||
siblings.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
prepareUpdate() {
|
||||
return null;
|
||||
},
|
||||
|
||||
commitUpdate() {
|
||||
},
|
||||
|
||||
emit() {
|
||||
},
|
||||
|
||||
finalizeInstance(_instance, _ctx) {
|
||||
},
|
||||
};
|
||||
13
src/index.ts
13
src/index.ts
@@ -1,8 +1,7 @@
|
||||
export * from "./error/index.js";
|
||||
|
||||
export { FlowGraph, buildTypeEdges, type FlowGraphOptions, type OperationSpec, type CallEventMapValue, type CallRequestedEvent, type CallRespondedEvent, type CallErrorEvent, type CallAbortedEvent, type CallCompletedEvent } from "./graph/index.js";
|
||||
export {
|
||||
validateSchema,
|
||||
validateGraph,
|
||||
validate,
|
||||
} from "./graph/validation.js";
|
||||
export * from "./schema/index.js";
|
||||
export * from "./component/index.js";
|
||||
export * from "./host/index.js";
|
||||
export * from "./reactive/index.js";
|
||||
export * from "./analysis/index.js";
|
||||
export * from "./graph/index.js";
|
||||
@@ -1,12 +1,22 @@
|
||||
export {
|
||||
WorkflowReactiveRoot,
|
||||
type FailurePolicy,
|
||||
type CallEventMapValue,
|
||||
type CallRequestedEvent,
|
||||
type CallRespondedEvent,
|
||||
type CallErrorEvent,
|
||||
type CallAbortedEvent,
|
||||
type CallCompletedEvent,
|
||||
type EventLogProjection,
|
||||
type AggregateStatus,
|
||||
type ParallelGroup,
|
||||
type ParallelGroupConfig,
|
||||
} from "./workflow.js";
|
||||
|
||||
export type {
|
||||
WorkflowNode,
|
||||
ReactiveContext,
|
||||
} from "../host/reactive.js";
|
||||
|
||||
export {
|
||||
computePreconditions,
|
||||
computeBlockedByFailure,
|
||||
registerStartEffect,
|
||||
registerAbortEffect,
|
||||
type NodeStatusContext,
|
||||
type AbortEffectOptions,
|
||||
} from "./node-status.js";
|
||||
@@ -1 +1,81 @@
|
||||
export {};
|
||||
import { effect } from "@preact/signals-core";
|
||||
import type { Signal, ReadonlySignal } from "@preact/signals-core";
|
||||
import type { NodeStatus } from "../schema/enums.js";
|
||||
|
||||
const TERMINAL_STATUSES: Set<NodeStatus> = new Set([
|
||||
"completed",
|
||||
"failed",
|
||||
"aborted",
|
||||
"skipped",
|
||||
]);
|
||||
|
||||
export interface NodeStatusContext {
|
||||
statusMap: Map<string, Signal<NodeStatus>>;
|
||||
predecessors: string[];
|
||||
}
|
||||
|
||||
export function computePreconditions(
|
||||
_nodeKey: string,
|
||||
ctx: NodeStatusContext,
|
||||
): boolean {
|
||||
if (ctx.predecessors.length === 0) return true;
|
||||
return ctx.predecessors.every((pred: string) => {
|
||||
const predStatus = ctx.statusMap.get(pred);
|
||||
if (!predStatus) return false;
|
||||
return predStatus.value === "completed" || predStatus.value === "skipped";
|
||||
});
|
||||
}
|
||||
|
||||
export function computeBlockedByFailure(
|
||||
_nodeKey: string,
|
||||
ctx: NodeStatusContext,
|
||||
): boolean {
|
||||
return ctx.predecessors.some((pred: string) => {
|
||||
const predStatus = ctx.statusMap.get(pred);
|
||||
if (!predStatus) return false;
|
||||
return predStatus.value === "failed" || predStatus.value === "aborted";
|
||||
});
|
||||
}
|
||||
|
||||
export function registerStartEffect(
|
||||
status: Signal<NodeStatus>,
|
||||
canStart: ReadonlySignal<boolean>,
|
||||
effectDisposers: (() => void)[],
|
||||
): void {
|
||||
const disposer = effect(() => {
|
||||
if (canStart.value) {
|
||||
const current = status.value;
|
||||
if (current === "idle" || current === "waiting") {
|
||||
status.value = "ready";
|
||||
}
|
||||
}
|
||||
});
|
||||
effectDisposers.push(disposer);
|
||||
}
|
||||
|
||||
export interface AbortEffectOptions {
|
||||
abortDependents?: boolean;
|
||||
}
|
||||
|
||||
export function registerAbortEffect(
|
||||
status: Signal<NodeStatus>,
|
||||
blockedByFailure: ReadonlySignal<boolean>,
|
||||
effectDisposers: (() => void)[],
|
||||
options?: AbortEffectOptions,
|
||||
): void {
|
||||
const disposer = effect(() => {
|
||||
if (blockedByFailure.value) {
|
||||
const current = status.value;
|
||||
if (options?.abortDependents) {
|
||||
if (!TERMINAL_STATUSES.has(current)) {
|
||||
status.value = "aborted";
|
||||
}
|
||||
} else {
|
||||
if (current === "idle" || current === "waiting") {
|
||||
status.value = "aborted";
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
effectDisposers.push(disposer);
|
||||
}
|
||||
@@ -1,53 +1,29 @@
|
||||
import { signal, computed, effect } from "@preact/signals-core";
|
||||
import { signal, computed } from "@preact/signals-core";
|
||||
import type { Signal, ReadonlySignal } from "@preact/signals-core";
|
||||
import type { DirectedGraph } from "graphology";
|
||||
import type { NodeStatus } from "../schema/enums.js";
|
||||
import type { CallResult } from "../schema/edge.js";
|
||||
import type { CallEventMapValue } from "../graph/construction.js";
|
||||
export type { CallEventMapValue } from "../graph/construction.js";
|
||||
import {
|
||||
computePreconditions,
|
||||
computeBlockedByFailure,
|
||||
registerStartEffect,
|
||||
registerAbortEffect,
|
||||
} from "./node-status.js";
|
||||
import type { NodeStatusContext } from "./node-status.js";
|
||||
|
||||
export type FailurePolicy = "continue-running" | "abort-dependents";
|
||||
|
||||
export interface CallRequestedEvent {
|
||||
type: "call.requested";
|
||||
requestId: string;
|
||||
operationId: string;
|
||||
input: unknown;
|
||||
timestamp: string;
|
||||
export interface ParallelGroup {
|
||||
siblings: string[];
|
||||
maxConcurrency?: number;
|
||||
}
|
||||
|
||||
export interface CallRespondedEvent {
|
||||
type: "call.responded";
|
||||
requestId: string;
|
||||
output: unknown;
|
||||
timestamp: string;
|
||||
export interface ParallelGroupConfig {
|
||||
[groupKey: string]: ParallelGroup;
|
||||
}
|
||||
|
||||
export interface CallErrorEvent {
|
||||
type: "call.error";
|
||||
requestId: string;
|
||||
error: { code: string; message: string; details?: unknown };
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface CallAbortedEvent {
|
||||
type: "call.aborted";
|
||||
requestId: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface CallCompletedEvent {
|
||||
type: "call.completed";
|
||||
requestId: string;
|
||||
output: unknown;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export type CallEventMapValue =
|
||||
| CallRequestedEvent
|
||||
| CallRespondedEvent
|
||||
| CallErrorEvent
|
||||
| CallAbortedEvent
|
||||
| CallCompletedEvent;
|
||||
|
||||
export interface EventLogProjection {
|
||||
append(event: CallEventMapValue): void;
|
||||
getStatus(nodeId: string): NodeStatus;
|
||||
@@ -85,55 +61,86 @@ const EVENT_TO_STATUS: Record<string, NodeStatus> = {
|
||||
export class WorkflowReactiveRoot implements EventLogProjection {
|
||||
statusMap: Map<string, Signal<NodeStatus>>;
|
||||
preconditions: Map<string, ReadonlySignal<boolean>>;
|
||||
canStart: Map<string, ReadonlySignal<boolean>>;
|
||||
blockedByFailure: Map<string, ReadonlySignal<boolean>>;
|
||||
resultMap: Map<string, ReadonlySignal<CallResult | undefined>>;
|
||||
nodeKeyToRequestId: Map<string, string>;
|
||||
requestIdToNodeKey: Map<string, string>;
|
||||
|
||||
private graph: DirectedGraph;
|
||||
private effectDisposers: (() => void)[];
|
||||
private eventLog: CallEventMapValue[];
|
||||
private _failurePolicy: FailurePolicy;
|
||||
private _parallelGroups: ParallelGroupConfig;
|
||||
|
||||
constructor(
|
||||
graph: DirectedGraph,
|
||||
options?: { failurePolicy?: FailurePolicy },
|
||||
options?: { failurePolicy?: FailurePolicy; parallelGroups?: ParallelGroupConfig },
|
||||
) {
|
||||
this.graph = graph;
|
||||
this.statusMap = new Map();
|
||||
this.preconditions = new Map();
|
||||
this.canStart = new Map();
|
||||
this.blockedByFailure = new Map();
|
||||
this.resultMap = new Map();
|
||||
this.effectDisposers = [];
|
||||
this.eventLog = [];
|
||||
this.nodeKeyToRequestId = new Map();
|
||||
this.requestIdToNodeKey = new Map();
|
||||
this._failurePolicy = options?.failurePolicy ?? "continue-running";
|
||||
this._parallelGroups = options?.parallelGroups ?? {};
|
||||
this.initializeSignals();
|
||||
}
|
||||
|
||||
setRequestId(nodeKey: string, requestId: string): void {
|
||||
this.nodeKeyToRequestId.set(nodeKey, requestId);
|
||||
this.requestIdToNodeKey.set(requestId, nodeKey);
|
||||
}
|
||||
|
||||
private initializeSignals(): void {
|
||||
const nodeToGroupKey = new Map<string, string>();
|
||||
for (const [groupKey, group] of Object.entries(this._parallelGroups)) {
|
||||
for (const sibling of group.siblings) {
|
||||
nodeToGroupKey.set(sibling, groupKey);
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of this.graph.nodes()) {
|
||||
const predecessors: string[] = this.graph.inNeighbors(node) ?? [];
|
||||
|
||||
const status = signal<NodeStatus>("idle");
|
||||
|
||||
const ctx: NodeStatusContext = {
|
||||
statusMap: this.statusMap,
|
||||
predecessors,
|
||||
};
|
||||
|
||||
const preconditionsComputed = computed(() => {
|
||||
return predecessors.every((pred: string) => {
|
||||
const predStatus = this.statusMap.get(pred);
|
||||
if (!predStatus) return false;
|
||||
return (
|
||||
predStatus.value === "completed" || predStatus.value === "skipped"
|
||||
);
|
||||
});
|
||||
return computePreconditions(node, ctx);
|
||||
});
|
||||
|
||||
const blockedByFailureComputed = computed(() => {
|
||||
return predecessors.some((pred: string) => {
|
||||
const predStatus = this.statusMap.get(pred);
|
||||
if (!predStatus) return false;
|
||||
return (
|
||||
predStatus.value === "failed" || predStatus.value === "aborted"
|
||||
);
|
||||
const groupKey = nodeToGroupKey.get(node);
|
||||
const parallelGroup = groupKey ? this._parallelGroups[groupKey] : undefined;
|
||||
const maxConc = parallelGroup?.maxConcurrency;
|
||||
const siblings = parallelGroup?.siblings ?? [];
|
||||
|
||||
let canStartComputed: ReadonlySignal<boolean>;
|
||||
if (maxConc !== undefined && siblings.length > 0) {
|
||||
const otherSiblings = siblings.filter((s) => s !== node);
|
||||
canStartComputed = computed(() => {
|
||||
if (!preconditionsComputed.value) return false;
|
||||
const activeSiblingCount = otherSiblings.filter((sib) => {
|
||||
const sibStatus = this.statusMap.get(sib);
|
||||
return sibStatus && (sibStatus.value === "running" || sibStatus.value === "ready");
|
||||
}).length;
|
||||
return activeSiblingCount < maxConc;
|
||||
});
|
||||
} else {
|
||||
canStartComputed = preconditionsComputed;
|
||||
}
|
||||
|
||||
const blockedByFailureComputed = computed(() => {
|
||||
return computeBlockedByFailure(node, ctx);
|
||||
});
|
||||
|
||||
const resultComputed = computed(() => {
|
||||
@@ -182,29 +189,20 @@ export class WorkflowReactiveRoot implements EventLogProjection {
|
||||
|
||||
this.statusMap.set(node, status);
|
||||
this.preconditions.set(node, preconditionsComputed);
|
||||
this.canStart.set(node, canStartComputed);
|
||||
this.blockedByFailure.set(node, blockedByFailureComputed);
|
||||
this.resultMap.set(node, resultComputed);
|
||||
}
|
||||
|
||||
for (const node of this.graph.nodes()) {
|
||||
const status = this.statusMap.get(node)!;
|
||||
const canStart = this.canStart.get(node)!;
|
||||
const blocked = this.blockedByFailure.get(node)!;
|
||||
|
||||
const disposer = effect(() => {
|
||||
if (blocked.value) {
|
||||
const current = status.value;
|
||||
if (current === "idle" || current === "waiting" || current === "ready") {
|
||||
if (this._failurePolicy === "abort-dependents") {
|
||||
if (!TERMINAL_STATUSES.has(current)) {
|
||||
status.value = "aborted";
|
||||
}
|
||||
} else {
|
||||
status.value = "aborted";
|
||||
}
|
||||
}
|
||||
}
|
||||
registerStartEffect(status, canStart, this.effectDisposers);
|
||||
registerAbortEffect(status, blocked, this.effectDisposers, {
|
||||
abortDependents: this._failurePolicy === "abort-dependents",
|
||||
});
|
||||
this.effectDisposers.push(disposer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,15 +211,29 @@ export class WorkflowReactiveRoot implements EventLogProjection {
|
||||
|
||||
if (!("requestId" in event)) return;
|
||||
|
||||
const nodeId = this.findNodeByRequestId(event.requestId);
|
||||
let nodeId = this.requestIdToNodeKey.get(event.requestId);
|
||||
|
||||
if (nodeId === undefined) {
|
||||
for (const [nId, rid] of this.nodeKeyToRequestId) {
|
||||
if (rid === event.requestId) {
|
||||
nodeId = nId;
|
||||
this.requestIdToNodeKey.set(event.requestId, nId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nodeId === undefined) return;
|
||||
|
||||
const statusSignal = this.statusMap.get(nodeId);
|
||||
if (!statusSignal) return;
|
||||
const currentRequestId = this.nodeKeyToRequestId.get(nodeId);
|
||||
if (currentRequestId === event.requestId) {
|
||||
const statusSignal = this.statusMap.get(nodeId);
|
||||
if (!statusSignal) return;
|
||||
|
||||
const derived = EVENT_TO_STATUS[event.type];
|
||||
if (derived !== undefined) {
|
||||
statusSignal.value = derived;
|
||||
const derived = EVENT_TO_STATUS[event.type];
|
||||
if (derived !== undefined) {
|
||||
statusSignal.value = derived;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,12 +266,17 @@ export class WorkflowReactiveRoot implements EventLogProjection {
|
||||
}
|
||||
|
||||
getEvents(nodeId: string): CallEventMapValue[] {
|
||||
const requestId = this.nodeKeyToRequestId.get(nodeId);
|
||||
if (!requestId) return [];
|
||||
const requestIds = new Set<string>();
|
||||
for (const [rid, nId] of this.requestIdToNodeKey) {
|
||||
if (nId === nodeId) {
|
||||
requestIds.add(rid);
|
||||
}
|
||||
}
|
||||
if (requestIds.size === 0) return [];
|
||||
|
||||
const events: CallEventMapValue[] = [];
|
||||
for (const e of this.eventLog) {
|
||||
if ("requestId" in e && e.requestId === requestId) {
|
||||
if ("requestId" in e && requestIds.has(e.requestId)) {
|
||||
events.push(e);
|
||||
}
|
||||
}
|
||||
@@ -322,16 +339,11 @@ export class WorkflowReactiveRoot implements EventLogProjection {
|
||||
this.effectDisposers = [];
|
||||
this.statusMap.clear();
|
||||
this.preconditions.clear();
|
||||
this.canStart.clear();
|
||||
this.blockedByFailure.clear();
|
||||
this.resultMap.clear();
|
||||
this.nodeKeyToRequestId.clear();
|
||||
this.requestIdToNodeKey.clear();
|
||||
this.eventLog = [];
|
||||
}
|
||||
|
||||
private findNodeByRequestId(requestId: string): string | undefined {
|
||||
for (const [nodeId, rid] of this.nodeKeyToRequestId) {
|
||||
if (rid === requestId) return nodeId;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: analysis/build-type-edges
|
||||
name: Implement buildTypeEdges — populate operation graph with type-compatibility edges
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on:
|
||||
- analysis/type-compat
|
||||
- graph/construction-operation
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: analysis/ordering
|
||||
name: Implement execution ordering functions (topologicalOrder, parallelGroups, criticalPath, reachableFrom)
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on:
|
||||
- graph/flowgraph-class
|
||||
- graph/queries
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: analysis/template-validation
|
||||
name: Implement validateTemplate and validatePreconditions
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on:
|
||||
- analysis/type-compat
|
||||
- graph/queries
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: api/public-exports
|
||||
name: Wire barrel exports and verify exports map resolves correctly for all sub-paths
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on:
|
||||
- review/reactive-and-hosts
|
||||
- analysis/type-compat
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: graph/construction-call
|
||||
name: Implement call graph construction (fromCallEvents, updateFromEvent, addCall, addDependency, updateStatus)
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on:
|
||||
- graph/flowgraph-class
|
||||
- schema/enums
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: graph/construction-json
|
||||
name: Implement fromJSON and export/toJSON serialization for FlowGraph
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on:
|
||||
- graph/flowgraph-class
|
||||
- schema/graph-schemas
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: host/reactive
|
||||
name: Implement ReactiveHostConfig — render ujsx template to reactive execution engine
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on:
|
||||
- component/operation
|
||||
- component/sequential
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: meta/analysis-layer
|
||||
name: Complete analysis layer — type compatibility, ordering, template/graph validation, defaults
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on:
|
||||
- analysis/type-compat
|
||||
- analysis/build-type-edges
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: meta/graph-layer
|
||||
name: Complete graph layer — FlowGraph class, all construction paths, queries, validation
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on:
|
||||
- graph/flowgraph-class
|
||||
- graph/construction-operation
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: meta/reactive-layer
|
||||
name: Complete reactive execution layer — WorkflowRoot, node-status, maxConcurrency, retries
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on:
|
||||
- reactive/workflow-root
|
||||
- reactive/node-status
|
||||
@@ -19,11 +19,11 @@ Meta task that clusters all reactive execution tasks. Once complete, the reactiv
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] All reactive tasks completed
|
||||
- [ ] WorkflowReactiveRoot implements EventLogProjection correctly
|
||||
- [ ] Preconditions drive automatic state transitions
|
||||
- [ ] Failure follows dependency edges, not structural scope
|
||||
- [ ] dispose() prevents signal leaks
|
||||
- [x] All reactive tasks completed
|
||||
- [x] WorkflowReactiveRoot implements EventLogProjection correctly
|
||||
- [x] Preconditions drive automatic state transitions
|
||||
- [x] Failure follows dependency edges, not structural scope
|
||||
- [x] dispose() prevents signal leaks
|
||||
|
||||
## References
|
||||
|
||||
@@ -31,8 +31,8 @@ Meta task that clusters all reactive execution tasks. Once complete, the reactiv
|
||||
|
||||
## Notes
|
||||
|
||||
> To be filled by implementation agent
|
||||
All four dependent tasks (reactive/workflow-root, reactive/node-status, reactive/max-concurrency, reactive/retry-semantics) were already completed. This meta-task verifies the integrated reactive execution layer works correctly end-to-end.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
Verified the complete reactive execution layer: WorkflowReactiveRoot implements EventLogProjection correctly with signal-backed status, computed preconditions drive automatic state transitions, failure propagation follows dependency edges (not structural scope), maxConcurrency semaphore works for Parallel groups, retry semantics preserve full event history, and dispose() prevents signal leaks. All 665 tests pass (129 reactive-specific).
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: review/reactive-and-hosts
|
||||
name: Review reactive execution and host configs — signal graph, preconditions, HostConfig implementations
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on:
|
||||
- reactive/workflow-root
|
||||
- reactive/node-status
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: reactive/max-concurrency
|
||||
name: Implement maxConcurrency semaphore for Parallel groups
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on:
|
||||
- reactive/node-status
|
||||
- component/parallel
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: reactive/node-status
|
||||
name: Implement node status signal management and computed preconditions + blockedByFailure
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on:
|
||||
- reactive/workflow-root
|
||||
- schema/enums
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: reactive/retry-semantics
|
||||
name: Implement retry semantics — event log append with new requestId, status projection respects retries
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on:
|
||||
- reactive/workflow-root
|
||||
scope: narrow
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: review/complete-library
|
||||
name: Final review — validate full library against architecture docs, build, and exports
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on:
|
||||
- api/public-exports
|
||||
- review/foundation
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: review/foundation
|
||||
name: Review foundation layer — schemas, errors, FlowGraph class, construction
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on:
|
||||
- schema/enums
|
||||
- schema/node-attrs
|
||||
|
||||
270
test/analysis/ordering.test.ts
Normal file
270
test/analysis/ordering.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { FlowGraph } from "../../src/graph/construction.js";
|
||||
import {
|
||||
topologicalOrder,
|
||||
parallelGroups,
|
||||
criticalPath,
|
||||
reachableFrom,
|
||||
ancestors,
|
||||
descendants,
|
||||
} from "../../src/analysis/ordering.js";
|
||||
import { CycleError, NodeNotFoundError } from "../../src/error/index.js";
|
||||
|
||||
function buildDiamondDag(): FlowGraph {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("top", { name: "top" });
|
||||
fg.addNode("left", { name: "left" });
|
||||
fg.addNode("right", { name: "right" });
|
||||
fg.addNode("bottom", { name: "bottom" });
|
||||
fg.addEdge("top", "left");
|
||||
fg.addEdge("top", "right");
|
||||
fg.addEdge("left", "bottom");
|
||||
fg.addEdge("right", "bottom");
|
||||
return fg;
|
||||
}
|
||||
|
||||
function buildChainDag(): FlowGraph {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
fg.addNode("b", { name: "b" });
|
||||
fg.addNode("c", { name: "c" });
|
||||
fg.addNode("d", { name: "d" });
|
||||
fg.addEdge("a", "b");
|
||||
fg.addEdge("b", "c");
|
||||
fg.addEdge("c", "d");
|
||||
return fg;
|
||||
}
|
||||
|
||||
function buildWideDag(): FlowGraph {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("root", { name: "root" });
|
||||
fg.addNode("x", { name: "x" });
|
||||
fg.addNode("y", { name: "y" });
|
||||
fg.addNode("z", { name: "z" });
|
||||
fg.addEdge("root", "x");
|
||||
fg.addEdge("root", "y");
|
||||
fg.addEdge("root", "z");
|
||||
return fg;
|
||||
}
|
||||
|
||||
function buildDisconnectedDag(): FlowGraph {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" });
|
||||
fg.addNode("b", { name: "b" });
|
||||
fg.addNode("c", { name: "c" });
|
||||
fg.addEdge("a", "b");
|
||||
return fg;
|
||||
}
|
||||
|
||||
describe("topologicalOrder (analysis)", () => {
|
||||
it("returns topological order for a chain", () => {
|
||||
const fg = buildChainDag();
|
||||
expect(topologicalOrder(fg)).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
|
||||
it("returns topological order for a diamond DAG", () => {
|
||||
const fg = buildDiamondDag();
|
||||
const order = topologicalOrder(fg);
|
||||
expect(order[0]).toBe("top");
|
||||
expect(order[3]).toBe("bottom");
|
||||
expect(order).toContain("left");
|
||||
expect(order).toContain("right");
|
||||
});
|
||||
|
||||
it("returns empty array for empty graph", () => {
|
||||
const fg = new FlowGraph();
|
||||
expect(topologicalOrder(fg)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns single node for graph with one node", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("only", { name: "only" });
|
||||
expect(topologicalOrder(fg)).toEqual(["only"]);
|
||||
});
|
||||
|
||||
it("throws CycleError when graph has cycles", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.graph.addNode("a");
|
||||
fg.graph.addNode("b");
|
||||
fg.graph.addNode("c");
|
||||
fg.graph.addEdgeWithKey("a->b", "a", "b");
|
||||
fg.graph.addEdgeWithKey("b->c", "b", "c");
|
||||
fg.graph.addEdgeWithKey("c->a", "c", "a");
|
||||
expect(() => topologicalOrder(fg)).toThrow(CycleError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parallelGroups (analysis)", () => {
|
||||
it("groups chain nodes into sequential groups", () => {
|
||||
const fg = buildChainDag();
|
||||
const groups = parallelGroups(fg);
|
||||
expect(groups).toEqual([["a"], ["b"], ["c"], ["d"]]);
|
||||
});
|
||||
|
||||
it("groups diamond DAG correctly", () => {
|
||||
const fg = buildDiamondDag();
|
||||
const groups = parallelGroups(fg);
|
||||
expect(groups.length).toBe(3);
|
||||
expect(groups[0]).toEqual(["top"]);
|
||||
expect(groups[1]!.sort()).toEqual(["left", "right"]);
|
||||
expect(groups[2]).toEqual(["bottom"]);
|
||||
});
|
||||
|
||||
it("groups wide DAG with root and leaves", () => {
|
||||
const fg = buildWideDag();
|
||||
const groups = parallelGroups(fg);
|
||||
expect(groups.length).toBe(2);
|
||||
expect(groups[0]).toEqual(["root"]);
|
||||
expect(groups[1]!.sort()).toEqual(["x", "y", "z"]);
|
||||
});
|
||||
|
||||
it("groups disconnected DAG correctly", () => {
|
||||
const fg = buildDisconnectedDag();
|
||||
const groups = parallelGroups(fg);
|
||||
expect(groups.length).toBe(2);
|
||||
expect(groups[0]!.sort()).toEqual(["a", "c"]);
|
||||
expect(groups[1]).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty graph", () => {
|
||||
const fg = new FlowGraph();
|
||||
expect(parallelGroups(fg)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns single group for single node with no edges", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("only", { name: "only" });
|
||||
expect(parallelGroups(fg)).toEqual([["only"]]);
|
||||
});
|
||||
|
||||
it("throws CycleError when graph has cycles", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.graph.addNode("a");
|
||||
fg.graph.addNode("b");
|
||||
fg.graph.addEdgeWithKey("a->b", "a", "b");
|
||||
fg.graph.addEdgeWithKey("b->a", "b", "a");
|
||||
expect(() => parallelGroups(fg)).toThrow(CycleError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("criticalPath (analysis)", () => {
|
||||
it("returns full chain for a chain DAG", () => {
|
||||
const fg = buildChainDag();
|
||||
expect(criticalPath(fg)).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
|
||||
it("returns a longest path for diamond DAG", () => {
|
||||
const fg = buildDiamondDag();
|
||||
const path = criticalPath(fg);
|
||||
expect(path.length).toBe(3);
|
||||
expect(path[0]).toBe("top");
|
||||
expect(path[2]).toBe("bottom");
|
||||
expect(path[1] === "left" || path[1] === "right").toBe(true);
|
||||
});
|
||||
|
||||
it("returns single node for graph with one node", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("only", { name: "only" });
|
||||
expect(criticalPath(fg)).toEqual(["only"]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty graph", () => {
|
||||
const fg = new FlowGraph();
|
||||
expect(criticalPath(fg)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns correct path for wide DAG", () => {
|
||||
const fg = buildWideDag();
|
||||
const path = criticalPath(fg);
|
||||
expect(path.length).toBe(2);
|
||||
expect(path[0]).toBe("root");
|
||||
});
|
||||
|
||||
it("throws CycleError when graph has cycles", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.graph.addNode("a");
|
||||
fg.graph.addNode("b");
|
||||
fg.graph.addEdgeWithKey("a->b", "a", "b");
|
||||
fg.graph.addEdgeWithKey("b->a", "b", "a");
|
||||
expect(() => criticalPath(fg)).toThrow(CycleError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reachableFrom (analysis)", () => {
|
||||
it("returns all reachable nodes from a single start node", () => {
|
||||
const fg = buildChainDag();
|
||||
expect(reachableFrom(fg, ["a"])).toEqual(new Set(["a", "b", "c", "d"]));
|
||||
});
|
||||
|
||||
it("returns only the start node if it is a leaf", () => {
|
||||
const fg = buildChainDag();
|
||||
expect(reachableFrom(fg, ["d"])).toEqual(new Set(["d"]));
|
||||
});
|
||||
|
||||
it("returns union of reachable nodes from multiple start nodes", () => {
|
||||
const fg = buildDiamondDag();
|
||||
expect(reachableFrom(fg, ["left", "right"])).toEqual(new Set(["left", "right", "bottom"]));
|
||||
});
|
||||
|
||||
it("returns empty set for empty input", () => {
|
||||
const fg = buildChainDag();
|
||||
expect(reachableFrom(fg, [])).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("returns full reachable set from root in diamond", () => {
|
||||
const fg = buildDiamondDag();
|
||||
expect(reachableFrom(fg, ["top"])).toEqual(new Set(["top", "left", "right", "bottom"]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("ancestors (analysis)", () => {
|
||||
it("returns all ancestors for a node in a chain", () => {
|
||||
const fg = buildChainDag();
|
||||
expect(ancestors(fg, "d")).toEqual(["c", "b", "a"]);
|
||||
});
|
||||
|
||||
it("returns all ancestors for a node in a diamond DAG", () => {
|
||||
const fg = buildDiamondDag();
|
||||
const anc = ancestors(fg, "bottom");
|
||||
expect(anc).toContain("left");
|
||||
expect(anc).toContain("right");
|
||||
expect(anc).toContain("top");
|
||||
expect(anc.length).toBe(3);
|
||||
});
|
||||
|
||||
it("returns empty array for a root node", () => {
|
||||
const fg = buildChainDag();
|
||||
expect(ancestors(fg, "a")).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws NodeNotFoundError for missing node", () => {
|
||||
const fg = new FlowGraph();
|
||||
expect(() => ancestors(fg, "missing")).toThrow(NodeNotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("descendants (analysis)", () => {
|
||||
it("returns all descendants for a node in a chain", () => {
|
||||
const fg = buildChainDag();
|
||||
expect(descendants(fg, "a")).toEqual(["b", "c", "d"]);
|
||||
});
|
||||
|
||||
it("returns all descendants for a node in a diamond DAG", () => {
|
||||
const fg = buildDiamondDag();
|
||||
const desc = descendants(fg, "top");
|
||||
expect(desc).toContain("left");
|
||||
expect(desc).toContain("right");
|
||||
expect(desc).toContain("bottom");
|
||||
expect(desc.length).toBe(3);
|
||||
});
|
||||
|
||||
it("returns empty array for a leaf node", () => {
|
||||
const fg = buildChainDag();
|
||||
expect(descendants(fg, "d")).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws NodeNotFoundError for missing node", () => {
|
||||
const fg = new FlowGraph();
|
||||
expect(() => descendants(fg, "missing")).toThrow(NodeNotFoundError);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Type, type TSchema } from "@alkdev/typebox";
|
||||
import { typeCompat, type TypeCompatResult, type TypeMismatch } from "../../src/analysis/type-compat.js";
|
||||
import { typeCompat, buildTypeEdges, type TypeCompatResult, type TypeMismatch } from "../../src/analysis/type-compat.js";
|
||||
import { FlowGraph } from "../../src/graph/construction.js";
|
||||
import type { OperationSpec } from "../../src/graph/construction.js";
|
||||
|
||||
describe("typeCompat", () => {
|
||||
describe("exact match", () => {
|
||||
@@ -411,4 +413,111 @@ describe("typeCompat", () => {
|
||||
expect(typeof mismatch.actual).toBe("string");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTypeEdges", () => {
|
||||
it("adds compatible edges for matching output→input schemas", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addOperation({ name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) });
|
||||
fg.addOperation({ name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String(), score: Type.Number() }) });
|
||||
buildTypeEdges(fg);
|
||||
expect(fg.hasEdge("task.extract", "task.classify")).toBe(true);
|
||||
const attrs = fg.getEdgeAttributes("task.extract", "task.classify") as Record<string, unknown>;
|
||||
expect(attrs.edgeType).toBe("typed");
|
||||
expect(attrs.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it("adds incompatible edges when schemas mismatch", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addOperation({ name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String(), score: Type.Number() }) });
|
||||
fg.addOperation({ name: "count", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ count: Type.Number() }), outputSchema: Type.Object({ result: Type.Number() }) });
|
||||
buildTypeEdges(fg);
|
||||
expect(fg.hasEdge("task.classify", "task.count")).toBe(true);
|
||||
const attrs = fg.getEdgeAttributes("task.classify", "task.count") as Record<string, unknown>;
|
||||
expect(attrs.edgeType).toBe("typed");
|
||||
expect(attrs.compatible).toBe(false);
|
||||
expect(attrs.mismatches).toBeDefined();
|
||||
});
|
||||
|
||||
it("incompatible edges include mismatches array", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addOperation({ name: "a", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ x: Type.String() }), outputSchema: Type.Object({ value: Type.String() }) });
|
||||
fg.addOperation({ name: "b", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ value: Type.Number() }), outputSchema: Type.Object({ z: Type.Boolean() }) });
|
||||
buildTypeEdges(fg);
|
||||
const attrs = fg.getEdgeAttributes("op.a", "op.b") as Record<string, unknown>;
|
||||
expect(attrs.compatible).toBe(false);
|
||||
expect(Array.isArray(attrs.mismatches)).toBe(true);
|
||||
expect((attrs.mismatches as Array<unknown>).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not add edges when either schema is Unknown", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addOperation({ name: "unk_out", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ x: Type.String() }), outputSchema: Type.Unknown() });
|
||||
fg.addOperation({ name: "unk_in", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Unknown(), outputSchema: Type.Object({ y: Type.String() }) });
|
||||
fg.addOperation({ name: "normal", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ y: Type.String() }), outputSchema: Type.Object({ x: Type.String() }) });
|
||||
buildTypeEdges(fg);
|
||||
expect(fg.hasEdge("op.unk_out", "op.unk_in")).toBe(false);
|
||||
expect(fg.hasEdge("op.unk_out", "op.normal")).toBe(false);
|
||||
expect(fg.hasEdge("op.normal", "op.unk_in")).toBe(false);
|
||||
});
|
||||
|
||||
it("sets detail to namespace.name.output → namespace.name.input for compatible edges", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addOperation({ name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) });
|
||||
fg.addOperation({ name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String() }) });
|
||||
buildTypeEdges(fg);
|
||||
const attrs = fg.getEdgeAttributes("task.extract", "task.classify") as Record<string, unknown>;
|
||||
expect(attrs.detail).toContain("task.extract.output → task.classify.input");
|
||||
});
|
||||
|
||||
it("is callable after incremental addOperation calls", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addOperation({ name: "extract", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) });
|
||||
buildTypeEdges(fg);
|
||||
expect(fg.size).toBe(0);
|
||||
fg.addOperation({ name: "classify", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String() }) });
|
||||
buildTypeEdges(fg);
|
||||
expect(fg.hasEdge("op.extract", "op.classify")).toBe(true);
|
||||
});
|
||||
|
||||
it("produces edges for three operations in a pipeline", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addOperation({ name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) });
|
||||
fg.addOperation({ name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String() }) });
|
||||
fg.addOperation({ name: "enrich", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ label: Type.String() }), outputSchema: Type.Object({ enriched: Type.String() }) });
|
||||
buildTypeEdges(fg);
|
||||
expect(fg.hasEdge("task.extract", "task.classify")).toBe(true);
|
||||
expect(fg.hasEdge("task.classify", "task.enrich")).toBe(true);
|
||||
expect(fg.hasEdge("task.extract", "task.enrich")).toBe(true);
|
||||
const e2c = fg.getEdgeAttributes("task.extract", "task.classify") as Record<string, unknown>;
|
||||
const c2e = fg.getEdgeAttributes("task.classify", "task.enrich") as Record<string, unknown>;
|
||||
const e2e = fg.getEdgeAttributes("task.extract", "task.enrich") as Record<string, unknown>;
|
||||
expect(e2c.compatible).toBe(true);
|
||||
expect(c2e.compatible).toBe(true);
|
||||
expect(e2e.compatible).toBe(false);
|
||||
});
|
||||
|
||||
it("does not add self-loops", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addOperation({ name: "a", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ x: Type.String() }), outputSchema: Type.Object({ x: Type.String() }) });
|
||||
buildTypeEdges(fg);
|
||||
expect(fg.size).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty graph with no edges for empty graph", () => {
|
||||
const fg = new FlowGraph();
|
||||
buildTypeEdges(fg);
|
||||
expect(fg.order).toBe(0);
|
||||
expect(fg.size).toBe(0);
|
||||
});
|
||||
|
||||
it("skips edges that would already exist", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addOperation({ name: "a", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ x: Type.String() }), outputSchema: Type.Object({ y: Type.String() }) });
|
||||
fg.addOperation({ name: "b", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ y: Type.String() }), outputSchema: Type.Object({ z: Type.String() }) });
|
||||
buildTypeEdges(fg);
|
||||
const sizeAfterFirst = fg.size;
|
||||
buildTypeEdges(fg);
|
||||
expect(fg.size).toBe(sizeAfterFirst);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,251 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Type } from "@alkdev/typebox";
|
||||
import { h, createHostRoot } from "@alkdev/ujsx";
|
||||
import { Operation, Sequential, Parallel, Conditional } from "../../src/component/index.js";
|
||||
import { validatePreconditions, validateTemplate } from "../../src/analysis/workflow.js";
|
||||
import { FlowGraph } from "../../src/graph/construction.js";
|
||||
import type { OperationNodeAttrs, OperationEdgeAttrs } from "../../src/schema/index.js";
|
||||
|
||||
describe('analysis workflow', () => {
|
||||
it('placeholder', () => {
|
||||
expect(true).toBe(true);
|
||||
type OpGraph = FlowGraph<typeof import("../../src/schema/node.js").OperationNodeAttrs, typeof import("../../src/schema/edge.js").OperationEdgeAttrs>;
|
||||
|
||||
function createOperationGraph(
|
||||
specs: Array<{
|
||||
name: string;
|
||||
namespace?: string;
|
||||
inputSchema?: Record<string, unknown>;
|
||||
outputSchema?: Record<string, unknown>;
|
||||
}>,
|
||||
): OpGraph {
|
||||
const graph = new FlowGraph() as OpGraph;
|
||||
for (const spec of specs) {
|
||||
const ns = spec.namespace ?? "test";
|
||||
const key = `${ns}.${spec.name}`;
|
||||
graph.addNode(key, {
|
||||
name: spec.name,
|
||||
namespace: ns,
|
||||
version: "1.0.0",
|
||||
type: "query",
|
||||
inputSchema: spec.inputSchema ?? Type.Object({}),
|
||||
outputSchema: spec.outputSchema ?? Type.Object({}),
|
||||
} as OperationNodeAttrs);
|
||||
}
|
||||
return graph;
|
||||
}
|
||||
|
||||
function createOperationGraphWithEdges(
|
||||
specs: Array<{
|
||||
name: string;
|
||||
namespace?: string;
|
||||
inputSchema?: Record<string, unknown>;
|
||||
outputSchema?: Record<string, unknown>;
|
||||
}>,
|
||||
edges?: Array<{ source: string; target: string; compatible: boolean; mismatches?: Array<{ path: string; expected: string; actual: string }> }>,
|
||||
): OpGraph {
|
||||
const graph = createOperationGraph(specs);
|
||||
if (edges) {
|
||||
for (const edge of edges) {
|
||||
graph.addTypedEdge(edge.source, edge.target, {
|
||||
compatible: edge.compatible,
|
||||
mismatches: edge.mismatches,
|
||||
});
|
||||
}
|
||||
}
|
||||
return graph;
|
||||
}
|
||||
|
||||
describe("validatePreconditions", () => {
|
||||
it("returns empty for valid graph with no required fields", () => {
|
||||
const graph = createOperationGraph([
|
||||
{ name: "a", outputSchema: Type.Object({ x: Type.Number() }) },
|
||||
{ name: "b", inputSchema: Type.Object({}) },
|
||||
]);
|
||||
graph.addEdge("test.a", "test.b");
|
||||
const errors = validatePreconditions(graph);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty when all required input fields are provided by predecessors", () => {
|
||||
const graph = createOperationGraph([
|
||||
{ name: "a", outputSchema: Type.Object({ x: Type.Number(), y: Type.String() }) },
|
||||
{ name: "b", inputSchema: Type.Object({ x: Type.Number() }) },
|
||||
]);
|
||||
graph.addEdge("test.a", "test.b");
|
||||
const errors = validatePreconditions(graph);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns errors when required input field is not provided by any predecessor", () => {
|
||||
const graph = createOperationGraph([
|
||||
{ name: "a", outputSchema: Type.Object({ x: Type.Number() }) },
|
||||
{ name: "b", inputSchema: Type.Object({ x: Type.Number(), y: Type.String() }) },
|
||||
]);
|
||||
graph.addEdge("test.a", "test.b");
|
||||
const errors = validatePreconditions(graph);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
const fieldNames = errors.map((e) => e.field);
|
||||
expect(fieldNames).toContain("y");
|
||||
});
|
||||
|
||||
it("returns errors when node with required fields has no predecessors", () => {
|
||||
const graph = createOperationGraph([
|
||||
{ name: "a", inputSchema: Type.Object({ x: Type.Number() }), outputSchema: Type.Object({}) },
|
||||
]);
|
||||
const errors = validatePreconditions(graph);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0]!.message).toContain("no predecessor");
|
||||
});
|
||||
|
||||
it("collects provided fields from multiple predecessors", () => {
|
||||
const graph = createOperationGraph([
|
||||
{ name: "a", outputSchema: Type.Object({ x: Type.Number() }) },
|
||||
{ name: "c", outputSchema: Type.Object({ y: Type.String() }) },
|
||||
{ name: "b", inputSchema: Type.Object({ x: Type.Number(), y: Type.String() }) },
|
||||
]);
|
||||
graph.addEdge("test.a", "test.b");
|
||||
graph.addEdge("test.c", "test.b");
|
||||
const errors = validatePreconditions(graph);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty for graph with no nodes", () => {
|
||||
const graph = new FlowGraph() as OpGraph;
|
||||
const errors = validatePreconditions(graph);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateTemplate", () => {
|
||||
it("returns empty for valid template with all operations in graph", () => {
|
||||
const graph = createOperationGraphWithEdges([
|
||||
{ name: "a", outputSchema: Type.Object({ x: Type.Number() }) },
|
||||
{ name: "b", inputSchema: Type.Object({ x: Type.Number() }) },
|
||||
], [
|
||||
{ source: "test.a", target: "test.b", compatible: true },
|
||||
]);
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "test.a" }),
|
||||
h(Operation, { name: "test.b" }),
|
||||
);
|
||||
const errors = validateTemplate(template, graph);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns error when operation name is not in operation graph", () => {
|
||||
const graph = createOperationGraph([
|
||||
{ name: "a" },
|
||||
]);
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "test.a" }),
|
||||
h(Operation, { name: "test.missing" }),
|
||||
);
|
||||
const errors = validateTemplate(template, graph);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
const missingErrors = errors.filter(
|
||||
(e) => e.type === "graph" && (e as { type: string; category: string; details: unknown }).category === "orphan-node"
|
||||
&& JSON.stringify((e as { details: unknown }).details).includes("missing"),
|
||||
);
|
||||
expect(missingErrors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns error for type incompatibility between sequential operations", () => {
|
||||
const graph = createOperationGraphWithEdges([
|
||||
{ name: "a", outputSchema: Type.Object({ x: Type.String() }) },
|
||||
{ name: "b", inputSchema: Type.Object({ x: Type.Number() }) },
|
||||
], [
|
||||
{ source: "test.a", target: "test.b", compatible: false, mismatches: [{ path: "/x", expected: "number", actual: "string" }] },
|
||||
]);
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "test.a" }),
|
||||
h(Operation, { name: "test.b" }),
|
||||
);
|
||||
const errors = validateTemplate(template, graph);
|
||||
const typeErrors = errors.filter((e) => e.type === "type-compat");
|
||||
expect(typeErrors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns empty for single-operation template", () => {
|
||||
const graph = createOperationGraph([
|
||||
{ name: "a" },
|
||||
]);
|
||||
const template = h(Operation, { name: "test.a" });
|
||||
const errors = validateTemplate(template, graph);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects unreachable nodes", () => {
|
||||
const graph = createOperationGraphWithEdges([
|
||||
{ name: "a" },
|
||||
{ name: "b" },
|
||||
{ name: "c" },
|
||||
]);
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "test.a" }),
|
||||
h(Operation, { name: "test.b" }),
|
||||
);
|
||||
const errors = validateTemplate(template, graph);
|
||||
const reachableErrors = errors.filter(
|
||||
(e) => e.type === "graph" && JSON.stringify((e as { details: unknown }).details).includes("not reachable"),
|
||||
);
|
||||
expect(reachableErrors.length).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty for valid parallel template", () => {
|
||||
const graph = createOperationGraphWithEdges([
|
||||
{ name: "a" },
|
||||
{ name: "b" },
|
||||
{ name: "c" },
|
||||
]);
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "test.a" }),
|
||||
h(Parallel, {},
|
||||
h(Operation, { name: "test.b" }),
|
||||
h(Operation, { name: "test.c" }),
|
||||
),
|
||||
);
|
||||
const errors = validateTemplate(template, graph);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles template with conditional", () => {
|
||||
const graph = createOperationGraphWithEdges([
|
||||
{ name: "a", outputSchema: Type.Object({ result: Type.Boolean() }) },
|
||||
{ name: "b" },
|
||||
{ name: "c" },
|
||||
]);
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "test.a" }),
|
||||
h(Conditional, { test: "test.a" },
|
||||
h(Operation, { name: "test.b" }),
|
||||
),
|
||||
h(Operation, { name: "test.c" }),
|
||||
);
|
||||
const errors = validateTemplate(template, graph);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("template validation is advisory - never throws", () => {
|
||||
const graph = new FlowGraph() as OpGraph;
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "nonexistent" }),
|
||||
);
|
||||
const errors = validateTemplate(template, graph);
|
||||
expect(Array.isArray(errors)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects orphan node with no edges in multi-node template", () => {
|
||||
const graph = createOperationGraphWithEdges([
|
||||
{ name: "a" },
|
||||
{ name: "b" },
|
||||
]);
|
||||
const template = h(Parallel, {},
|
||||
h(Operation, { name: "test.a" }),
|
||||
h(Operation, { name: "test.b" }),
|
||||
);
|
||||
const errors = validateTemplate(template, graph);
|
||||
const orphanErrors = errors.filter(
|
||||
(e) => e.type === "graph" && (e as { type: string; category: string }).category === "orphan-node"
|
||||
&& JSON.stringify((e as { details: unknown }).details).includes("no edges"),
|
||||
);
|
||||
expect(orphanErrors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
77
test/exports.test.ts
Normal file
77
test/exports.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { FlowGraph, typeCompat, CycleError, OperationNodeAttrs, Operation, GraphologyHostConfig, WorkflowReactiveRoot, topologicalOrder } from "../src/index.js";
|
||||
import { buildTypeEdges as buildTypeEdgesGraph } from "../src/graph/index.js";
|
||||
import { CallStatusEnum, NodeStatusEnum, EdgeTypeEnum, CallResultSchema, OperationEdgeAttrs, CallEdgeAttrs, TemplateEdgeAttrs, CallNodeAttrs } from "../src/schema/index.js";
|
||||
import { Sequential, Parallel, Conditional, Map } from "../src/component/index.js";
|
||||
import { ReactiveHostConfig } from "../src/host/index.js";
|
||||
import { WorkflowReactiveRoot as ReactiveRootFromReactive, computePreconditions, computeBlockedByFailure } from "../src/reactive/index.js";
|
||||
import { typeCompat as typeCompatAnalysis, buildTypeEdges, validateGraph, validateTemplate, topologicalOrder as topologicalOrderAnalysis, parallelGroups, criticalPath, reachableFrom } from "../src/analysis/index.js";
|
||||
import { FlowgraphError, ConstructionError, CycleError as CycleErrorFromError, InvalidTransitionError } from "../src/error/index.js";
|
||||
|
||||
describe("Sub-path imports", () => {
|
||||
it("@alkdev/flowgraph → all public types", () => {
|
||||
expect(FlowGraph).toBeDefined();
|
||||
expect(typeCompat).toBeDefined();
|
||||
expect(CycleError).toBeDefined();
|
||||
expect(OperationNodeAttrs).toBeDefined();
|
||||
expect(Operation).toBeDefined();
|
||||
expect(GraphologyHostConfig).toBeDefined();
|
||||
expect(WorkflowReactiveRoot).toBeDefined();
|
||||
expect(topologicalOrder).toBeDefined();
|
||||
});
|
||||
|
||||
it("@alkdev/flowgraph/graph → FlowGraph, FlowGraphOptions", () => {
|
||||
expect(FlowGraph).toBeDefined();
|
||||
expect(buildTypeEdgesGraph).toBeDefined();
|
||||
});
|
||||
|
||||
it("@alkdev/flowgraph/schema → all schemas, enums, types, CallResult", () => {
|
||||
expect(OperationNodeAttrs).toBeDefined();
|
||||
expect(CallNodeAttrs).toBeDefined();
|
||||
expect(OperationEdgeAttrs).toBeDefined();
|
||||
expect(CallEdgeAttrs).toBeDefined();
|
||||
expect(TemplateEdgeAttrs).toBeDefined();
|
||||
expect(CallResultSchema).toBeDefined();
|
||||
expect(CallStatusEnum).toBeDefined();
|
||||
expect(NodeStatusEnum).toBeDefined();
|
||||
expect(EdgeTypeEnum).toBeDefined();
|
||||
});
|
||||
|
||||
it("@alkdev/flowgraph/component → Operation, Sequential, Parallel, Conditional, Map", () => {
|
||||
expect(Operation).toBeDefined();
|
||||
expect(Sequential).toBeDefined();
|
||||
expect(Parallel).toBeDefined();
|
||||
expect(Conditional).toBeDefined();
|
||||
expect(Map).toBeDefined();
|
||||
});
|
||||
|
||||
it("@alkdev/flowgraph/host → GraphologyHostConfig, ReactiveHostConfig", () => {
|
||||
expect(GraphologyHostConfig).toBeDefined();
|
||||
expect(ReactiveHostConfig).toBeDefined();
|
||||
});
|
||||
|
||||
it("@alkdev/flowgraph/reactive → WorkflowReactiveRoot, EventLogProjection", () => {
|
||||
expect(ReactiveRootFromReactive).toBeDefined();
|
||||
expect(computePreconditions).toBeDefined();
|
||||
expect(computeBlockedByFailure).toBeDefined();
|
||||
});
|
||||
|
||||
it("@alkdev/flowgraph/analysis → typeCompat, buildTypeEdges, validateGraph, validateTemplate, topologicalOrder, parallelGroups, criticalPath, reachableFrom", () => {
|
||||
expect(typeCompatAnalysis).toBeDefined();
|
||||
expect(buildTypeEdges).toBeDefined();
|
||||
expect(validateGraph).toBeDefined();
|
||||
expect(validateTemplate).toBeDefined();
|
||||
expect(topologicalOrderAnalysis).toBeDefined();
|
||||
expect(parallelGroups).toBeDefined();
|
||||
expect(criticalPath).toBeDefined();
|
||||
expect(reachableFrom).toBeDefined();
|
||||
});
|
||||
|
||||
it("@alkdev/flowgraph/error → all error classes", () => {
|
||||
expect(FlowgraphError).toBeDefined();
|
||||
expect(ConstructionError).toBeDefined();
|
||||
expect(CycleErrorFromError).toBeDefined();
|
||||
expect(InvalidTransitionError).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
CycleError,
|
||||
InvalidTransitionError,
|
||||
} from "../../src/error/index.js";
|
||||
import type { CallStatus } from "../../src/error/index.js";
|
||||
import type { CallStatus } from "../../src/schema/enums.js";
|
||||
|
||||
describe("FlowGraph constructor", () => {
|
||||
it("creates an empty graph", () => {
|
||||
@@ -307,7 +307,6 @@ describe("FlowGraph static stubs", () => {
|
||||
expect(graph.order).toBe(0);
|
||||
expect(graph.size).toBe(0);
|
||||
});
|
||||
|
||||
it("fromJSON throws not implemented", () => {
|
||||
expect(() => FlowGraph.fromJSON({} as never)).toThrow();
|
||||
});
|
||||
|
||||
232
test/graph/serialization.test.ts
Normal file
232
test/graph/serialization.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Type } from "@alkdev/typebox";
|
||||
import { FlowGraph } from "../../src/graph/construction.js";
|
||||
import type { OperationSpec } from "../../src/graph/construction.js";
|
||||
import { InvalidInputError, CycleError } from "../../src/error/index.js";
|
||||
|
||||
describe("FlowGraph.export", () => {
|
||||
it("returns graphology native JSON format for empty graph", () => {
|
||||
const fg = new FlowGraph();
|
||||
const data = fg.export();
|
||||
expect(data.options).toEqual({ type: "directed", multi: false, allowSelfLoops: false });
|
||||
expect(data.attributes).toEqual({});
|
||||
expect(data.nodes).toEqual([]);
|
||||
expect(data.edges).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns graphology native JSON format for operation graph", () => {
|
||||
const specs: OperationSpec[] = [
|
||||
{ name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) },
|
||||
{ name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String() }) },
|
||||
];
|
||||
const graph = FlowGraph.fromSpecs(specs);
|
||||
const data = graph.export();
|
||||
expect(data.options.type).toBe("directed");
|
||||
expect(data.nodes.length).toBe(2);
|
||||
expect(data.edges.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlowGraph.toJSON", () => {
|
||||
it("is an alias for export", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" } as never);
|
||||
const exported = fg.export();
|
||||
const jsoned = fg.toJSON();
|
||||
expect(jsoned).toEqual(exported);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlowGraph.toString", () => {
|
||||
it("returns JSON.stringify of export()", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" } as never);
|
||||
expect(fg.toString()).toBe(JSON.stringify(fg.export()));
|
||||
});
|
||||
|
||||
it("round-trips through JSON.parse", () => {
|
||||
const fg = new FlowGraph();
|
||||
fg.addNode("a", { name: "a" } as never);
|
||||
const parsed = JSON.parse(fg.toString());
|
||||
expect(parsed.options).toEqual({ type: "directed", multi: false, allowSelfLoops: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlowGraph.fromJSON", () => {
|
||||
it("round-trips fromSpecs -> export -> fromJSON", () => {
|
||||
const specs: OperationSpec[] = [
|
||||
{ name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) },
|
||||
{ name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String() }) },
|
||||
];
|
||||
const original = FlowGraph.fromSpecs(specs);
|
||||
const data = original.export();
|
||||
const restored = FlowGraph.fromJSON(data);
|
||||
expect(restored.order).toBe(original.order);
|
||||
expect(restored.size).toBe(original.size);
|
||||
for (const node of original.nodes()) {
|
||||
expect(restored.hasNode(node)).toBe(true);
|
||||
const origAttrs = original.getNodeAttributes(node as never) as Record<string, unknown>;
|
||||
const restAttrs = restored.getNodeAttributes(node as never) as Record<string, unknown>;
|
||||
expect(restAttrs.name).toBe(origAttrs.name);
|
||||
expect(restAttrs.namespace).toBe(origAttrs.namespace);
|
||||
}
|
||||
for (const edge of original.edges()) {
|
||||
const source = edge.split("->")[0]!;
|
||||
const target = edge.split("->")[1]!;
|
||||
expect(restored.hasEdge(source, target)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("round-trips empty graph", () => {
|
||||
const fg = new FlowGraph();
|
||||
const data = fg.export();
|
||||
const restored = FlowGraph.fromJSON(data);
|
||||
expect(restored.order).toBe(0);
|
||||
expect(restored.size).toBe(0);
|
||||
expect(restored.export()).toEqual(data);
|
||||
});
|
||||
|
||||
it("throws InvalidInputError on invalid input", () => {
|
||||
expect(() => FlowGraph.fromJSON({})).toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
it("InvalidInputError contains errors array", () => {
|
||||
try {
|
||||
FlowGraph.fromJSON({});
|
||||
expect.unreachable("should throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(InvalidInputError);
|
||||
const err = e as InvalidInputError;
|
||||
expect(err.errors.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws InvalidInputError on missing nodes", () => {
|
||||
const bad = {
|
||||
options: { type: "directed", multi: false, allowSelfLoops: false },
|
||||
attributes: {},
|
||||
edges: [],
|
||||
};
|
||||
expect(() => FlowGraph.fromJSON(bad as never)).toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
it("throws CycleError on cyclic input", () => {
|
||||
const cyclicData = {
|
||||
options: { type: "directed", multi: false, allowSelfLoops: false },
|
||||
attributes: {},
|
||||
nodes: [
|
||||
{ key: "a", attributes: { requestId: "a", operationId: "op.a", status: "completed", input: {} } },
|
||||
{ key: "b", attributes: { requestId: "b", operationId: "op.b", status: "completed", input: {} } },
|
||||
],
|
||||
edges: [
|
||||
{ key: "a->b", source: "a", target: "b", attributes: {} },
|
||||
{ key: "b->a", source: "b", target: "a", attributes: {} },
|
||||
],
|
||||
};
|
||||
expect(() => FlowGraph.fromJSON(cyclicData as never)).toThrow(CycleError);
|
||||
});
|
||||
|
||||
it("CycleError contains cycle paths on cyclic input", () => {
|
||||
const cyclicData = {
|
||||
options: { type: "directed", multi: false, allowSelfLoops: false },
|
||||
attributes: {},
|
||||
nodes: [
|
||||
{ key: "a", attributes: { requestId: "a", operationId: "op.a", status: "completed", input: {} } },
|
||||
{ key: "b", attributes: { requestId: "b", operationId: "op.b", status: "completed", input: {} } },
|
||||
],
|
||||
edges: [
|
||||
{ key: "a->b", source: "a", target: "b", attributes: {} },
|
||||
{ key: "b->a", source: "b", target: "a", attributes: {} },
|
||||
],
|
||||
};
|
||||
try {
|
||||
FlowGraph.fromJSON(cyclicData as never);
|
||||
expect.unreachable("should throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(CycleError);
|
||||
const ce = e as CycleError;
|
||||
expect(ce.cycles.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves node attributes through round-trip", () => {
|
||||
const specs: OperationSpec[] = [
|
||||
{ name: "classify", namespace: "task", version: "2.0.0", type: "mutation", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String() }), description: "Classifies text", tags: ["nlp"] },
|
||||
];
|
||||
const original = FlowGraph.fromSpecs(specs);
|
||||
const data = original.export();
|
||||
const restored = FlowGraph.fromJSON(data);
|
||||
const origAttrs = original.getNodeAttributes("task.classify" as never) as Record<string, unknown>;
|
||||
const restAttrs = restored.getNodeAttributes("task.classify" as never) as Record<string, unknown>;
|
||||
expect(restAttrs.name).toBe(origAttrs.name);
|
||||
expect(restAttrs.namespace).toBe(origAttrs.namespace);
|
||||
expect(restAttrs.version).toBe(origAttrs.version);
|
||||
expect(restAttrs.type).toBe(origAttrs.type);
|
||||
expect(restAttrs.description).toBe(origAttrs.description);
|
||||
expect(restAttrs.tags).toEqual(origAttrs.tags);
|
||||
});
|
||||
|
||||
it("preserves edge attributes through round-trip", () => {
|
||||
const specs: OperationSpec[] = [
|
||||
{ name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) },
|
||||
{ name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String() }) },
|
||||
];
|
||||
const original = FlowGraph.fromSpecs(specs);
|
||||
const data = original.export();
|
||||
const restored = FlowGraph.fromJSON(data);
|
||||
const origEdgeAttrs = original.getEdgeAttributes("task.extract", "task.classify") as Record<string, unknown>;
|
||||
const restEdgeAttrs = restored.getEdgeAttributes("task.extract", "task.classify") as Record<string, unknown>;
|
||||
expect(restEdgeAttrs.edgeType).toBe(origEdgeAttrs.edgeType);
|
||||
expect(restEdgeAttrs.compatible).toBe(origEdgeAttrs.compatible);
|
||||
});
|
||||
|
||||
it("double round-trip is lossless", () => {
|
||||
const specs: OperationSpec[] = [
|
||||
{ name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) },
|
||||
{ name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String() }) },
|
||||
];
|
||||
const original = FlowGraph.fromSpecs(specs);
|
||||
const first = original.export();
|
||||
const restored1 = FlowGraph.fromJSON(first);
|
||||
const second = restored1.export();
|
||||
const restored2 = FlowGraph.fromJSON(second);
|
||||
const third = restored2.export();
|
||||
expect(third).toEqual(first);
|
||||
expect(third).toEqual(second);
|
||||
});
|
||||
|
||||
it("accepts valid call graph serialized data", () => {
|
||||
const callData = {
|
||||
options: { type: "directed", multi: false, allowSelfLoops: false },
|
||||
attributes: {},
|
||||
nodes: [
|
||||
{
|
||||
key: "req_1",
|
||||
attributes: {
|
||||
requestId: "req_1",
|
||||
operationId: "task.classify",
|
||||
status: "completed",
|
||||
input: { text: "hello" },
|
||||
output: { label: "greeting" },
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
const fg = FlowGraph.fromJSON(callData as never);
|
||||
expect(fg.order).toBe(1);
|
||||
expect(fg.hasNode("req_1")).toBe(true);
|
||||
});
|
||||
|
||||
it("throws InvalidInputError for invalid node attributes", () => {
|
||||
const bad = {
|
||||
options: { type: "directed", multi: false, allowSelfLoops: false },
|
||||
attributes: {},
|
||||
nodes: [
|
||||
{ key: "a", attributes: { invalid: true } },
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
expect(() => FlowGraph.fromJSON(bad as never)).toThrow(InvalidInputError);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,507 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { h, createHostRoot } from "@alkdev/ujsx";
|
||||
import { Operation, Sequential, Parallel, Conditional, Map } from "../../src/component/index.js";
|
||||
import { ReactiveHostConfig } from "../../src/host/reactive.js";
|
||||
import type { WorkflowNode, ReactiveContext } from "../../src/host/reactive.js";
|
||||
|
||||
describe('reactive host', () => {
|
||||
it('placeholder', () => {
|
||||
expect(true).toBe(true);
|
||||
function renderTemplate(template: ReturnType<typeof h>): ReactiveContext {
|
||||
const root = createHostRoot(ReactiveHostConfig, null);
|
||||
root.render(template);
|
||||
return root.ctx;
|
||||
}
|
||||
|
||||
describe("ReactiveHostConfig", () => {
|
||||
describe("createRootContext", () => {
|
||||
it("creates fresh ReactiveContext with empty maps", () => {
|
||||
const ctx = ReactiveHostConfig.createRootContext(null);
|
||||
expect(ctx.nodes.size).toBe(0);
|
||||
expect(ctx.statusSignals.size).toBe(0);
|
||||
expect(ctx.parentMap.size).toBe(0);
|
||||
expect(ctx.siblingMap.size).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts options with registry", () => {
|
||||
const registry = { resolve: (name: string) => name };
|
||||
const ctx = ReactiveHostConfig.createRootContext(null, { registry });
|
||||
expect(ctx.operationRegistry).toBe(registry);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createInstance for operation", () => {
|
||||
it("creates WorkflowNode with correct key and type", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "classify" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
const node = ctx.nodes.get("classify");
|
||||
expect(node).toBeDefined();
|
||||
expect(node!.key).toBe("classify");
|
||||
expect(node!.type).toBe("operation");
|
||||
expect(node!.operationId).toBe("classify");
|
||||
});
|
||||
|
||||
it("creates WorkflowNode with idle status signal", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "classify" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
const node = ctx.nodes.get("classify")!;
|
||||
expect(node.status.value).toBe("idle");
|
||||
});
|
||||
|
||||
it("registers node in context maps", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "classify" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
expect(ctx.statusSignals.has("classify")).toBe(true);
|
||||
expect(ctx.preconditions.has("classify")).toBe(true);
|
||||
expect(ctx.blockedByFailure.has("classify")).toBe(true);
|
||||
});
|
||||
|
||||
it("creates output signal for operation nodes", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "classify" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
const node = ctx.nodes.get("classify")!;
|
||||
expect(node.output).toBeDefined();
|
||||
expect(node.output!.value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createInstance for structural containers", () => {
|
||||
it("creates WorkflowNode tracking children for Sequential", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
const seqNode = ctx.nodes.get("__sequential_0");
|
||||
expect(seqNode).toBeDefined();
|
||||
expect(seqNode!.type).toBe("sequential");
|
||||
expect(seqNode!.children.length).toBe(2);
|
||||
});
|
||||
|
||||
it("creates WorkflowNode for Parallel with children", () => {
|
||||
const template = h(Parallel, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
const parNode = ctx.nodes.get("__parallel_0");
|
||||
expect(parNode).toBeDefined();
|
||||
expect(parNode!.type).toBe("parallel");
|
||||
expect(parNode!.children.length).toBe(2);
|
||||
});
|
||||
|
||||
it("creates WorkflowNode for Conditional with children", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Conditional, { test: "A" },
|
||||
h(Operation, { name: "B" }),
|
||||
),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
const condNode = ctx.nodes.get("__conditional_1");
|
||||
expect(condNode).toBeDefined();
|
||||
expect(condNode!.type).toBe("conditional");
|
||||
});
|
||||
|
||||
it("structural containers do not have operationId", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
const seqNode = ctx.nodes.get("__sequential_0")!;
|
||||
expect(seqNode.operationId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("signal initial states", () => {
|
||||
it("root operation has preconditions met (no predecessors)", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
const nodeA = ctx.nodes.get("A")!;
|
||||
expect(nodeA.preconditions.value).toBe(true);
|
||||
});
|
||||
|
||||
it("second operation in sequential has unmet preconditions", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
const nodeB = ctx.nodes.get("B")!;
|
||||
expect(nodeB.preconditions.value).toBe(false);
|
||||
});
|
||||
|
||||
it("parallel children have preconditions met (no inter-child deps)", () => {
|
||||
const template = h(Parallel, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
expect(ctx.nodes.get("A")!.preconditions.value).toBe(true);
|
||||
expect(ctx.nodes.get("B")!.preconditions.value).toBe(true);
|
||||
});
|
||||
|
||||
it("all initial blockedByFailure are false", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
expect(ctx.nodes.get("A")!.blockedByFailure.value).toBe(false);
|
||||
expect(ctx.nodes.get("B")!.blockedByFailure.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("precondition transition on predecessor completion", () => {
|
||||
it("sequential: completing predecessor updates dependent preconditions", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
const nodeA = ctx.nodes.get("A")!;
|
||||
const nodeB = ctx.nodes.get("B")!;
|
||||
|
||||
expect(nodeB.preconditions.value).toBe(false);
|
||||
|
||||
nodeA.status.value = "completed";
|
||||
|
||||
expect(nodeB.preconditions.value).toBe(true);
|
||||
});
|
||||
|
||||
it("sequential: skipped predecessor satisfies preconditions", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
const nodeA = ctx.nodes.get("A")!;
|
||||
|
||||
nodeA.status.value = "skipped";
|
||||
|
||||
expect(ctx.nodes.get("B")!.preconditions.value).toBe(true);
|
||||
});
|
||||
|
||||
it("sequential chain: A→B→C, completing A then B", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
h(Operation, { name: "C" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
expect(ctx.nodes.get("B")!.preconditions.value).toBe(false);
|
||||
expect(ctx.nodes.get("C")!.preconditions.value).toBe(false);
|
||||
|
||||
ctx.nodes.get("A")!.status.value = "completed";
|
||||
expect(ctx.nodes.get("B")!.preconditions.value).toBe(true);
|
||||
expect(ctx.nodes.get("C")!.preconditions.value).toBe(false);
|
||||
|
||||
ctx.nodes.get("B")!.status.value = "completed";
|
||||
expect(ctx.nodes.get("C")!.preconditions.value).toBe(true);
|
||||
});
|
||||
|
||||
it("parallel children preconditions independent of each other", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "pre" }),
|
||||
h(Parallel, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
expect(ctx.nodes.get("A")!.preconditions.value).toBe(false);
|
||||
expect(ctx.nodes.get("B")!.preconditions.value).toBe(false);
|
||||
|
||||
ctx.nodes.get("pre")!.status.value = "completed";
|
||||
|
||||
expect(ctx.nodes.get("A")!.preconditions.value).toBe(true);
|
||||
expect(ctx.nodes.get("B")!.preconditions.value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("failure propagation", () => {
|
||||
it("sequential: failed predecessor causes blockedByFailure to be true", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
ctx.nodes.get("A")!.status.value = "failed";
|
||||
|
||||
expect(ctx.nodes.get("B")!.blockedByFailure.value).toBe(true);
|
||||
});
|
||||
|
||||
it("sequential: aborted predecessor causes blockedByFailure to be true", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
ctx.nodes.get("A")!.status.value = "aborted";
|
||||
|
||||
expect(ctx.nodes.get("B")!.blockedByFailure.value).toBe(true);
|
||||
});
|
||||
|
||||
it("parallel siblings are independent for failure propagation", () => {
|
||||
const template = h(Parallel, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
ctx.nodes.get("A")!.status.value = "failed";
|
||||
|
||||
expect(ctx.nodes.get("B")!.blockedByFailure.value).toBe(false);
|
||||
});
|
||||
|
||||
it("failed predecessor does not satisfy preconditions", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
ctx.nodes.get("A")!.status.value = "failed";
|
||||
|
||||
expect(ctx.nodes.get("B")!.preconditions.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendChild", () => {
|
||||
it("appends children to parent's children array", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
const seqNode = ctx.nodes.get("__sequential_0")!;
|
||||
expect(seqNode.children.length).toBe(2);
|
||||
expect(seqNode.children[0]!.key).toBe("A");
|
||||
expect(seqNode.children[1]!.key).toBe("B");
|
||||
});
|
||||
|
||||
it("does not duplicate children", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
const seqNode = ctx.nodes.get("__sequential_0")!;
|
||||
const childNode = ctx.nodes.get("A")!;
|
||||
ReactiveHostConfig.appendChild(seqNode, childNode, ctx);
|
||||
expect(seqNode.children.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeChild", () => {
|
||||
it("removes child from parent's children array", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
const seqNode = ctx.nodes.get("__sequential_0")!;
|
||||
const childB = ctx.nodes.get("B")!;
|
||||
|
||||
ReactiveHostConfig.removeChild(seqNode, childB, ctx);
|
||||
expect(seqNode.children.length).toBe(1);
|
||||
expect(seqNode.children[0]!.key).toBe("A");
|
||||
});
|
||||
|
||||
it("preconditions auto-reevaluate after removeChild", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
h(Operation, { name: "C" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
const seqNode = ctx.nodes.get("__sequential_0")!;
|
||||
const childB = ctx.nodes.get("B")!;
|
||||
|
||||
ctx.nodes.get("A")!.status.value = "completed";
|
||||
|
||||
expect(childB.preconditions.value).toBe(true);
|
||||
|
||||
ReactiveHostConfig.removeChild(seqNode, childB, ctx);
|
||||
|
||||
const childC = ctx.nodes.get("C")!;
|
||||
expect(childC.preconditions.value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parentMap and siblingMap", () => {
|
||||
it("registers parent-child relationships in parentMap", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
expect(ctx.parentMap.get("A")).toBe("__sequential_0");
|
||||
expect(ctx.parentMap.get("B")).toBe("__sequential_0");
|
||||
});
|
||||
|
||||
it("registers siblings in siblingMap", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
const siblings = ctx.siblingMap.get("__sequential_0");
|
||||
expect(siblings).toEqual(["A", "B"]);
|
||||
});
|
||||
|
||||
it("nested structures register correct parent", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "pre" }),
|
||||
h(Parallel, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
expect(ctx.parentMap.get("pre")).toBe("__sequential_0");
|
||||
expect(ctx.parentMap.get("A")).toBe("__parallel_1");
|
||||
expect(ctx.parentMap.get("B")).toBe("__parallel_1");
|
||||
|
||||
const seqSiblings = ctx.siblingMap.get("__sequential_0");
|
||||
expect(seqSiblings).toEqual(["pre", "__parallel_1"]);
|
||||
|
||||
const parSiblings = ctx.siblingMap.get("__parallel_1");
|
||||
expect(parSiblings).toEqual(["A", "B"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Conditional as error boundary", () => {
|
||||
it("conditional node is created with correct type", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Conditional, { test: "A" },
|
||||
h(Operation, { name: "B" }),
|
||||
),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
const condNode = ctx.nodes.get("__conditional_1");
|
||||
expect(condNode).toBeDefined();
|
||||
expect(condNode!.type).toBe("conditional");
|
||||
});
|
||||
|
||||
it("conditional child has preconditions met (no inter-child ordering)", () => {
|
||||
const template = h(Conditional, { test: "A" },
|
||||
h(Operation, { name: "B" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
expect(ctx.nodes.get("B")!.preconditions.value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("full template rendering", () => {
|
||||
it("renders a complete pipeline template", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "architect" }),
|
||||
h(Operation, { name: "reviewer" }),
|
||||
h(Parallel, {},
|
||||
h(Operation, { name: "decomposer" }),
|
||||
h(Operation, { name: "specialist" }),
|
||||
),
|
||||
h(Operation, { name: "synthesizer" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
expect(ctx.nodes.get("architect")).toBeDefined();
|
||||
expect(ctx.nodes.get("reviewer")).toBeDefined();
|
||||
expect(ctx.nodes.get("decomposer")).toBeDefined();
|
||||
expect(ctx.nodes.get("specialist")).toBeDefined();
|
||||
expect(ctx.nodes.get("synthesizer")).toBeDefined();
|
||||
|
||||
expect(ctx.nodes.get("architect")!.preconditions.value).toBe(true);
|
||||
expect(ctx.nodes.get("reviewer")!.preconditions.value).toBe(false);
|
||||
|
||||
ctx.nodes.get("architect")!.status.value = "completed";
|
||||
expect(ctx.nodes.get("reviewer")!.preconditions.value).toBe(true);
|
||||
});
|
||||
|
||||
it("deeply nested template registers all nodes", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Parallel, {},
|
||||
h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
h(Operation, { name: "B" }),
|
||||
),
|
||||
h(Operation, { name: "C" }),
|
||||
),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
|
||||
expect(ctx.nodes.has("A")).toBe(true);
|
||||
expect(ctx.nodes.has("B")).toBe(true);
|
||||
expect(ctx.nodes.has("C")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared signal references", () => {
|
||||
it("WorkflowNode.status and statusSignals point to same signal", () => {
|
||||
const template = h(Sequential, {},
|
||||
h(Operation, { name: "A" }),
|
||||
);
|
||||
|
||||
const ctx = renderTemplate(template);
|
||||
const node = ctx.nodes.get("A")!;
|
||||
const statusFromMap = ctx.statusSignals.get("A")!;
|
||||
|
||||
expect(node.status).toBe(statusFromMap);
|
||||
|
||||
statusFromMap.value = "running";
|
||||
expect(node.status.value).toBe("running");
|
||||
|
||||
node.status.value = "completed";
|
||||
expect(statusFromMap.value).toBe("completed");
|
||||
});
|
||||
});
|
||||
});
|
||||
482
test/reactive/max-concurrency.test.ts
Normal file
482
test/reactive/max-concurrency.test.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { DirectedGraph } from "graphology";
|
||||
import { WorkflowReactiveRoot } from "../../src/reactive/workflow.js";
|
||||
import type { ParallelGroupConfig } from "../../src/reactive/workflow.js";
|
||||
|
||||
function makeParallelGroupGraph(): DirectedGraph {
|
||||
const graph = new DirectedGraph();
|
||||
graph.addNode("entry", { name: "entry" });
|
||||
graph.addNode("a", { name: "a" });
|
||||
graph.addNode("b", { name: "b" });
|
||||
graph.addNode("c", { name: "c" });
|
||||
graph.addNode("d", { name: "d" });
|
||||
graph.addEdgeWithKey("entry->a", "entry", "a", { edgeType: "sequential" });
|
||||
graph.addEdgeWithKey("entry->b", "entry", "b", { edgeType: "sequential" });
|
||||
graph.addEdgeWithKey("entry->c", "entry", "c", { edgeType: "sequential" });
|
||||
graph.addEdgeWithKey("entry->d", "entry", "d", { edgeType: "sequential" });
|
||||
return graph;
|
||||
}
|
||||
|
||||
function makeParallelGroupNoEntry(): DirectedGraph {
|
||||
const graph = new DirectedGraph();
|
||||
graph.addNode("a", { name: "a" });
|
||||
graph.addNode("b", { name: "b" });
|
||||
graph.addNode("c", { name: "c" });
|
||||
return graph;
|
||||
}
|
||||
|
||||
describe("maxConcurrency semaphore", () => {
|
||||
describe("parallel group with maxConcurrency: 2", () => {
|
||||
const parallelGroups: ParallelGroupConfig = {
|
||||
group1: {
|
||||
siblings: ["a", "b", "c", "d"],
|
||||
maxConcurrency: 2,
|
||||
},
|
||||
};
|
||||
|
||||
it("limits running siblings to maxConcurrency", () => {
|
||||
const graph = makeParallelGroupGraph();
|
||||
const root = new WorkflowReactiveRoot(graph, { parallelGroups });
|
||||
|
||||
expect(root.canStart.has("a")).toBe(true);
|
||||
expect(root.canStart.has("b")).toBe(true);
|
||||
expect(root.canStart.has("c")).toBe(true);
|
||||
expect(root.canStart.has("d")).toBe(true);
|
||||
|
||||
expect(root.statusMap.get("entry")!.value).toBe("ready");
|
||||
root.statusMap.get("entry")!.value = "completed";
|
||||
|
||||
expect(root.preconditions.get("a")!.value).toBe(true);
|
||||
expect(root.preconditions.get("b")!.value).toBe(true);
|
||||
expect(root.preconditions.get("c")!.value).toBe(true);
|
||||
expect(root.preconditions.get("d")!.value).toBe(true);
|
||||
|
||||
expect(root.canStart.get("a")!.value).toBe(true);
|
||||
expect(root.canStart.get("b")!.value).toBe(true);
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
|
||||
it("canStart becomes false when maxConcurrency siblings are running", () => {
|
||||
const graph = makeParallelGroupGraph();
|
||||
const root = new WorkflowReactiveRoot(graph, { parallelGroups });
|
||||
|
||||
root.statusMap.get("entry")!.value = "completed";
|
||||
|
||||
root.statusMap.get("a")!.value = "running";
|
||||
root.statusMap.get("b")!.value = "running";
|
||||
|
||||
expect(root.canStart.get("a")!.value).toBe(true);
|
||||
expect(root.canStart.get("b")!.value).toBe(true);
|
||||
expect(root.canStart.get("c")!.value).toBe(false);
|
||||
expect(root.canStart.get("d")!.value).toBe(false);
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
|
||||
it("blocked nodes by semaphore stay idle (not ready)", () => {
|
||||
const graph = makeParallelGroupGraph();
|
||||
const root = new WorkflowReactiveRoot(graph, { parallelGroups });
|
||||
|
||||
root.statusMap.get("entry")!.value = "completed";
|
||||
|
||||
root.statusMap.get("a")!.value = "running";
|
||||
root.statusMap.get("b")!.value = "running";
|
||||
|
||||
expect(root.statusMap.get("c")!.value).toBe("idle");
|
||||
expect(root.statusMap.get("d")!.value).toBe("idle");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
|
||||
it("slot opens when a running sibling completes", () => {
|
||||
const graph = makeParallelGroupGraph();
|
||||
const root = new WorkflowReactiveRoot(graph, { parallelGroups });
|
||||
|
||||
root.statusMap.get("entry")!.value = "completed";
|
||||
root.statusMap.get("a")!.value = "running";
|
||||
root.statusMap.get("b")!.value = "running";
|
||||
|
||||
expect(root.canStart.get("c")!.value).toBe(false);
|
||||
|
||||
root.statusMap.get("a")!.value = "completed";
|
||||
|
||||
expect(root.canStart.get("c")!.value).toBe(true);
|
||||
expect(root.canStart.get("d")!.value).toBe(false);
|
||||
|
||||
expect(root.statusMap.get("c")!.value).toBe("ready");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
|
||||
it("all slots eventually open as siblings complete", () => {
|
||||
const graph = makeParallelGroupGraph();
|
||||
const root = new WorkflowReactiveRoot(graph, { parallelGroups });
|
||||
|
||||
root.statusMap.get("entry")!.value = "completed";
|
||||
root.statusMap.get("a")!.value = "running";
|
||||
root.statusMap.get("b")!.value = "running";
|
||||
|
||||
root.statusMap.get("a")!.value = "completed";
|
||||
expect(root.canStart.get("c")!.value).toBe(true);
|
||||
|
||||
root.statusMap.get("b")!.value = "completed";
|
||||
expect(root.canStart.get("d")!.value).toBe(true);
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
|
||||
it("canStart correctly limits when sibling transitions to ready", () => {
|
||||
const graph = makeParallelGroupGraph();
|
||||
const root = new WorkflowReactiveRoot(graph, { parallelGroups });
|
||||
|
||||
root.statusMap.get("entry")!.value = "completed";
|
||||
|
||||
root.statusMap.get("a")!.value = "ready";
|
||||
root.statusMap.get("b")!.value = "ready";
|
||||
|
||||
expect(root.canStart.get("c")!.value).toBe(false);
|
||||
expect(root.canStart.get("d")!.value).toBe(false);
|
||||
|
||||
root.statusMap.get("a")!.value = "running";
|
||||
root.statusMap.get("b")!.value = "running";
|
||||
|
||||
expect(root.canStart.get("c")!.value).toBe(false);
|
||||
expect(root.canStart.get("d")!.value).toBe(false);
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parallel group without maxConcurrency", () => {
|
||||
const parallelGroups: ParallelGroupConfig = {
|
||||
group1: {
|
||||
siblings: ["a", "b", "c", "d"],
|
||||
},
|
||||
};
|
||||
|
||||
it("all siblings start immediately when preconditions are met", () => {
|
||||
const graph = makeParallelGroupGraph();
|
||||
const root = new WorkflowReactiveRoot(graph, { parallelGroups });
|
||||
|
||||
root.statusMap.get("entry")!.value = "completed";
|
||||
|
||||
expect(root.canStart.get("a")!.value).toBe(true);
|
||||
expect(root.canStart.get("b")!.value).toBe(true);
|
||||
expect(root.canStart.get("c")!.value).toBe(true);
|
||||
expect(root.canStart.get("d")!.value).toBe(true);
|
||||
|
||||
expect(root.statusMap.get("a")!.value).toBe("ready");
|
||||
expect(root.statusMap.get("b")!.value).toBe("ready");
|
||||
expect(root.statusMap.get("c")!.value).toBe("ready");
|
||||
expect(root.statusMap.get("d")!.value).toBe("ready");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
|
||||
it("no semaphore — running siblings do not block others", () => {
|
||||
const graph = makeParallelGroupGraph();
|
||||
const root = new WorkflowReactiveRoot(graph, { parallelGroups });
|
||||
|
||||
root.statusMap.get("entry")!.value = "completed";
|
||||
root.statusMap.get("a")!.value = "running";
|
||||
root.statusMap.get("b")!.value = "running";
|
||||
|
||||
expect(root.canStart.get("c")!.value).toBe(true);
|
||||
expect(root.canStart.get("d")!.value).toBe(true);
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("no parallelGroups config", () => {
|
||||
it("all nodes start normally without semaphore", () => {
|
||||
const graph = makeParallelGroupGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.statusMap.get("entry")!.value = "completed";
|
||||
|
||||
expect(root.canStart.get("a")!.value).toBe(true);
|
||||
expect(root.canStart.get("b")!.value).toBe(true);
|
||||
expect(root.canStart.get("c")!.value).toBe(true);
|
||||
expect(root.canStart.get("d")!.value).toBe(true);
|
||||
|
||||
expect(root.statusMap.get("a")!.value).toBe("ready");
|
||||
expect(root.statusMap.get("b")!.value).toBe("ready");
|
||||
expect(root.statusMap.get("c")!.value).toBe("ready");
|
||||
expect(root.statusMap.get("d")!.value).toBe("ready");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("maxConcurrency: 1 (serial within parallel)", () => {
|
||||
const parallelGroups: ParallelGroupConfig = {
|
||||
group1: {
|
||||
siblings: ["a", "b", "c"],
|
||||
maxConcurrency: 1,
|
||||
},
|
||||
};
|
||||
|
||||
it("only one sibling can run at a time", () => {
|
||||
const graph = makeParallelGroupGraph();
|
||||
const root = new WorkflowReactiveRoot(graph, { parallelGroups });
|
||||
|
||||
root.statusMap.get("entry")!.value = "completed";
|
||||
|
||||
const canStartA = root.canStart.get("a")!;
|
||||
const canStartB = root.canStart.get("b")!;
|
||||
const canStartC = root.canStart.get("c")!;
|
||||
|
||||
expect(canStartA.value).toBe(true);
|
||||
expect(canStartB.value).toBe(false);
|
||||
expect(canStartC.value).toBe(false);
|
||||
|
||||
root.statusMap.get("a")!.value = "running";
|
||||
|
||||
expect(canStartA.value).toBe(true);
|
||||
expect(canStartB.value).toBe(false);
|
||||
expect(canStartC.value).toBe(false);
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
|
||||
it("next sibling can start when current finishes", () => {
|
||||
const graph = makeParallelGroupGraph();
|
||||
const root = new WorkflowReactiveRoot(graph, { parallelGroups });
|
||||
|
||||
root.statusMap.get("entry")!.value = "completed";
|
||||
|
||||
root.statusMap.get("a")!.value = "running";
|
||||
root.statusMap.get("a")!.value = "completed";
|
||||
|
||||
expect(root.canStart.get("b")!.value).toBe(true);
|
||||
expect(root.canStart.get("c")!.value).toBe(false);
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("root-level parallel group with maxConcurrency", () => {
|
||||
it("limits concurrent start for root nodes with no predecessors", () => {
|
||||
const graph = makeParallelGroupNoEntry();
|
||||
const parallelGroups: ParallelGroupConfig = {
|
||||
root: {
|
||||
siblings: ["a", "b", "c"],
|
||||
maxConcurrency: 2,
|
||||
},
|
||||
};
|
||||
const root = new WorkflowReactiveRoot(graph, { parallelGroups });
|
||||
|
||||
expect(root.canStart.get("a")!.value).toBe(true);
|
||||
expect(root.canStart.get("b")!.value).toBe(true);
|
||||
expect(root.canStart.get("c")!.value).toBe(false);
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
|
||||
it("third node becomes ready when a slot opens", () => {
|
||||
const graph = makeParallelGroupNoEntry();
|
||||
const parallelGroups: ParallelGroupConfig = {
|
||||
root: {
|
||||
siblings: ["a", "b", "c"],
|
||||
maxConcurrency: 2,
|
||||
},
|
||||
};
|
||||
const root = new WorkflowReactiveRoot(graph, { parallelGroups });
|
||||
|
||||
root.statusMap.get("a")!.value = "running";
|
||||
root.statusMap.get("b")!.value = "running";
|
||||
|
||||
expect(root.canStart.get("c")!.value).toBe(false);
|
||||
expect(root.statusMap.get("c")!.value).toBe("idle");
|
||||
|
||||
root.statusMap.get("a")!.value = "completed";
|
||||
|
||||
expect(root.canStart.get("c")!.value).toBe(true);
|
||||
expect(root.statusMap.get("c")!.value).toBe("ready");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("preconditions vs canStart", () => {
|
||||
it("canStart is false when preconditions are not met even if semaphore has slots", () => {
|
||||
const graph = makeParallelGroupGraph();
|
||||
const parallelGroups: ParallelGroupConfig = {
|
||||
group1: {
|
||||
siblings: ["a", "b", "c", "d"],
|
||||
maxConcurrency: 2,
|
||||
},
|
||||
};
|
||||
const root = new WorkflowReactiveRoot(graph, { parallelGroups });
|
||||
|
||||
expect(root.preconditions.get("a")!.value).toBe(false);
|
||||
expect(root.canStart.get("a")!.value).toBe(false);
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
|
||||
it("canStart requires both preconditions AND semaphore slot", () => {
|
||||
const graph = makeParallelGroupGraph();
|
||||
const parallelGroups: ParallelGroupConfig = {
|
||||
group1: {
|
||||
siblings: ["a", "b", "c", "d"],
|
||||
maxConcurrency: 2,
|
||||
},
|
||||
};
|
||||
const root = new WorkflowReactiveRoot(graph, { parallelGroups });
|
||||
|
||||
root.statusMap.get("entry")!.value = "completed";
|
||||
|
||||
expect(root.preconditions.get("a")!.value).toBe(true);
|
||||
expect(root.canStart.get("a")!.value).toBe(true);
|
||||
|
||||
root.statusMap.get("a")!.value = "running";
|
||||
root.statusMap.get("b")!.value = "running";
|
||||
|
||||
expect(root.preconditions.get("c")!.value).toBe(true);
|
||||
expect(root.canStart.get("c")!.value).toBe(false);
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("failed sibling does not count as running", () => {
|
||||
it("failed sibling frees a slot for semaphore", () => {
|
||||
const graph = makeParallelGroupGraph();
|
||||
const parallelGroups: ParallelGroupConfig = {
|
||||
group1: {
|
||||
siblings: ["a", "b", "c", "d"],
|
||||
maxConcurrency: 2,
|
||||
},
|
||||
};
|
||||
const root = new WorkflowReactiveRoot(graph, { parallelGroups });
|
||||
|
||||
root.statusMap.get("entry")!.value = "completed";
|
||||
root.statusMap.get("a")!.value = "running";
|
||||
root.statusMap.get("b")!.value = "running";
|
||||
|
||||
expect(root.canStart.get("c")!.value).toBe(false);
|
||||
|
||||
root.statusMap.get("b")!.value = "failed";
|
||||
|
||||
expect(root.canStart.get("c")!.value).toBe(true);
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
|
||||
it("completed sibling frees a slot for semaphore", () => {
|
||||
const graph = makeParallelGroupGraph();
|
||||
const parallelGroups: ParallelGroupConfig = {
|
||||
group1: {
|
||||
siblings: ["a", "b", "c", "d"],
|
||||
maxConcurrency: 2,
|
||||
},
|
||||
};
|
||||
const root = new WorkflowReactiveRoot(graph, { parallelGroups });
|
||||
|
||||
root.statusMap.get("entry")!.value = "completed";
|
||||
root.statusMap.get("a")!.value = "running";
|
||||
root.statusMap.get("b")!.value = "running";
|
||||
|
||||
root.statusMap.get("b")!.value = "completed";
|
||||
|
||||
expect(root.canStart.get("c")!.value).toBe(true);
|
||||
expect(root.canStart.get("d")!.value).toBe(false);
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
|
||||
it("aborted sibling frees a slot for semaphore", () => {
|
||||
const graph = makeParallelGroupGraph();
|
||||
const parallelGroups: ParallelGroupConfig = {
|
||||
group1: {
|
||||
siblings: ["a", "b", "c", "d"],
|
||||
maxConcurrency: 2,
|
||||
},
|
||||
};
|
||||
const root = new WorkflowReactiveRoot(graph, { parallelGroups });
|
||||
|
||||
root.statusMap.get("entry")!.value = "completed";
|
||||
root.statusMap.get("a")!.value = "running";
|
||||
root.statusMap.get("b")!.value = "running";
|
||||
|
||||
root.statusMap.get("b")!.value = "aborted";
|
||||
|
||||
expect(root.canStart.get("c")!.value).toBe(true);
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispose clears canStart", () => {
|
||||
it("canStart map is cleared on dispose", () => {
|
||||
const graph = makeParallelGroupGraph();
|
||||
const parallelGroups: ParallelGroupConfig = {
|
||||
group1: {
|
||||
siblings: ["a", "b", "c", "d"],
|
||||
maxConcurrency: 2,
|
||||
},
|
||||
};
|
||||
const root = new WorkflowReactiveRoot(graph, { parallelGroups });
|
||||
|
||||
expect(root.canStart.size).toBe(5);
|
||||
|
||||
root.dispose();
|
||||
|
||||
expect(root.canStart.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("full execution flow with maxConcurrency", () => {
|
||||
it("simulates parallel execution with maxConcurrency: 2 — only 2 run at a time", () => {
|
||||
const graph = makeParallelGroupGraph();
|
||||
const parallelGroups: ParallelGroupConfig = {
|
||||
group1: {
|
||||
siblings: ["a", "b", "c", "d"],
|
||||
maxConcurrency: 2,
|
||||
},
|
||||
};
|
||||
const root = new WorkflowReactiveRoot(graph, { parallelGroups });
|
||||
|
||||
root.setRequestId("entry", "req-entry");
|
||||
root.setRequestId("a", "req-a");
|
||||
root.setRequestId("b", "req-b");
|
||||
root.setRequestId("c", "req-c");
|
||||
root.setRequestId("d", "req-d");
|
||||
|
||||
root.statusMap.get("entry")!.value = "completed";
|
||||
|
||||
expect(root.statusMap.get("a")!.value).toBe("ready");
|
||||
expect(root.statusMap.get("b")!.value).toBe("ready");
|
||||
expect(root.statusMap.get("c")!.value).toBe("idle");
|
||||
expect(root.statusMap.get("d")!.value).toBe("idle");
|
||||
|
||||
root.statusMap.get("a")!.value = "running";
|
||||
root.statusMap.get("b")!.value = "running";
|
||||
|
||||
expect(root.statusMap.get("c")!.value).toBe("idle");
|
||||
expect(root.statusMap.get("d")!.value).toBe("idle");
|
||||
|
||||
root.statusMap.get("a")!.value = "completed";
|
||||
|
||||
expect(root.statusMap.get("c")!.value).toBe("ready");
|
||||
expect(root.statusMap.get("d")!.value).toBe("idle");
|
||||
|
||||
root.statusMap.get("c")!.value = "running";
|
||||
|
||||
root.statusMap.get("b")!.value = "completed";
|
||||
|
||||
expect(root.statusMap.get("d")!.value).toBe("ready");
|
||||
|
||||
root.statusMap.get("d")!.value = "running";
|
||||
root.statusMap.get("c")!.value = "completed";
|
||||
root.statusMap.get("d")!.value = "completed";
|
||||
|
||||
expect(root.isComplete()).toBe(true);
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
663
test/reactive/node-status.test.ts
Normal file
663
test/reactive/node-status.test.ts
Normal file
@@ -0,0 +1,663 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { signal, computed } from "@preact/signals-core";
|
||||
import type { Signal, ReadonlySignal } from "@preact/signals-core";
|
||||
import { DirectedGraph } from "graphology";
|
||||
import {
|
||||
computePreconditions,
|
||||
computeBlockedByFailure,
|
||||
registerStartEffect,
|
||||
registerAbortEffect,
|
||||
} from "../../src/reactive/node-status.js";
|
||||
import type { NodeStatusContext } from "../../src/reactive/node-status.js";
|
||||
import { WorkflowReactiveRoot } from "../../src/reactive/workflow.js";
|
||||
import type { NodeStatus } from "../../src/schema/enums.js";
|
||||
|
||||
function makeContext(
|
||||
statusMap: Map<string, Signal<NodeStatus>>,
|
||||
predecessors: string[],
|
||||
): NodeStatusContext {
|
||||
return { statusMap, predecessors };
|
||||
}
|
||||
|
||||
function makeStatusMap(
|
||||
entries: [string, NodeStatus][],
|
||||
): Map<string, Signal<NodeStatus>> {
|
||||
const map = new Map<string, Signal<NodeStatus>>();
|
||||
for (const [key, value] of entries) {
|
||||
map.set(key, signal<NodeStatus>(value));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
describe("computePreconditions", () => {
|
||||
it("returns true for root node with no predecessors", () => {
|
||||
const statusMap = makeStatusMap([]);
|
||||
const ctx = makeContext(statusMap, []);
|
||||
expect(computePreconditions("a", ctx)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when predecessor is idle", () => {
|
||||
const statusMap = makeStatusMap([["a", "idle"]]);
|
||||
const ctx = makeContext(statusMap, ["a"]);
|
||||
expect(computePreconditions("b", ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when all predecessors are completed", () => {
|
||||
const statusMap = makeStatusMap([["a", "completed"]]);
|
||||
const ctx = makeContext(statusMap, ["a"]);
|
||||
expect(computePreconditions("b", ctx)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when all predecessors are skipped", () => {
|
||||
const statusMap = makeStatusMap([["a", "skipped"]]);
|
||||
const ctx = makeContext(statusMap, ["a"]);
|
||||
expect(computePreconditions("b", ctx)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when predecessors are mix of completed and skipped", () => {
|
||||
const statusMap = makeStatusMap([
|
||||
["a", "completed"],
|
||||
["b", "skipped"],
|
||||
]);
|
||||
const ctx = makeContext(statusMap, ["a", "b"]);
|
||||
expect(computePreconditions("c", ctx)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when predecessor is failed", () => {
|
||||
const statusMap = makeStatusMap([["a", "failed"]]);
|
||||
const ctx = makeContext(statusMap, ["a"]);
|
||||
expect(computePreconditions("b", ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when predecessor is aborted", () => {
|
||||
const statusMap = makeStatusMap([["a", "aborted"]]);
|
||||
const ctx = makeContext(statusMap, ["a"]);
|
||||
expect(computePreconditions("b", ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when one of multiple predecessors is failed", () => {
|
||||
const statusMap = makeStatusMap([
|
||||
["a", "completed"],
|
||||
["b", "failed"],
|
||||
]);
|
||||
const ctx = makeContext(statusMap, ["a", "b"]);
|
||||
expect(computePreconditions("c", ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when predecessor is running", () => {
|
||||
const statusMap = makeStatusMap([["a", "running"]]);
|
||||
const ctx = makeContext(statusMap, ["a"]);
|
||||
expect(computePreconditions("b", ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when predecessor is waiting", () => {
|
||||
const statusMap = makeStatusMap([["a", "waiting"]]);
|
||||
const ctx = makeContext(statusMap, ["a"]);
|
||||
expect(computePreconditions("b", ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for missing predecessor in statusMap", () => {
|
||||
const statusMap = makeStatusMap([]);
|
||||
const ctx = makeContext(statusMap, ["unknown"]);
|
||||
expect(computePreconditions("b", ctx)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeBlockedByFailure", () => {
|
||||
it("returns false for root node with no predecessors", () => {
|
||||
const statusMap = makeStatusMap([]);
|
||||
const ctx = makeContext(statusMap, []);
|
||||
expect(computeBlockedByFailure("a", ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when predecessor is failed", () => {
|
||||
const statusMap = makeStatusMap([["a", "failed"]]);
|
||||
const ctx = makeContext(statusMap, ["a"]);
|
||||
expect(computeBlockedByFailure("b", ctx)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when predecessor is aborted", () => {
|
||||
const statusMap = makeStatusMap([["a", "aborted"]]);
|
||||
const ctx = makeContext(statusMap, ["a"]);
|
||||
expect(computeBlockedByFailure("b", ctx)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when predecessor is completed", () => {
|
||||
const statusMap = makeStatusMap([["a", "completed"]]);
|
||||
const ctx = makeContext(statusMap, ["a"]);
|
||||
expect(computeBlockedByFailure("b", ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when predecessor is skipped", () => {
|
||||
const statusMap = makeStatusMap([["a", "skipped"]]);
|
||||
const ctx = makeContext(statusMap, ["a"]);
|
||||
expect(computeBlockedByFailure("b", ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when any of multiple predecessors is failed", () => {
|
||||
const statusMap = makeStatusMap([
|
||||
["a", "completed"],
|
||||
["b", "failed"],
|
||||
]);
|
||||
const ctx = makeContext(statusMap, ["a", "b"]);
|
||||
expect(computeBlockedByFailure("c", ctx)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when all predecessors are completed", () => {
|
||||
const statusMap = makeStatusMap([
|
||||
["a", "completed"],
|
||||
["b", "completed"],
|
||||
]);
|
||||
const ctx = makeContext(statusMap, ["a", "b"]);
|
||||
expect(computeBlockedByFailure("c", ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for missing predecessor in statusMap", () => {
|
||||
const statusMap = makeStatusMap([]);
|
||||
const ctx = makeContext(statusMap, ["unknown"]);
|
||||
expect(computeBlockedByFailure("b", ctx)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerStartEffect", () => {
|
||||
it("transitions idle node to ready when preconditions are true", () => {
|
||||
const status = signal<NodeStatus>("idle");
|
||||
const preconditions = computed(() => true);
|
||||
const disposers: (() => void)[] = [];
|
||||
|
||||
registerStartEffect(status, preconditions, disposers);
|
||||
|
||||
expect(status.value).toBe("ready");
|
||||
|
||||
for (const d of disposers) d();
|
||||
});
|
||||
|
||||
it("transitions waiting node to ready when preconditions are true", () => {
|
||||
const status = signal<NodeStatus>("waiting");
|
||||
const preconditions = computed(() => true);
|
||||
const disposers: (() => void)[] = [];
|
||||
|
||||
registerStartEffect(status, preconditions, disposers);
|
||||
|
||||
expect(status.value).toBe("ready");
|
||||
|
||||
for (const d of disposers) d();
|
||||
});
|
||||
|
||||
it("does not transition running node when preconditions become true", () => {
|
||||
const status = signal<NodeStatus>("running");
|
||||
const preconditions = computed(() => true);
|
||||
const disposers: (() => void)[] = [];
|
||||
|
||||
registerStartEffect(status, preconditions, disposers);
|
||||
|
||||
expect(status.value).toBe("running");
|
||||
|
||||
for (const d of disposers) d();
|
||||
});
|
||||
|
||||
it("does not transition completed node when preconditions become true", () => {
|
||||
const status = signal<NodeStatus>("completed");
|
||||
const preconditions = computed(() => true);
|
||||
const disposers: (() => void)[] = [];
|
||||
|
||||
registerStartEffect(status, preconditions, disposers);
|
||||
|
||||
expect(status.value).toBe("completed");
|
||||
|
||||
for (const d of disposers) d();
|
||||
});
|
||||
|
||||
it("does not transition idle node when preconditions are false", () => {
|
||||
const status = signal<NodeStatus>("idle");
|
||||
const preconditions = computed(() => false);
|
||||
const disposers: (() => void)[] = [];
|
||||
|
||||
registerStartEffect(status, preconditions, disposers);
|
||||
|
||||
expect(status.value).toBe("idle");
|
||||
|
||||
for (const d of disposers) d();
|
||||
});
|
||||
|
||||
it("reactively transitions to ready when preconditions change from false to true", () => {
|
||||
const trigger = signal<NodeStatus>("idle");
|
||||
const preconditions = computed(() => trigger.value === "completed");
|
||||
const status = signal<NodeStatus>("idle");
|
||||
const disposers: (() => void)[] = [];
|
||||
|
||||
registerStartEffect(status, preconditions, disposers);
|
||||
|
||||
expect(status.value).toBe("idle");
|
||||
|
||||
trigger.value = "completed";
|
||||
|
||||
expect(status.value).toBe("ready");
|
||||
|
||||
for (const d of disposers) d();
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerAbortEffect", () => {
|
||||
it("transitions idle node to aborted when blockedByFailure is true", () => {
|
||||
const status = signal<NodeStatus>("idle");
|
||||
const blockedByFailure = computed(() => true);
|
||||
const disposers: (() => void)[] = [];
|
||||
|
||||
registerAbortEffect(status, blockedByFailure, disposers);
|
||||
|
||||
expect(status.value).toBe("aborted");
|
||||
|
||||
for (const d of disposers) d();
|
||||
});
|
||||
|
||||
it("transitions waiting node to aborted when blockedByFailure is true", () => {
|
||||
const status = signal<NodeStatus>("waiting");
|
||||
const blockedByFailure = computed(() => true);
|
||||
const disposers: (() => void)[] = [];
|
||||
|
||||
registerAbortEffect(status, blockedByFailure, disposers);
|
||||
|
||||
expect(status.value).toBe("aborted");
|
||||
|
||||
for (const d of disposers) d();
|
||||
});
|
||||
|
||||
it("does not transition ready node with default policy", () => {
|
||||
const status = signal<NodeStatus>("ready");
|
||||
const blockedByFailure = computed(() => true);
|
||||
const disposers: (() => void)[] = [];
|
||||
|
||||
registerAbortEffect(status, blockedByFailure, disposers);
|
||||
|
||||
expect(status.value).toBe("ready");
|
||||
|
||||
for (const d of disposers) d();
|
||||
});
|
||||
|
||||
it("transitions ready node to aborted with abortDependents option", () => {
|
||||
const status = signal<NodeStatus>("ready");
|
||||
const blockedByFailure = computed(() => true);
|
||||
const disposers: (() => void)[] = [];
|
||||
|
||||
registerAbortEffect(status, blockedByFailure, disposers, {
|
||||
abortDependents: true,
|
||||
});
|
||||
|
||||
expect(status.value).toBe("aborted");
|
||||
|
||||
for (const d of disposers) d();
|
||||
});
|
||||
|
||||
it("transitions running node to aborted with abortDependents option", () => {
|
||||
const status = signal<NodeStatus>("running");
|
||||
const blockedByFailure = computed(() => true);
|
||||
const disposers: (() => void)[] = [];
|
||||
|
||||
registerAbortEffect(status, blockedByFailure, disposers, {
|
||||
abortDependents: true,
|
||||
});
|
||||
|
||||
expect(status.value).toBe("aborted");
|
||||
|
||||
for (const d of disposers) d();
|
||||
});
|
||||
|
||||
it("does not transition completed node even with abortDependents", () => {
|
||||
const status = signal<NodeStatus>("completed");
|
||||
const blockedByFailure = computed(() => true);
|
||||
const disposers: (() => void)[] = [];
|
||||
|
||||
registerAbortEffect(status, blockedByFailure, disposers, {
|
||||
abortDependents: true,
|
||||
});
|
||||
|
||||
expect(status.value).toBe("completed");
|
||||
|
||||
for (const d of disposers) d();
|
||||
});
|
||||
|
||||
it("does not transition failed node even with abortDependents", () => {
|
||||
const status = signal<NodeStatus>("failed");
|
||||
const blockedByFailure = computed(() => true);
|
||||
const disposers: (() => void)[] = [];
|
||||
|
||||
registerAbortEffect(status, blockedByFailure, disposers, {
|
||||
abortDependents: true,
|
||||
});
|
||||
|
||||
expect(status.value).toBe("failed");
|
||||
|
||||
for (const d of disposers) d();
|
||||
});
|
||||
|
||||
it("reactively transitions to aborted when blockedByFailure changes", () => {
|
||||
const trigger = signal<NodeStatus>("idle");
|
||||
const blockedByFailure = computed(
|
||||
() => trigger.value === "failed" || trigger.value === "aborted",
|
||||
);
|
||||
const status = signal<NodeStatus>("idle");
|
||||
const disposers: (() => void)[] = [];
|
||||
|
||||
registerAbortEffect(status, blockedByFailure, disposers);
|
||||
|
||||
expect(status.value).toBe("idle");
|
||||
|
||||
trigger.value = "failed";
|
||||
|
||||
expect(status.value).toBe("aborted");
|
||||
|
||||
for (const d of disposers) d();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sequential preconditions via WorkflowReactiveRoot", () => {
|
||||
function makeSeqGraph(): DirectedGraph {
|
||||
const graph = new DirectedGraph();
|
||||
graph.addNode("a", { name: "a" });
|
||||
graph.addNode("b", { name: "b" });
|
||||
graph.addNode("c", { name: "c" });
|
||||
graph.addEdgeWithKey("a->b", "a", "b", { edgeType: "sequential" });
|
||||
graph.addEdgeWithKey("b->c", "b", "c", { edgeType: "sequential" });
|
||||
return graph;
|
||||
}
|
||||
|
||||
it("root node transitions to ready (no predecessors)", () => {
|
||||
const graph = makeSeqGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
expect(root.statusMap.get("a")!.value).toBe("ready");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
|
||||
it("downstream nodes stay idle until predecessor completes", () => {
|
||||
const graph = makeSeqGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
expect(root.statusMap.get("b")!.value).toBe("idle");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
|
||||
it("downstream node transitions to ready after predecessor completes", () => {
|
||||
const graph = makeSeqGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.statusMap.get("a")!.value = "completed";
|
||||
|
||||
expect(root.statusMap.get("b")!.value).toBe("ready");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
|
||||
it("full sequential chain transitions", () => {
|
||||
const graph = makeSeqGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.statusMap.get("a")!.value = "completed";
|
||||
expect(root.statusMap.get("b")!.value).toBe("ready");
|
||||
|
||||
root.statusMap.get("b")!.value = "completed";
|
||||
expect(root.statusMap.get("c")!.value).toBe("ready");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parallel preconditions via WorkflowReactiveRoot", () => {
|
||||
function makeParallelGraph(): DirectedGraph {
|
||||
const graph = new DirectedGraph();
|
||||
graph.addNode("top", { name: "top" });
|
||||
graph.addNode("left", { name: "left" });
|
||||
graph.addNode("right", { name: "right" });
|
||||
graph.addEdgeWithKey("top->left", "top", "left", {
|
||||
edgeType: "sequential",
|
||||
});
|
||||
graph.addEdgeWithKey("top->right", "top", "right", {
|
||||
edgeType: "sequential",
|
||||
});
|
||||
return graph;
|
||||
}
|
||||
|
||||
it("parallel siblings both become ready when parent completes", () => {
|
||||
const graph = makeParallelGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.statusMap.get("top")!.value = "completed";
|
||||
|
||||
expect(root.statusMap.get("left")!.value).toBe("ready");
|
||||
expect(root.statusMap.get("right")!.value).toBe("ready");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
|
||||
it("parallel siblings are independent - failure on one does not abort the other", () => {
|
||||
const graph = makeParallelGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.statusMap.get("top")!.value = "completed";
|
||||
root.statusMap.get("left")!.value = "ready";
|
||||
root.statusMap.get("right")!.value = "ready";
|
||||
|
||||
root.statusMap.get("left")!.value = "failed";
|
||||
|
||||
expect(root.statusMap.get("left")!.value).toBe("failed");
|
||||
expect(root.statusMap.get("right")!.value).toBe("ready");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("join (fork-join) preconditions via WorkflowReactiveRoot", () => {
|
||||
function makeDiamondGraph(): DirectedGraph {
|
||||
const graph = new DirectedGraph();
|
||||
graph.addNode("top", { name: "top" });
|
||||
graph.addNode("left", { name: "left" });
|
||||
graph.addNode("right", { name: "right" });
|
||||
graph.addNode("bottom", { name: "bottom" });
|
||||
graph.addEdgeWithKey("top->left", "top", "left", {
|
||||
edgeType: "sequential",
|
||||
});
|
||||
graph.addEdgeWithKey("top->right", "top", "right", {
|
||||
edgeType: "sequential",
|
||||
});
|
||||
graph.addEdgeWithKey("left->bottom", "left", "bottom", {
|
||||
edgeType: "sequential",
|
||||
});
|
||||
graph.addEdgeWithKey("right->bottom", "right", "bottom", {
|
||||
edgeType: "sequential",
|
||||
});
|
||||
return graph;
|
||||
}
|
||||
|
||||
it("join node stays idle until all predecessors complete", () => {
|
||||
const graph = makeDiamondGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.statusMap.get("top")!.value = "completed";
|
||||
root.statusMap.get("left")!.value = "completed";
|
||||
|
||||
expect(root.statusMap.get("bottom")!.value).toBe("idle");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
|
||||
it("join node becomes ready when all predecessors are completed", () => {
|
||||
const graph = makeDiamondGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.statusMap.get("top")!.value = "completed";
|
||||
root.statusMap.get("left")!.value = "completed";
|
||||
root.statusMap.get("right")!.value = "completed";
|
||||
|
||||
expect(root.statusMap.get("bottom")!.value).toBe("ready");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
|
||||
it("join node blocked when one predecessor fails", () => {
|
||||
const graph = makeDiamondGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.statusMap.get("top")!.value = "completed";
|
||||
root.statusMap.get("left")!.value = "completed";
|
||||
root.statusMap.get("right")!.value = "failed";
|
||||
|
||||
expect(root.preconditions.get("bottom")!.value).toBe(false);
|
||||
expect(root.statusMap.get("bottom")!.value).toBe("aborted");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("blockedByFailure cascade via WorkflowReactiveRoot", () => {
|
||||
it("failure cascades through multiple downstream nodes", () => {
|
||||
const graph = new DirectedGraph();
|
||||
graph.addNode("a", { name: "a" });
|
||||
graph.addNode("b", { name: "b" });
|
||||
graph.addNode("c", { name: "c" });
|
||||
graph.addEdgeWithKey("a->b", "a", "b", { edgeType: "sequential" });
|
||||
graph.addEdgeWithKey("b->c", "b", "c", { edgeType: "sequential" });
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.statusMap.get("a")!.value = "failed";
|
||||
|
||||
expect(root.statusMap.get("b")!.value).toBe("aborted");
|
||||
expect(root.statusMap.get("c")!.value).toBe("aborted");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
|
||||
it("aborted predecessor causes cascade just like failed", () => {
|
||||
const graph = new DirectedGraph();
|
||||
graph.addNode("a", { name: "a" });
|
||||
graph.addNode("b", { name: "b" });
|
||||
graph.addEdgeWithKey("a->b", "a", "b", { edgeType: "sequential" });
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.statusMap.get("a")!.value = "aborted";
|
||||
|
||||
expect(root.statusMap.get("b")!.value).toBe("aborted");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("skipped satisfies preconditions via WorkflowReactiveRoot", () => {
|
||||
it("skipped predecessor satisfies preconditions for downstream node", () => {
|
||||
const graph = new DirectedGraph();
|
||||
graph.addNode("a", { name: "a" });
|
||||
graph.addNode("b", { name: "b" });
|
||||
graph.addEdgeWithKey("a->b", "a", "b", { edgeType: "sequential" });
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.statusMap.get("a")!.value = "skipped";
|
||||
|
||||
expect(root.preconditions.get("b")!.value).toBe(true);
|
||||
expect(root.statusMap.get("b")!.value).toBe("ready");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("failure isolation in parallel branches via WorkflowReactiveRoot", () => {
|
||||
it("failure in one branch does not abort sibling branch", () => {
|
||||
const graph = new DirectedGraph();
|
||||
graph.addNode("top", { name: "top" });
|
||||
graph.addNode("left", { name: "left" });
|
||||
graph.addNode("right", { name: "right" });
|
||||
graph.addNode("bottom", { name: "bottom" });
|
||||
graph.addEdgeWithKey("top->left", "top", "left", {
|
||||
edgeType: "sequential",
|
||||
});
|
||||
graph.addEdgeWithKey("top->right", "top", "right", {
|
||||
edgeType: "sequential",
|
||||
});
|
||||
graph.addEdgeWithKey("left->bottom", "left", "bottom", {
|
||||
edgeType: "sequential",
|
||||
});
|
||||
graph.addEdgeWithKey("right->bottom", "right", "bottom", {
|
||||
edgeType: "sequential",
|
||||
});
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.statusMap.get("top")!.value = "completed";
|
||||
root.statusMap.get("left")!.value = "running";
|
||||
root.statusMap.get("right")!.value = "failed";
|
||||
|
||||
expect(root.statusMap.get("left")!.value).toBe("running");
|
||||
expect(root.statusMap.get("bottom")!.value).toBe("aborted");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
|
||||
it("completed branch is not affected by failure in other branch", () => {
|
||||
const graph = new DirectedGraph();
|
||||
graph.addNode("top", { name: "top" });
|
||||
graph.addNode("left", { name: "left" });
|
||||
graph.addNode("right", { name: "right" });
|
||||
graph.addNode("bottom", { name: "bottom" });
|
||||
graph.addEdgeWithKey("top->left", "top", "left", {
|
||||
edgeType: "sequential",
|
||||
});
|
||||
graph.addEdgeWithKey("top->right", "top", "right", {
|
||||
edgeType: "sequential",
|
||||
});
|
||||
graph.addEdgeWithKey("left->bottom", "left", "bottom", {
|
||||
edgeType: "sequential",
|
||||
});
|
||||
graph.addEdgeWithKey("right->bottom", "right", "bottom", {
|
||||
edgeType: "sequential",
|
||||
});
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.statusMap.get("top")!.value = "completed";
|
||||
root.statusMap.get("left")!.value = "completed";
|
||||
root.statusMap.get("right")!.value = "failed";
|
||||
|
||||
expect(root.statusMap.get("left")!.value).toBe("completed");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("effect disposal", () => {
|
||||
it("start effect is tracked by effectDisposers and cleaned up", () => {
|
||||
const status = signal<NodeStatus>("idle");
|
||||
const preconditions = computed(() => false);
|
||||
const disposers: (() => void)[] = [];
|
||||
|
||||
registerStartEffect(status, preconditions, disposers);
|
||||
|
||||
expect(disposers.length).toBe(1);
|
||||
|
||||
disposers[0]!();
|
||||
});
|
||||
|
||||
it("abort effect is tracked by effectDisposer and cleaned up", () => {
|
||||
const status = signal<NodeStatus>("idle");
|
||||
const blockedByFailure = computed(() => false);
|
||||
const disposers: (() => void)[] = [];
|
||||
|
||||
registerAbortEffect(status, blockedByFailure, disposers);
|
||||
|
||||
expect(disposers.length).toBe(1);
|
||||
|
||||
disposers[0]!();
|
||||
});
|
||||
|
||||
it("WorkflowReactiveRoot dispose clears all effects", () => {
|
||||
const graph = new DirectedGraph();
|
||||
graph.addNode("a", { name: "a" });
|
||||
graph.addNode("b", { name: "b" });
|
||||
graph.addEdgeWithKey("a->b", "a", "b", { edgeType: "sequential" });
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
expect(root.statusMap.get("a")!.value).toBe("ready");
|
||||
|
||||
root.dispose();
|
||||
|
||||
expect(root.statusMap.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -48,11 +48,11 @@ function makeForkJoinGraph(): DirectedGraph {
|
||||
|
||||
describe("WorkflowReactiveRoot", () => {
|
||||
describe("constructor and initializeSignals", () => {
|
||||
it("initializes all nodes with idle status", () => {
|
||||
it("root node starts as ready, downstream nodes start as idle", () => {
|
||||
const graph = makeSimpleGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
expect(root.statusMap.get("a")!.value).toBe("idle");
|
||||
expect(root.statusMap.get("a")!.value).toBe("ready");
|
||||
expect(root.statusMap.get("b")!.value).toBe("idle");
|
||||
expect(root.statusMap.get("c")!.value).toBe("idle");
|
||||
|
||||
@@ -159,7 +159,7 @@ describe("WorkflowReactiveRoot", () => {
|
||||
const graph = makeSimpleGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.nodeKeyToRequestId.set("a", "req-1");
|
||||
root.setRequestId("a", "req-1");
|
||||
root.append({
|
||||
type: "call.requested",
|
||||
requestId: "req-1",
|
||||
@@ -177,7 +177,7 @@ describe("WorkflowReactiveRoot", () => {
|
||||
const graph = makeSimpleGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.nodeKeyToRequestId.set("a", "req-1");
|
||||
root.setRequestId("a", "req-1");
|
||||
root.append({
|
||||
type: "call.requested",
|
||||
requestId: "req-1",
|
||||
@@ -201,7 +201,7 @@ describe("WorkflowReactiveRoot", () => {
|
||||
const graph = makeSimpleGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.nodeKeyToRequestId.set("a", "req-1");
|
||||
root.setRequestId("a", "req-1");
|
||||
root.append({
|
||||
type: "call.requested",
|
||||
requestId: "req-1",
|
||||
@@ -225,7 +225,7 @@ describe("WorkflowReactiveRoot", () => {
|
||||
const graph = makeSimpleGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.nodeKeyToRequestId.set("a", "req-1");
|
||||
root.setRequestId("a", "req-1");
|
||||
root.append({
|
||||
type: "call.requested",
|
||||
requestId: "req-1",
|
||||
@@ -256,7 +256,7 @@ describe("WorkflowReactiveRoot", () => {
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
|
||||
expect(root.statusMap.get("a")!.value).toBe("idle");
|
||||
expect(root.statusMap.get("a")!.value).toBe("ready");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
@@ -265,7 +265,7 @@ describe("WorkflowReactiveRoot", () => {
|
||||
const graph = makeSimpleGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.nodeKeyToRequestId.set("a", "req-1");
|
||||
root.setRequestId("a", "req-1");
|
||||
|
||||
const respondedEvent: CallEventMapValue = {
|
||||
type: "call.responded",
|
||||
@@ -291,11 +291,11 @@ describe("WorkflowReactiveRoot", () => {
|
||||
});
|
||||
|
||||
describe("getStatus", () => {
|
||||
it("returns idle for nodes with no events", () => {
|
||||
it("returns ready for root nodes with no events", () => {
|
||||
const graph = makeSimpleGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
expect(root.getStatus("a")).toBe("idle");
|
||||
expect(root.getStatus("a")).toBe("ready");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
@@ -304,7 +304,7 @@ describe("WorkflowReactiveRoot", () => {
|
||||
const graph = makeSimpleGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.nodeKeyToRequestId.set("a", "req-1");
|
||||
root.setRequestId("a", "req-1");
|
||||
root.append({
|
||||
type: "call.requested",
|
||||
requestId: "req-1",
|
||||
@@ -322,9 +322,9 @@ describe("WorkflowReactiveRoot", () => {
|
||||
const graph = makeSimpleGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.statusMap.get("a")!.value = "waiting";
|
||||
root.statusMap.get("b")!.value = "waiting";
|
||||
|
||||
expect(root.getStatus("a")).toBe("waiting");
|
||||
expect(root.getStatus("b")).toBe("waiting");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
@@ -353,7 +353,7 @@ describe("WorkflowReactiveRoot", () => {
|
||||
const graph = makeSimpleGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.nodeKeyToRequestId.set("a", "req-1");
|
||||
root.setRequestId("a", "req-1");
|
||||
root.append({
|
||||
type: "call.requested",
|
||||
requestId: "req-1",
|
||||
@@ -380,7 +380,7 @@ describe("WorkflowReactiveRoot", () => {
|
||||
const graph = makeSimpleGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.nodeKeyToRequestId.set("a", "req-1");
|
||||
root.setRequestId("a", "req-1");
|
||||
root.append({
|
||||
type: "call.requested",
|
||||
requestId: "req-1",
|
||||
@@ -408,7 +408,7 @@ describe("WorkflowReactiveRoot", () => {
|
||||
const graph = makeSimpleGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.nodeKeyToRequestId.set("a", "req-1");
|
||||
root.setRequestId("a", "req-1");
|
||||
root.append({
|
||||
type: "call.requested",
|
||||
requestId: "req-1",
|
||||
@@ -434,7 +434,7 @@ describe("WorkflowReactiveRoot", () => {
|
||||
const graph = makeSimpleGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.nodeKeyToRequestId.set("a", "req-1");
|
||||
root.setRequestId("a", "req-1");
|
||||
root.append({
|
||||
type: "call.requested",
|
||||
requestId: "req-1",
|
||||
@@ -452,7 +452,7 @@ describe("WorkflowReactiveRoot", () => {
|
||||
const graph = makeSimpleGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.nodeKeyToRequestId.set("a", "req-2");
|
||||
root.setRequestId("a", "req-1");
|
||||
root.append({
|
||||
type: "call.requested",
|
||||
requestId: "req-1",
|
||||
@@ -466,6 +466,7 @@ describe("WorkflowReactiveRoot", () => {
|
||||
error: { code: "ERR", message: "first attempt failed" },
|
||||
timestamp: "2026-01-01T00:00:01Z",
|
||||
});
|
||||
root.setRequestId("a", "req-2");
|
||||
root.append({
|
||||
type: "call.requested",
|
||||
requestId: "req-2",
|
||||
@@ -503,7 +504,7 @@ describe("WorkflowReactiveRoot", () => {
|
||||
const graph = makeSimpleGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.nodeKeyToRequestId.set("a", "req-1");
|
||||
root.setRequestId("a", "req-1");
|
||||
root.append({
|
||||
type: "call.requested",
|
||||
requestId: "req-1",
|
||||
@@ -527,6 +528,219 @@ describe("WorkflowReactiveRoot", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("retry semantics", () => {
|
||||
it("nodeKeyToRequestId overwrites previous requestId — projection tracks latest attempt", () => {
|
||||
const graph = makeSimpleGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.setRequestId("a", "req-1");
|
||||
expect(root.nodeKeyToRequestId.get("a")).toBe("req-1");
|
||||
|
||||
root.setRequestId("a", "req-2");
|
||||
expect(root.nodeKeyToRequestId.get("a")).toBe("req-2");
|
||||
expect(root.requestIdToNodeKey.get("req-2")).toBe("a");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
|
||||
it("retry sequence: error then requested then responded → status is completed", () => {
|
||||
const graph = makeSimpleGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.setRequestId("a", "req-1");
|
||||
root.append({
|
||||
type: "call.requested",
|
||||
requestId: "req-1",
|
||||
operationId: "a",
|
||||
input: null,
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
root.append({
|
||||
type: "call.error",
|
||||
requestId: "req-1",
|
||||
error: { code: "ERR", message: "failed" },
|
||||
timestamp: "2026-01-01T00:00:01Z",
|
||||
});
|
||||
expect(root.getStatus("a")).toBe("failed");
|
||||
|
||||
root.setRequestId("a", "req-2");
|
||||
root.append({
|
||||
type: "call.requested",
|
||||
requestId: "req-2",
|
||||
operationId: "a",
|
||||
input: null,
|
||||
timestamp: "2026-01-01T00:00:02Z",
|
||||
});
|
||||
expect(root.getStatus("a")).toBe("running");
|
||||
|
||||
root.append({
|
||||
type: "call.responded",
|
||||
requestId: "req-2",
|
||||
output: "ok",
|
||||
timestamp: "2026-01-01T00:00:03Z",
|
||||
});
|
||||
expect(root.getStatus("a")).toBe("completed");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
|
||||
it("getResult reflects latest attempt after retry", () => {
|
||||
const graph = makeSimpleGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.setRequestId("a", "req-1");
|
||||
root.append({
|
||||
type: "call.requested",
|
||||
requestId: "req-1",
|
||||
operationId: "a",
|
||||
input: null,
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
root.append({
|
||||
type: "call.error",
|
||||
requestId: "req-1",
|
||||
error: { code: "ERR", message: "failed" },
|
||||
timestamp: "2026-01-01T00:00:01Z",
|
||||
});
|
||||
|
||||
root.setRequestId("a", "req-2");
|
||||
root.append({
|
||||
type: "call.requested",
|
||||
requestId: "req-2",
|
||||
operationId: "a",
|
||||
input: null,
|
||||
timestamp: "2026-01-01T00:00:02Z",
|
||||
});
|
||||
root.append({
|
||||
type: "call.responded",
|
||||
requestId: "req-2",
|
||||
output: { retry: true },
|
||||
timestamp: "2026-01-01T00:00:03Z",
|
||||
});
|
||||
|
||||
const result = root.getResult("a");
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.status).toBe("completed");
|
||||
expect(result!.output).toEqual({ retry: true });
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
|
||||
it("event log preserves full history across retries", () => {
|
||||
const graph = makeSimpleGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.setRequestId("a", "req-1");
|
||||
root.append({
|
||||
type: "call.requested",
|
||||
requestId: "req-1",
|
||||
operationId: "a",
|
||||
input: null,
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
root.append({
|
||||
type: "call.error",
|
||||
requestId: "req-1",
|
||||
error: { code: "ERR", message: "failed" },
|
||||
timestamp: "2026-01-01T00:00:01Z",
|
||||
});
|
||||
|
||||
root.setRequestId("a", "req-2");
|
||||
root.append({
|
||||
type: "call.requested",
|
||||
requestId: "req-2",
|
||||
operationId: "a",
|
||||
input: null,
|
||||
timestamp: "2026-01-01T00:00:02Z",
|
||||
});
|
||||
root.append({
|
||||
type: "call.responded",
|
||||
requestId: "req-2",
|
||||
output: "ok",
|
||||
timestamp: "2026-01-01T00:00:03Z",
|
||||
});
|
||||
|
||||
const events = root.getEvents("a");
|
||||
expect(events.length).toBe(4);
|
||||
expect(events[0]!.type).toBe("call.requested");
|
||||
expect(events[0]!.requestId).toBe("req-1");
|
||||
expect(events[1]!.type).toBe("call.error");
|
||||
expect(events[1]!.requestId).toBe("req-1");
|
||||
expect(events[2]!.type).toBe("call.requested");
|
||||
expect(events[2]!.requestId).toBe("req-2");
|
||||
expect(events[3]!.type).toBe("call.responded");
|
||||
expect(events[3]!.requestId).toBe("req-2");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
|
||||
it("retry clears failed status so downstream preconditions can be met", () => {
|
||||
const graph = makeSimpleGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.setRequestId("a", "req-1");
|
||||
root.setRequestId("b", "req-b");
|
||||
root.append({
|
||||
type: "call.requested",
|
||||
requestId: "req-1",
|
||||
operationId: "a",
|
||||
input: null,
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
root.append({
|
||||
type: "call.error",
|
||||
requestId: "req-1",
|
||||
error: { code: "ERR", message: "failed" },
|
||||
timestamp: "2026-01-01T00:00:01Z",
|
||||
});
|
||||
expect(root.blockedByFailure.get("b")!.value).toBe(true);
|
||||
|
||||
root.setRequestId("a", "req-2");
|
||||
root.append({
|
||||
type: "call.requested",
|
||||
requestId: "req-2",
|
||||
operationId: "a",
|
||||
input: null,
|
||||
timestamp: "2026-01-01T00:00:02Z",
|
||||
});
|
||||
root.append({
|
||||
type: "call.responded",
|
||||
requestId: "req-2",
|
||||
output: "ok",
|
||||
timestamp: "2026-01-01T00:00:03Z",
|
||||
});
|
||||
expect(root.getStatus("a")).toBe("completed");
|
||||
expect(root.blockedByFailure.get("b")!.value).toBe(false);
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
|
||||
it("requestIdToNodeKey preserves all requestIds including previous attempts", () => {
|
||||
const graph = makeSimpleGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.setRequestId("a", "req-1");
|
||||
root.setRequestId("a", "req-2");
|
||||
|
||||
expect(root.requestIdToNodeKey.get("req-1")).toBe("a");
|
||||
expect(root.requestIdToNodeKey.get("req-2")).toBe("a");
|
||||
|
||||
root.dispose();
|
||||
});
|
||||
|
||||
it("dispose clears requestIdToNodeKey", () => {
|
||||
const graph = makeSimpleGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.setRequestId("a", "req-1");
|
||||
root.setRequestId("a", "req-2");
|
||||
expect(root.requestIdToNodeKey.size).toBe(2);
|
||||
|
||||
root.dispose();
|
||||
expect(root.requestIdToNodeKey.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("abort cascade", () => {
|
||||
it("failed node causes downstream dependents to abort (continue-running default)", () => {
|
||||
const graph = makeSimpleGraph();
|
||||
@@ -840,11 +1054,11 @@ describe("WorkflowReactiveRoot", () => {
|
||||
const graph = makeSimpleGraph();
|
||||
const root = new WorkflowReactiveRoot(graph);
|
||||
|
||||
root.nodeKeyToRequestId.set("a", "req-a");
|
||||
root.nodeKeyToRequestId.set("b", "req-b");
|
||||
root.nodeKeyToRequestId.set("c", "req-c");
|
||||
root.setRequestId("a", "req-a");
|
||||
root.setRequestId("b", "req-b");
|
||||
root.setRequestId("c", "req-c");
|
||||
|
||||
expect(root.getStatus("a")).toBe("idle");
|
||||
expect(root.getStatus("a")).toBe("ready");
|
||||
expect(root.getStatus("b")).toBe("idle");
|
||||
expect(root.getStatus("c")).toBe("idle");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user