commandnet 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- commandnet-0.3.0/PKG-INFO +174 -0
- commandnet-0.3.0/README.md +150 -0
- {commandnet-0.2.0 → commandnet-0.3.0}/commandnet/__init__.py +1 -1
- {commandnet-0.2.0 → commandnet-0.3.0}/commandnet/core/graph.py +11 -24
- commandnet-0.3.0/commandnet/core/node.py +43 -0
- commandnet-0.3.0/commandnet/engine/runtime.py +292 -0
- {commandnet-0.2.0 → commandnet-0.3.0}/commandnet/interfaces/persistence.py +9 -1
- {commandnet-0.2.0 → commandnet-0.3.0}/pyproject.toml +1 -1
- commandnet-0.2.0/PKG-INFO +0 -174
- commandnet-0.2.0/README.md +0 -150
- commandnet-0.2.0/commandnet/core/node.py +0 -50
- commandnet-0.2.0/commandnet/engine/runtime.py +0 -191
- {commandnet-0.2.0 → commandnet-0.3.0}/commandnet/core/models.py +0 -0
- {commandnet-0.2.0 → commandnet-0.3.0}/commandnet/interfaces/event_bus.py +0 -0
- {commandnet-0.2.0 → commandnet-0.3.0}/commandnet/interfaces/observer.py +0 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: commandnet
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: A lightweight, Pydantic-powered, distributed event-driven state machine and typed node graph runtime.
|
|
5
|
+
Author: Christopher Vaz
|
|
6
|
+
Author-email: christophervaz160@gmail.com
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
14
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pydantic (>=2.0.0)
|
|
17
|
+
Requires-Dist: pytest ; extra == "dev"
|
|
18
|
+
Requires-Dist: pytest-asyncio ; extra == "dev"
|
|
19
|
+
Project-URL: Homepage, https://github.com/NullAxon/commandnet
|
|
20
|
+
Project-URL: Issues, https://github.com/NullAxon/commandnet/issues
|
|
21
|
+
Project-URL: Repository, https://github.com/NullAxon/commandnet
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# 🕸️ CommandNet
|
|
25
|
+
|
|
26
|
+
**CommandNet** is a lightweight, distributed, event-driven state machine and typed node graph runtime for Python 3.11+.
|
|
27
|
+
|
|
28
|
+
It enables you to build durable, asynchronous workflows using strictly typed Python classes and Pydantic models. Unlike heavy orchestrators, CommandNet provides a minimal core that executes graph-based logic across distributed workers using your choice of database and message broker.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## 🚀 Installation
|
|
33
|
+
|
|
34
|
+
Install CommandNet via pip:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install commandnet
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## ✨ Key Features
|
|
43
|
+
|
|
44
|
+
- **Type-Safe Transitions**: The execution graph is inferred directly from Python type hints (`-> Union[Type[NodeA], Type[NodeB]]`). No external JSON/YAML definitions.
|
|
45
|
+
- **Pydantic State Management**: Context is automatically serialized and rehydrated into Pydantic models with full validation.
|
|
46
|
+
- **Distributed by Design**: Built-in row-level locking and idempotency support for safe execution across horizontally scaled workers.
|
|
47
|
+
- **Fan-out / Fan-in (Parallel)**: Native support for triggering multiple concurrent sub-tasks and merging results back into the parent state.
|
|
48
|
+
- **Native Scheduling**: Schedule nodes to run after a specific delay with built-in idempotency keys to prevent duplicate execution.
|
|
49
|
+
- **Static Validation**: Validate your entire workflow graph (types and connectivity) before a single event is processed.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## 🛠️ Quick Start
|
|
54
|
+
|
|
55
|
+
### 1. Define Your Context
|
|
56
|
+
The "Context" is the persistent state of your agent, defined using Pydantic.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from pydantic import BaseModel
|
|
60
|
+
|
|
61
|
+
class WorkflowCtx(BaseModel):
|
|
62
|
+
user_id: str
|
|
63
|
+
status: str = "pending"
|
|
64
|
+
attempts: int = 0
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 2. Define Your Nodes
|
|
68
|
+
Nodes are the building blocks of your graph. The return type hint of the `run` method defines the edges of your DAG.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from typing import Union, Type, Optional
|
|
72
|
+
from commandnet import Node
|
|
73
|
+
|
|
74
|
+
class ProcessPayment(Node[WorkflowCtx, None]):
|
|
75
|
+
async def run(self, ctx: WorkflowCtx, payload: None) -> None:
|
|
76
|
+
print(f"Processing for {ctx.user_id}...")
|
|
77
|
+
ctx.status = "complete"
|
|
78
|
+
return None # Terminal state
|
|
79
|
+
|
|
80
|
+
class CheckRisk(Node[WorkflowCtx, None]):
|
|
81
|
+
# The return type explicitly defines the possible next nodes
|
|
82
|
+
async def run(self, ctx: WorkflowCtx, payload: None) -> Union[Type[ProcessPayment], None]:
|
|
83
|
+
ctx.attempts += 1
|
|
84
|
+
if ctx.attempts > 3:
|
|
85
|
+
return None # Failure/Stop
|
|
86
|
+
return ProcessPayment
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### 3. Advanced Routing (Parallel & Scheduled)
|
|
90
|
+
CommandNet supports complex workflow patterns beyond simple linear transitions.
|
|
91
|
+
|
|
92
|
+
#### Parallel Fan-out
|
|
93
|
+
```python
|
|
94
|
+
from commandnet import Parallel, ParallelTask
|
|
95
|
+
|
|
96
|
+
class StartAnalysis(Node[WorkflowCtx, None]):
|
|
97
|
+
async def run(self, ctx: WorkflowCtx, payload: None) -> Parallel:
|
|
98
|
+
return Parallel(
|
|
99
|
+
branches=[
|
|
100
|
+
ParallelTask(node_cls=SubTaskNode, sub_context_path="sub_data_1"),
|
|
101
|
+
ParallelTask(node_cls=SubTaskNode, sub_context_path="sub_data_2")
|
|
102
|
+
],
|
|
103
|
+
join_node=FinalMergeNode
|
|
104
|
+
)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
#### Delayed Scheduling
|
|
108
|
+
```python
|
|
109
|
+
from commandnet import Schedule
|
|
110
|
+
|
|
111
|
+
class RetryNode(Node[WorkflowCtx, None]):
|
|
112
|
+
async def run(self, ctx: WorkflowCtx, payload: None) -> Schedule:
|
|
113
|
+
return Schedule(
|
|
114
|
+
node_cls=CheckRisk,
|
|
115
|
+
delay_seconds=300,
|
|
116
|
+
idempotency_key=f"retry-{ctx.attempts}"
|
|
117
|
+
)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## 🏗️ Infrastructure Integration
|
|
123
|
+
|
|
124
|
+
CommandNet is unopinionated about your stack. You simply implement two abstract interfaces:
|
|
125
|
+
|
|
126
|
+
1. **`Persistence`**: Handles locking state in your DB (Postgres, Redis, DynamoDB).
|
|
127
|
+
2. **`EventBus`**: Handles moving events between workers (RabbitMQ, NATS, SQS).
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from commandnet import Engine
|
|
131
|
+
|
|
132
|
+
# Implement these interfaces for your specific stack
|
|
133
|
+
db = MyPostgresAdapter()
|
|
134
|
+
bus = MyRabbitMQAdapter()
|
|
135
|
+
|
|
136
|
+
engine = Engine(persistence=db, event_bus=bus)
|
|
137
|
+
|
|
138
|
+
# Start the worker loop
|
|
139
|
+
await engine.start_worker()
|
|
140
|
+
|
|
141
|
+
# Trigger an execution
|
|
142
|
+
await engine.trigger_agent("agent-123", CheckRisk, WorkflowCtx(user_id="user_abc"))
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## 🔍 Static Analysis & Safety
|
|
148
|
+
|
|
149
|
+
Prevent runtime failures by validating your graph during CI/CD or at startup. The `GraphAnalyzer` checks for disconnected nodes and ensures that if `NodeA` transitions to `NodeB`, they share compatible `Context` types.
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
from commandnet import GraphAnalyzer
|
|
153
|
+
|
|
154
|
+
# This will raise a TypeError if types don't match or a ValueError if edges are broken
|
|
155
|
+
GraphAnalyzer.validate(CheckRisk)
|
|
156
|
+
|
|
157
|
+
# Generate a dictionary representation of your DAG
|
|
158
|
+
dag = GraphAnalyzer.build_graph(CheckRisk)
|
|
159
|
+
print(dag) # {'CheckRisk': ['ProcessPayment'], 'ProcessPayment': []}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## ⚖️ Design Philosophy
|
|
165
|
+
|
|
166
|
+
1. **Code as Truth**: If your IDE can navigate it, CommandNet can run it. No "magic strings."
|
|
167
|
+
2. **Stateless Execution**: Workers don't keep local state. Every node execution starts with a fresh database fetch and lock.
|
|
168
|
+
3. **Zero Magic**: No hidden background threads or global singletons. You control the `Engine` lifecycle.
|
|
169
|
+
4. **Ownership**: CommandNet provides the orchestration logic; you provide the infrastructure.
|
|
170
|
+
|
|
171
|
+
## 📄 License
|
|
172
|
+
|
|
173
|
+
MIT
|
|
174
|
+
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# 🕸️ CommandNet
|
|
2
|
+
|
|
3
|
+
**CommandNet** is a lightweight, distributed, event-driven state machine and typed node graph runtime for Python 3.11+.
|
|
4
|
+
|
|
5
|
+
It enables you to build durable, asynchronous workflows using strictly typed Python classes and Pydantic models. Unlike heavy orchestrators, CommandNet provides a minimal core that executes graph-based logic across distributed workers using your choice of database and message broker.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 🚀 Installation
|
|
10
|
+
|
|
11
|
+
Install CommandNet via pip:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install commandnet
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## ✨ Key Features
|
|
20
|
+
|
|
21
|
+
- **Type-Safe Transitions**: The execution graph is inferred directly from Python type hints (`-> Union[Type[NodeA], Type[NodeB]]`). No external JSON/YAML definitions.
|
|
22
|
+
- **Pydantic State Management**: Context is automatically serialized and rehydrated into Pydantic models with full validation.
|
|
23
|
+
- **Distributed by Design**: Built-in row-level locking and idempotency support for safe execution across horizontally scaled workers.
|
|
24
|
+
- **Fan-out / Fan-in (Parallel)**: Native support for triggering multiple concurrent sub-tasks and merging results back into the parent state.
|
|
25
|
+
- **Native Scheduling**: Schedule nodes to run after a specific delay with built-in idempotency keys to prevent duplicate execution.
|
|
26
|
+
- **Static Validation**: Validate your entire workflow graph (types and connectivity) before a single event is processed.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 🛠️ Quick Start
|
|
31
|
+
|
|
32
|
+
### 1. Define Your Context
|
|
33
|
+
The "Context" is the persistent state of your agent, defined using Pydantic.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from pydantic import BaseModel
|
|
37
|
+
|
|
38
|
+
class WorkflowCtx(BaseModel):
|
|
39
|
+
user_id: str
|
|
40
|
+
status: str = "pending"
|
|
41
|
+
attempts: int = 0
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### 2. Define Your Nodes
|
|
45
|
+
Nodes are the building blocks of your graph. The return type hint of the `run` method defines the edges of your DAG.
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from typing import Union, Type, Optional
|
|
49
|
+
from commandnet import Node
|
|
50
|
+
|
|
51
|
+
class ProcessPayment(Node[WorkflowCtx, None]):
|
|
52
|
+
async def run(self, ctx: WorkflowCtx, payload: None) -> None:
|
|
53
|
+
print(f"Processing for {ctx.user_id}...")
|
|
54
|
+
ctx.status = "complete"
|
|
55
|
+
return None # Terminal state
|
|
56
|
+
|
|
57
|
+
class CheckRisk(Node[WorkflowCtx, None]):
|
|
58
|
+
# The return type explicitly defines the possible next nodes
|
|
59
|
+
async def run(self, ctx: WorkflowCtx, payload: None) -> Union[Type[ProcessPayment], None]:
|
|
60
|
+
ctx.attempts += 1
|
|
61
|
+
if ctx.attempts > 3:
|
|
62
|
+
return None # Failure/Stop
|
|
63
|
+
return ProcessPayment
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 3. Advanced Routing (Parallel & Scheduled)
|
|
67
|
+
CommandNet supports complex workflow patterns beyond simple linear transitions.
|
|
68
|
+
|
|
69
|
+
#### Parallel Fan-out
|
|
70
|
+
```python
|
|
71
|
+
from commandnet import Parallel, ParallelTask
|
|
72
|
+
|
|
73
|
+
class StartAnalysis(Node[WorkflowCtx, None]):
|
|
74
|
+
async def run(self, ctx: WorkflowCtx, payload: None) -> Parallel:
|
|
75
|
+
return Parallel(
|
|
76
|
+
branches=[
|
|
77
|
+
ParallelTask(node_cls=SubTaskNode, sub_context_path="sub_data_1"),
|
|
78
|
+
ParallelTask(node_cls=SubTaskNode, sub_context_path="sub_data_2")
|
|
79
|
+
],
|
|
80
|
+
join_node=FinalMergeNode
|
|
81
|
+
)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
#### Delayed Scheduling
|
|
85
|
+
```python
|
|
86
|
+
from commandnet import Schedule
|
|
87
|
+
|
|
88
|
+
class RetryNode(Node[WorkflowCtx, None]):
|
|
89
|
+
async def run(self, ctx: WorkflowCtx, payload: None) -> Schedule:
|
|
90
|
+
return Schedule(
|
|
91
|
+
node_cls=CheckRisk,
|
|
92
|
+
delay_seconds=300,
|
|
93
|
+
idempotency_key=f"retry-{ctx.attempts}"
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## 🏗️ Infrastructure Integration
|
|
100
|
+
|
|
101
|
+
CommandNet is unopinionated about your stack. You simply implement two abstract interfaces:
|
|
102
|
+
|
|
103
|
+
1. **`Persistence`**: Handles locking state in your DB (Postgres, Redis, DynamoDB).
|
|
104
|
+
2. **`EventBus`**: Handles moving events between workers (RabbitMQ, NATS, SQS).
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from commandnet import Engine
|
|
108
|
+
|
|
109
|
+
# Implement these interfaces for your specific stack
|
|
110
|
+
db = MyPostgresAdapter()
|
|
111
|
+
bus = MyRabbitMQAdapter()
|
|
112
|
+
|
|
113
|
+
engine = Engine(persistence=db, event_bus=bus)
|
|
114
|
+
|
|
115
|
+
# Start the worker loop
|
|
116
|
+
await engine.start_worker()
|
|
117
|
+
|
|
118
|
+
# Trigger an execution
|
|
119
|
+
await engine.trigger_agent("agent-123", CheckRisk, WorkflowCtx(user_id="user_abc"))
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## 🔍 Static Analysis & Safety
|
|
125
|
+
|
|
126
|
+
Prevent runtime failures by validating your graph during CI/CD or at startup. The `GraphAnalyzer` checks for disconnected nodes and ensures that if `NodeA` transitions to `NodeB`, they share compatible `Context` types.
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from commandnet import GraphAnalyzer
|
|
130
|
+
|
|
131
|
+
# This will raise a TypeError if types don't match or a ValueError if edges are broken
|
|
132
|
+
GraphAnalyzer.validate(CheckRisk)
|
|
133
|
+
|
|
134
|
+
# Generate a dictionary representation of your DAG
|
|
135
|
+
dag = GraphAnalyzer.build_graph(CheckRisk)
|
|
136
|
+
print(dag) # {'CheckRisk': ['ProcessPayment'], 'ProcessPayment': []}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## ⚖️ Design Philosophy
|
|
142
|
+
|
|
143
|
+
1. **Code as Truth**: If your IDE can navigate it, CommandNet can run it. No "magic strings."
|
|
144
|
+
2. **Stateless Execution**: Workers don't keep local state. Every node execution starts with a fresh database fetch and lock.
|
|
145
|
+
3. **Zero Magic**: No hidden background threads or global singletons. You control the `Engine` lifecycle.
|
|
146
|
+
4. **Ownership**: CommandNet provides the orchestration logic; you provide the infrastructure.
|
|
147
|
+
|
|
148
|
+
## 📄 License
|
|
149
|
+
|
|
150
|
+
MIT
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from .core.models import Event
|
|
2
|
-
from .core.node import Node, Parallel, ParallelTask, Schedule
|
|
2
|
+
from .core.node import Node, Parallel, ParallelTask, Schedule, Wait
|
|
3
3
|
from .core.graph import GraphAnalyzer
|
|
4
4
|
from .interfaces.persistence import Persistence
|
|
5
5
|
from .interfaces.event_bus import EventBus
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import typing
|
|
2
2
|
import inspect
|
|
3
3
|
from typing import Any, Dict, List, Set, Type, Union, get_args, get_origin
|
|
4
|
-
from .node import Node
|
|
4
|
+
from .node import Node
|
|
5
5
|
|
|
6
6
|
class GraphAnalyzer:
|
|
7
7
|
@staticmethod
|
|
@@ -25,16 +25,12 @@ class GraphAnalyzer:
|
|
|
25
25
|
@staticmethod
|
|
26
26
|
def _get_node_names(type_hint: Any) -> List[str]:
|
|
27
27
|
origin = typing.get_origin(type_hint)
|
|
28
|
-
|
|
29
|
-
# 1. Handle Union (and Optional)
|
|
30
28
|
if origin is Union:
|
|
31
29
|
names = []
|
|
32
30
|
for arg in typing.get_args(type_hint):
|
|
33
31
|
if arg is type(None): continue
|
|
34
32
|
names.extend(GraphAnalyzer._get_node_names(arg))
|
|
35
33
|
return names
|
|
36
|
-
|
|
37
|
-
# 2. Handle Type[X] or typing.Type[X]
|
|
38
34
|
if origin in (type, typing.Type):
|
|
39
35
|
arg = typing.get_args(type_hint)[0]
|
|
40
36
|
if inspect.isclass(arg) and issubclass(arg, Node):
|
|
@@ -43,15 +39,12 @@ class GraphAnalyzer:
|
|
|
43
39
|
return [arg.__forward_arg__]
|
|
44
40
|
if isinstance(arg, str):
|
|
45
41
|
return [arg]
|
|
46
|
-
|
|
47
|
-
# 3. Direct class references
|
|
48
42
|
if inspect.isclass(type_hint) and issubclass(type_hint, Node):
|
|
49
43
|
return [type_hint.get_node_name()]
|
|
50
|
-
|
|
51
44
|
return []
|
|
52
45
|
|
|
53
46
|
@staticmethod
|
|
54
|
-
def get_transitions(node_cls: Type[Node]) -> Set[Type[Node]]:
|
|
47
|
+
def get_transitions(node_cls: Type[Node], registry: Dict[str, Type[Node]]) -> Set[Type[Node]]:
|
|
55
48
|
ret_annotation = node_cls.run.__annotations__.get("return")
|
|
56
49
|
if not ret_annotation: return set()
|
|
57
50
|
|
|
@@ -59,47 +52,42 @@ class GraphAnalyzer:
|
|
|
59
52
|
transitions = set()
|
|
60
53
|
|
|
61
54
|
for name in node_names:
|
|
62
|
-
if name not in
|
|
55
|
+
if name not in registry:
|
|
63
56
|
raise RuntimeError(
|
|
64
57
|
f"Graph Error: Node '{node_cls.get_node_name()}' references unknown node '{name}'. "
|
|
65
|
-
"Ensure it is
|
|
58
|
+
"Ensure it is passed to the Engine/Registry."
|
|
66
59
|
)
|
|
67
|
-
transitions.add(
|
|
68
|
-
|
|
60
|
+
transitions.add(registry[name])
|
|
69
61
|
return transitions
|
|
70
62
|
|
|
71
63
|
@staticmethod
|
|
72
|
-
def build_graph(start_node: Type[Node]) -> Dict[str, List[str]]:
|
|
64
|
+
def build_graph(start_node: Type[Node], registry: Dict[str, Type[Node]]) -> Dict[str, List[str]]:
|
|
73
65
|
graph = {}
|
|
74
66
|
visited, queue = set(), [start_node]
|
|
75
67
|
while queue:
|
|
76
68
|
current = queue.pop(0)
|
|
77
69
|
if current in visited: continue
|
|
78
70
|
visited.add(current)
|
|
79
|
-
transitions = GraphAnalyzer.get_transitions(current)
|
|
71
|
+
transitions = GraphAnalyzer.get_transitions(current, registry)
|
|
80
72
|
graph[current.get_node_name()] = [t.get_node_name() for t in transitions]
|
|
81
73
|
for t in transitions:
|
|
82
74
|
if t not in visited: queue.append(t)
|
|
83
75
|
return graph
|
|
84
76
|
|
|
85
77
|
@staticmethod
|
|
86
|
-
def validate(start_node: Type[Node]):
|
|
87
|
-
|
|
88
|
-
graph = GraphAnalyzer.build_graph(start_node)
|
|
89
|
-
|
|
78
|
+
def validate(start_node: Type[Node], registry: Dict[str, Type[Node]]):
|
|
79
|
+
graph = GraphAnalyzer.build_graph(start_node, registry)
|
|
90
80
|
for node_name, edges in graph.items():
|
|
91
|
-
node_cls =
|
|
81
|
+
node_cls = registry.get(node_name)
|
|
92
82
|
if not node_cls:
|
|
93
83
|
raise ValueError(f"Validation Error: Node '{node_name}' missing from registry.")
|
|
94
84
|
|
|
95
85
|
source_ctx = GraphAnalyzer.get_context_type(node_cls)
|
|
96
|
-
|
|
97
86
|
for edge in edges:
|
|
98
|
-
target_cls =
|
|
87
|
+
target_cls = registry.get(edge)
|
|
99
88
|
if not target_cls:
|
|
100
89
|
raise ValueError(f"Validation Error: Edge '{edge}' from '{node_name}' does not exist.")
|
|
101
90
|
|
|
102
|
-
# STRICT CONTEXT MATCHING
|
|
103
91
|
target_ctx = GraphAnalyzer.get_context_type(target_cls)
|
|
104
92
|
if source_ctx is not target_ctx and not issubclass(source_ctx, target_ctx):
|
|
105
93
|
raise TypeError(
|
|
@@ -107,5 +95,4 @@ class GraphAnalyzer:
|
|
|
107
95
|
f"but their Context types do not match! "
|
|
108
96
|
f"({source_ctx.__name__} -> {target_ctx.__name__})"
|
|
109
97
|
)
|
|
110
|
-
|
|
111
98
|
return True
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from typing import Generic, TypeVar, Type, Optional, Union, List, Any
|
|
2
|
+
from pydantic import BaseModel, ConfigDict
|
|
3
|
+
|
|
4
|
+
C = TypeVar('C', bound=BaseModel) # Context
|
|
5
|
+
P = TypeVar('P', bound=BaseModel) # Payload
|
|
6
|
+
|
|
7
|
+
# The Recursive Type Definition
|
|
8
|
+
Target = Union[Type['Node'], 'Parallel', 'Schedule', 'Wait', None]
|
|
9
|
+
|
|
10
|
+
class ParallelTask(BaseModel):
|
|
11
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
12
|
+
action: Target
|
|
13
|
+
sub_context_path: str
|
|
14
|
+
payload: Optional[Any] = None
|
|
15
|
+
|
|
16
|
+
class Parallel(BaseModel):
|
|
17
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
18
|
+
branches: List[Union[ParallelTask, 'Wait']]
|
|
19
|
+
join_node: Optional[Type['Node']] = None
|
|
20
|
+
|
|
21
|
+
class Schedule(BaseModel):
|
|
22
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
23
|
+
action: Target
|
|
24
|
+
delay_seconds: int
|
|
25
|
+
payload: Optional[Any] = None
|
|
26
|
+
idempotency_key: Optional[str] = None
|
|
27
|
+
|
|
28
|
+
class Wait(BaseModel):
|
|
29
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
30
|
+
signal_id: str
|
|
31
|
+
resume_action: Target
|
|
32
|
+
sub_context_path: Optional[str] = None
|
|
33
|
+
|
|
34
|
+
TransitionResult = Target
|
|
35
|
+
|
|
36
|
+
class Node(Generic[C, P]):
|
|
37
|
+
@classmethod
|
|
38
|
+
def get_node_name(cls) -> str:
|
|
39
|
+
return cls.__name__
|
|
40
|
+
|
|
41
|
+
async def run(self, ctx: C, payload: Optional[P] = None) -> TransitionResult:
|
|
42
|
+
"""Executes node logic. Returns a Target (Node class, Parallel, Schedule, Wait, or None)."""
|
|
43
|
+
pass
|