commandnet 0.2.0__tar.gz → 0.2.2__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.
@@ -0,0 +1,174 @@
1
+ Metadata-Version: 2.4
2
+ Name: commandnet
3
+ Version: 0.2.2
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,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, NODE_REGISTRY
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 NODE_REGISTRY:
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 imported and subclasses CommandNet Node."
58
+ "Ensure it is passed to the Engine/Registry."
66
59
  )
67
- transitions.add(NODE_REGISTRY[name])
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
- """Static analysis of the DAG to prevent runtime crashes."""
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 = NODE_REGISTRY.get(node_name)
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 = NODE_REGISTRY.get(edge)
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
@@ -5,8 +5,6 @@ from pydantic import BaseModel, ConfigDict
5
5
  C = TypeVar('C', bound=BaseModel) # Context
6
6
  P = TypeVar('P', bound=BaseModel) # Payload (Optional)
7
7
 
8
- NODE_REGISTRY: Dict[str, Type['Node']] = {}
9
-
10
8
  class ParallelTask(BaseModel):
11
9
  model_config = ConfigDict(arbitrary_types_allowed=True)
12
10
  node_cls: Type['Node']
@@ -33,18 +31,6 @@ class Node(Generic[C, P]):
33
31
  def get_node_name(cls) -> str:
34
32
  return cls.__name__
35
33
 
36
- def __init_subclass__(cls, **kwargs):
37
- super().__init_subclass__(**kwargs)
38
- if not inspect.isabstract(cls):
39
- name = cls.get_node_name()
40
- # Prevent registry collisions across modules
41
- if name in NODE_REGISTRY and NODE_REGISTRY[name] is not cls:
42
- raise RuntimeError(
43
- f"NODE_REGISTRY Collision: '{name}' is already registered by {NODE_REGISTRY[name].__module__}. "
44
- f"Override `get_node_name()` on {cls.__module__}.{name} to ensure uniqueness."
45
- )
46
- NODE_REGISTRY[name] = cls
47
-
48
34
  async def run(self, ctx: C, payload: Optional[P] = None) -> TransitionResult:
49
35
  """Executes node logic. Returns the Next Node, a Parallel request, a Schedule request, or None."""
50
36
  pass
@@ -1,23 +1,41 @@
1
1
  import asyncio
2
2
  import logging
3
3
  from datetime import datetime, timezone, timedelta
4
- from typing import Optional, Type
4
+ from typing import Optional, Type, Iterable, Dict
5
5
  from pydantic import BaseModel
6
6
 
7
7
  from ..core.models import Event
8
- from ..core.node import Node, NODE_REGISTRY, Parallel, Schedule
8
+ from ..core.node import Node, Parallel, Schedule
9
9
  from ..core.graph import GraphAnalyzer
10
10
  from ..interfaces.persistence import Persistence
11
11
  from ..interfaces.event_bus import EventBus
12
12
  from ..interfaces.observer import Observer
13
13
 
14
14
  class Engine:
15
- def __init__(self, persistence: Persistence, event_bus: EventBus, observer: Optional[Observer] = None):
15
+ def __init__(
16
+ self,
17
+ persistence: Persistence,
18
+ event_bus: EventBus,
19
+ nodes: Iterable[Type[Node]],
20
+ observer: Optional[Observer] = None
21
+ ):
16
22
  self.db = persistence
17
23
  self.bus = event_bus
18
24
  self.observer = observer or Observer()
19
25
  self.logger = logging.getLogger("CommandNet")
20
26
  self._scheduler_task: Optional[asyncio.Task] = None
27
+
28
+ # Build Engine-scoped registry
29
+ self._registry: Dict[str, Type[Node]] = {}
30
+ for node_cls in nodes:
31
+ name = node_cls.get_node_name()
32
+ if name in self._registry and self._registry[name] is not node_cls:
33
+ raise RuntimeError(f"Node name collision in Engine: '{name}'")
34
+ self._registry[name] = node_cls
35
+
36
+ def validate_graph(self, start_node: Type[Node]):
37
+ """Helper to validate the graph using this engine's registry."""
38
+ return GraphAnalyzer.validate(start_node, self._registry)
21
39
 
22
40
  async def start_worker(self, poll_interval: float = 1.0):
23
41
  await self.bus.subscribe(self.process_event)
@@ -46,6 +64,9 @@ class Engine:
46
64
 
47
65
  async def trigger_agent(self, agent_id: str, start_node: Type[Node], initial_context: BaseModel, payload: Optional[BaseModel] = None):
48
66
  node_name = start_node.get_node_name()
67
+ if node_name not in self._registry:
68
+ raise ValueError(f"Node '{node_name}' is not registered with this Engine.")
69
+
49
70
  start_event = Event(
50
71
  agent_id=agent_id,
51
72
  node_name=node_name,
@@ -64,6 +85,7 @@ class Engine:
64
85
  locked = True
65
86
  try:
66
87
  if current_node_name != event.node_name:
88
+ # Same logic as before for sub-state/state
67
89
  if "#" in event.agent_id:
68
90
  parent_id = event.agent_id.split("#")[0]
69
91
  await self.db.save_sub_state(event.agent_id, parent_id, current_node_name, ctx_dict, None)
@@ -72,9 +94,9 @@ class Engine:
72
94
  locked = False
73
95
  return
74
96
 
75
- node_cls = NODE_REGISTRY.get(current_node_name)
97
+ node_cls = self._registry.get(current_node_name)
76
98
  if not node_cls:
77
- raise RuntimeError(f"Node '{current_node_name}' missing from registry.")
99
+ raise RuntimeError(f"Node '{current_node_name}' not found in this Engine's registry.")
78
100
 
79
101
  ctx_type = GraphAnalyzer.get_context_type(node_cls)
80
102
  payload_type = GraphAnalyzer.get_payload_type(node_cls)
@@ -101,20 +123,22 @@ class Engine:
101
123
  else:
102
124
  await self._handle_terminal(event.agent_id, current_node_name, ctx, duration)
103
125
 
104
- locked = False # Success! The handle_* methods performed a save_state, which released the lock.
126
+ locked = False
105
127
 
106
128
  except Exception as e:
107
129
  await self.observer.on_error(event.agent_id, current_node_name, e)
108
130
  raise
109
131
  finally:
110
132
  if locked:
111
- # Deadlock prevention: Exception occurred before state was saved
112
133
  await self.db.unlock_agent(event.agent_id)
113
134
 
135
+ # Remaining _handle_* methods use self._registry and self.validate_graph
114
136
  async def _handle_transition(self, agent_id: str, from_node: str, next_node_cls: Type[Node], ctx: BaseModel, duration: float):
115
137
  next_name = next_node_cls.get_node_name()
138
+ if next_name not in self._registry:
139
+ raise RuntimeError(f"Transition target '{next_name}' not in registry.")
140
+
116
141
  await self.observer.on_transition(agent_id, from_node, next_name, duration)
117
-
118
142
  next_event = Event(agent_id=agent_id, node_name=next_name)
119
143
  await self.db.save_state(agent_id, next_name, ctx.model_dump(), next_event)
120
144
  await self.bus.publish(next_event)
@@ -122,33 +146,31 @@ class Engine:
122
146
  async def _handle_parallel_start(self, parent_id: str, parent_ctx: BaseModel, parallel: Parallel, duration: float):
123
147
  join_name = parallel.join_node.get_node_name()
124
148
  await self.observer.on_transition(parent_id, "ParallelStart", join_name, duration)
125
-
126
149
  await self.db.create_task_group(parent_id=parent_id, join_node_name=join_name, task_count=len(parallel.branches))
127
150
 
128
151
  for task in parallel.branches:
129
152
  if not hasattr(parent_ctx, task.sub_context_path):
130
- raise RuntimeError(f"Context missing path: '{task.sub_context_path}'. Cannot fan out.")
153
+ raise RuntimeError(f"Context missing path: '{task.sub_context_path}'.")
131
154
 
132
155
  sub_ctx = getattr(parent_ctx, task.sub_context_path)
133
156
  sub_id = f"{parent_id}#{task.sub_context_path}"
157
+ node_name = task.node_cls.get_node_name()
134
158
 
135
159
  evt = Event(
136
160
  agent_id=sub_id,
137
- node_name=task.node_cls.get_node_name(),
161
+ node_name=node_name,
138
162
  payload=task.payload.model_dump() if hasattr(task.payload, "model_dump") else task.payload
139
163
  )
140
- await self.db.save_sub_state(sub_id, parent_id, task.node_cls.get_node_name(), sub_ctx.model_dump(), evt)
164
+ await self.db.save_sub_state(sub_id, parent_id, node_name, sub_ctx.model_dump(), evt)
141
165
  await self.bus.publish(evt)
142
166
 
143
167
  await self.db.save_state(parent_id, "WAITING_FOR_JOIN", parent_ctx.model_dump(), None)
144
168
 
145
169
  async def _handle_terminal(self, agent_id: str, from_node: str, ctx: BaseModel, duration: float):
146
170
  await self.observer.on_transition(agent_id, from_node, "TERMINAL", duration)
147
-
148
171
  if "#" in agent_id:
149
172
  parent_id = agent_id.split("#")[0]
150
173
  await self.db.save_sub_state(agent_id, parent_id, "TERMINAL", ctx.model_dump(), None)
151
-
152
174
  join_node_name = await self.db.register_sub_task_completion(agent_id)
153
175
  if join_node_name:
154
176
  await self._trigger_recompose(parent_id, join_node_name)
@@ -158,7 +180,6 @@ class Engine:
158
180
  async def _handle_schedule(self, agent_id: str, from_node: str, ctx: BaseModel, schedule: Schedule, duration: float):
159
181
  target_name = schedule.node_cls.get_node_name()
160
182
  await self.observer.on_transition(agent_id, from_node, f"SCHEDULED:{target_name}", duration)
161
-
162
183
  run_at_dt = datetime.now(timezone.utc) + timedelta(seconds=schedule.delay_seconds)
163
184
 
164
185
  evt = Event(
@@ -175,7 +196,6 @@ class Engine:
175
196
  if "#" in agent_id:
176
197
  parent_id = agent_id.split("#")[0]
177
198
  await self.db.save_sub_state(agent_id, parent_id, next_node, ctx.model_dump(), None)
178
-
179
199
  if not scheduled:
180
200
  join_node_name = await self.db.register_sub_task_completion(agent_id)
181
201
  if join_node_name:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "commandnet"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "A lightweight, Pydantic-powered, distributed event-driven state machine and typed node graph runtime."
5
5
  authors = [
6
6
  { name = "Christopher Vaz", email = "christophervaz160@gmail.com" }
commandnet-0.2.0/PKG-INFO DELETED
@@ -1,174 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: commandnet
3
- Version: 0.2.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 allows you to build durable, asynchronous workflow graphs using strictly typed Python classes and Pydantic models. **CommandNet is not an orchestrator** (no built-in crons, external scheduling, or magic workflow DSLs). Instead, it provides a minimal, dependency-free (except Pydantic) core for executing graph-based logic across distributed workers using any database and message broker you choose.
29
-
30
- ## Features
31
-
32
- - **Strictly Typed Transitions**: Execution graphs are inferred directly from Python type hints (`-> NextNode`). No string-based identifiers.
33
- - **First-Class Pydantic Support**: Context state is automatically serialized to your database and strictly rehydrated into Pydantic models before node execution.
34
- - **Distributed-Worker Ready**: Safely runs across multiple horizontally scaled consumers via row-level locking patterns and idempotency checks.
35
- - **Bring Your Own Infrastructure**: Clean abstract interfaces for `Persistence` (Postgres, SQLite) and `EventBus` (RabbitMQ, NATS, Redis).
36
- - **Zero Magic**: Deterministic execution, highly observable, and easy to test.
37
-
38
- ---
39
-
40
- ## Installation
41
-
42
- ```bash
43
- pip install commandnet
44
- ```
45
- *Or with Poetry:*
46
- ```bash
47
- poetry add commandnet
48
- ```
49
-
50
- ---
51
-
52
- ## Quick Start
53
-
54
- ### 1. Define your State (Context)
55
- Use Pydantic to define the mutable state that will be passed through your graph. CommandNet will automatically validate and rehydrate this data from your database.
56
-
57
- ```python
58
- from pydantic import BaseModel, Field
59
-
60
- class AgentContext(BaseModel):
61
- user_query: str
62
- is_authenticated: bool = False
63
- attempts: int = Field(default=0, ge=0)
64
- ```
65
-
66
- ### 2. Define your Nodes
67
- Nodes subclass `Node` and must implement an `async def run(self, ctx)`. The **return type hint** dictates the execution graph!
68
-
69
- ```python
70
- from typing import Union, Type
71
- from commandnet import Node
72
-
73
- class Denied(Node[AgentContext]):
74
- async def run(self, ctx: AgentContext) -> None: # Returning None means Terminal state
75
- print(f"[{ctx.user_query}] -> Access Denied.")
76
- return None
77
-
78
- class Executing(Node[AgentContext]):
79
- async def run(self, ctx: AgentContext) -> None:
80
- print(f"[{ctx.user_query}] -> Running task successfully!")
81
- return None
82
-
83
- class AuthCheck(Node[AgentContext]):
84
- # The return type explicitly defines the DAG edges:
85
- async def run(self, ctx: AgentContext) -> Union[Type[Executing], Type[Denied]]:
86
- print(f"[{ctx.user_query}] -> Checking Auth...")
87
- ctx.attempts += 1
88
-
89
- if ctx.user_query == "hack_system":
90
- return Denied
91
-
92
- ctx.is_authenticated = True
93
- return Executing
94
- ```
95
-
96
- ### 3. Run the Engine
97
- Implement the `Persistence` and `EventBus` interfaces for your infrastructure (or use in-memory mocks for testing), and trigger the agent.
98
-
99
- ```python
100
- import asyncio
101
- from commandnet import Engine, GraphAnalyzer
102
-
103
- # Note: You must implement Persistence and EventBus interfaces
104
- # See the `interfaces/` directory for expected methods.
105
- from my_app.adapters import PostgresPersistence, RabbitMQBus
106
-
107
- async def main():
108
- # 1. (Optional) Introspect your graph to visualize or validate it
109
- dag = GraphAnalyzer.build_graph(AuthCheck)
110
- print("Graph Structure:", dag)
111
- # Output: {'AuthCheck': ['Executing', 'Denied'], 'Executing': [], 'Denied': []}
112
-
113
- # 2. Initialize Engine
114
- db = PostgresPersistence()
115
- bus = RabbitMQBus()
116
- engine = Engine(persistence=db, event_bus=bus)
117
-
118
- # 3. Start listening to the event queue
119
- await engine.start_worker()
120
-
121
- # 4. Trigger an execution
122
- initial_context = AgentContext(user_query="clean_logs")
123
- await engine.trigger_agent(
124
- agent_id="agent-001",
125
- start_node=AuthCheck,
126
- initial_context=initial_context
127
- )
128
-
129
- if __name__ == "__main__":
130
- asyncio.run(main())
131
- ```
132
-
133
- ---
134
-
135
- ## Pluggable Architecture
136
-
137
- CommandNet forces you to own your infrastructure. You connect it to your stack by implementing three simple interfaces:
138
-
139
- ### `Persistence`
140
- Handles locking, saving, and loading the agent's context.
141
- ```python
142
- class Persistence(ABC):
143
- async def load_and_lock_agent(self, agent_id: str) -> Tuple[Optional[str], Optional[Dict]]: ...
144
- async def save_state(self, agent_id: str, node_name: str, context: Dict, event: Event): ...
145
- ```
146
-
147
- ### `EventBus`
148
- Handles emitting transitions and consuming events in your worker loop.
149
- ```python
150
- class EventBus(ABC):
151
- async def publish(self, event: Event): ...
152
- async def subscribe(self, handler: Callable[[Event], Coroutine]): ...
153
- ```
154
-
155
- ### `Observer` (Optional)
156
- Hooks for integrating OpenTelemetry, Prometheus, or custom logging.
157
- ```python
158
- class Observer(ABC):
159
- async def on_transition(self, agent_id: str, from_node: str, to_node: str, duration_ms: float): ...
160
- async def on_error(self, agent_id: str, node: str, error: Exception): ...
161
- ```
162
-
163
- ---
164
-
165
- ## Design Principles
166
-
167
- 1. **Minimalism**: CommandNet aims to be under 1,000 lines of core code. It does one thing perfectly: reliably transitioning state machines via queue events.
168
- 2. **Stateless Nodes**: Node classes are instantiated fresh on every execution. All mutable state lives exclusively in the Pydantic `Context`.
169
- 3. **No String Magic**: You shouldn't need a massive JSON file or string literals to define your graph. Python's `typing` module is powerful enough. If your IDE can autocomple it, CommandNet can route it.
170
-
171
- ## License
172
-
173
- MIT
174
-
@@ -1,150 +0,0 @@
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 allows you to build durable, asynchronous workflow graphs using strictly typed Python classes and Pydantic models. **CommandNet is not an orchestrator** (no built-in crons, external scheduling, or magic workflow DSLs). Instead, it provides a minimal, dependency-free (except Pydantic) core for executing graph-based logic across distributed workers using any database and message broker you choose.
6
-
7
- ## Features
8
-
9
- - **Strictly Typed Transitions**: Execution graphs are inferred directly from Python type hints (`-> NextNode`). No string-based identifiers.
10
- - **First-Class Pydantic Support**: Context state is automatically serialized to your database and strictly rehydrated into Pydantic models before node execution.
11
- - **Distributed-Worker Ready**: Safely runs across multiple horizontally scaled consumers via row-level locking patterns and idempotency checks.
12
- - **Bring Your Own Infrastructure**: Clean abstract interfaces for `Persistence` (Postgres, SQLite) and `EventBus` (RabbitMQ, NATS, Redis).
13
- - **Zero Magic**: Deterministic execution, highly observable, and easy to test.
14
-
15
- ---
16
-
17
- ## Installation
18
-
19
- ```bash
20
- pip install commandnet
21
- ```
22
- *Or with Poetry:*
23
- ```bash
24
- poetry add commandnet
25
- ```
26
-
27
- ---
28
-
29
- ## Quick Start
30
-
31
- ### 1. Define your State (Context)
32
- Use Pydantic to define the mutable state that will be passed through your graph. CommandNet will automatically validate and rehydrate this data from your database.
33
-
34
- ```python
35
- from pydantic import BaseModel, Field
36
-
37
- class AgentContext(BaseModel):
38
- user_query: str
39
- is_authenticated: bool = False
40
- attempts: int = Field(default=0, ge=0)
41
- ```
42
-
43
- ### 2. Define your Nodes
44
- Nodes subclass `Node` and must implement an `async def run(self, ctx)`. The **return type hint** dictates the execution graph!
45
-
46
- ```python
47
- from typing import Union, Type
48
- from commandnet import Node
49
-
50
- class Denied(Node[AgentContext]):
51
- async def run(self, ctx: AgentContext) -> None: # Returning None means Terminal state
52
- print(f"[{ctx.user_query}] -> Access Denied.")
53
- return None
54
-
55
- class Executing(Node[AgentContext]):
56
- async def run(self, ctx: AgentContext) -> None:
57
- print(f"[{ctx.user_query}] -> Running task successfully!")
58
- return None
59
-
60
- class AuthCheck(Node[AgentContext]):
61
- # The return type explicitly defines the DAG edges:
62
- async def run(self, ctx: AgentContext) -> Union[Type[Executing], Type[Denied]]:
63
- print(f"[{ctx.user_query}] -> Checking Auth...")
64
- ctx.attempts += 1
65
-
66
- if ctx.user_query == "hack_system":
67
- return Denied
68
-
69
- ctx.is_authenticated = True
70
- return Executing
71
- ```
72
-
73
- ### 3. Run the Engine
74
- Implement the `Persistence` and `EventBus` interfaces for your infrastructure (or use in-memory mocks for testing), and trigger the agent.
75
-
76
- ```python
77
- import asyncio
78
- from commandnet import Engine, GraphAnalyzer
79
-
80
- # Note: You must implement Persistence and EventBus interfaces
81
- # See the `interfaces/` directory for expected methods.
82
- from my_app.adapters import PostgresPersistence, RabbitMQBus
83
-
84
- async def main():
85
- # 1. (Optional) Introspect your graph to visualize or validate it
86
- dag = GraphAnalyzer.build_graph(AuthCheck)
87
- print("Graph Structure:", dag)
88
- # Output: {'AuthCheck': ['Executing', 'Denied'], 'Executing': [], 'Denied': []}
89
-
90
- # 2. Initialize Engine
91
- db = PostgresPersistence()
92
- bus = RabbitMQBus()
93
- engine = Engine(persistence=db, event_bus=bus)
94
-
95
- # 3. Start listening to the event queue
96
- await engine.start_worker()
97
-
98
- # 4. Trigger an execution
99
- initial_context = AgentContext(user_query="clean_logs")
100
- await engine.trigger_agent(
101
- agent_id="agent-001",
102
- start_node=AuthCheck,
103
- initial_context=initial_context
104
- )
105
-
106
- if __name__ == "__main__":
107
- asyncio.run(main())
108
- ```
109
-
110
- ---
111
-
112
- ## Pluggable Architecture
113
-
114
- CommandNet forces you to own your infrastructure. You connect it to your stack by implementing three simple interfaces:
115
-
116
- ### `Persistence`
117
- Handles locking, saving, and loading the agent's context.
118
- ```python
119
- class Persistence(ABC):
120
- async def load_and_lock_agent(self, agent_id: str) -> Tuple[Optional[str], Optional[Dict]]: ...
121
- async def save_state(self, agent_id: str, node_name: str, context: Dict, event: Event): ...
122
- ```
123
-
124
- ### `EventBus`
125
- Handles emitting transitions and consuming events in your worker loop.
126
- ```python
127
- class EventBus(ABC):
128
- async def publish(self, event: Event): ...
129
- async def subscribe(self, handler: Callable[[Event], Coroutine]): ...
130
- ```
131
-
132
- ### `Observer` (Optional)
133
- Hooks for integrating OpenTelemetry, Prometheus, or custom logging.
134
- ```python
135
- class Observer(ABC):
136
- async def on_transition(self, agent_id: str, from_node: str, to_node: str, duration_ms: float): ...
137
- async def on_error(self, agent_id: str, node: str, error: Exception): ...
138
- ```
139
-
140
- ---
141
-
142
- ## Design Principles
143
-
144
- 1. **Minimalism**: CommandNet aims to be under 1,000 lines of core code. It does one thing perfectly: reliably transitioning state machines via queue events.
145
- 2. **Stateless Nodes**: Node classes are instantiated fresh on every execution. All mutable state lives exclusively in the Pydantic `Context`.
146
- 3. **No String Magic**: You shouldn't need a massive JSON file or string literals to define your graph. Python's `typing` module is powerful enough. If your IDE can autocomple it, CommandNet can route it.
147
-
148
- ## License
149
-
150
- MIT