commandnet 0.5.0__tar.gz → 0.6.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: commandnet
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: A lightweight, Pydantic-powered, distributed event-driven state machine and typed node graph runtime.
5
5
  Author: Christopher Vaz
6
6
  Author-email: christophervaz160@gmail.com
@@ -1,5 +1,5 @@
1
1
  from .core.models import Event
2
- from .core.node import Node, Parallel, ParallelTask, Schedule, Wait, Call, Interrupt
2
+ from .core.node import Node, Parallel, ParallelTask, Schedule, Wait, Call, Interrupt, Transition
3
3
  from .core.graph import GraphAnalyzer
4
4
  from .interfaces.persistence import Persistence
5
5
  from .interfaces.event_bus import EventBus
@@ -9,6 +9,6 @@ from .engine.runtime import Engine
9
9
  __all__ = [
10
10
  "Event", "Node", "Parallel", "ParallelTask", "GraphAnalyzer",
11
11
  "Persistence", "EventBus", "Observer", "Engine", "Schedule",
12
- "Call", "Interrupt",
12
+ "Call", "Interrupt", "Transition",
13
13
  ]
14
14
 
@@ -5,7 +5,7 @@ C = TypeVar('C', bound=BaseModel) # Context
5
5
  P = TypeVar('P', bound=BaseModel) # Payload
6
6
 
7
7
  # The Recursive Type Definition
8
- Target = Union[Type['Node'], 'Parallel', 'Schedule', 'Wait', 'Call', 'Interrupt', None]
8
+ Target = Union[Type['Node'], 'Parallel', 'Schedule', 'Wait', 'Call', 'Interrupt', 'Transition', None]
9
9
 
10
10
  class ParallelTask(BaseModel):
11
11
  model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -31,6 +31,11 @@ class Wait(BaseModel):
31
31
  resume_action: Target
32
32
  sub_context_path: Optional[str] = None
33
33
 
34
+ class Transition(BaseModel):
35
+ model_config = ConfigDict(arbitrary_types_allowed=True)
36
+ node_cls: Type['Node']
37
+ payload: Optional[Any] = None
38
+
34
39
  class Call(BaseModel):
35
40
  """The 'Await' type: Deduplicates execution based on an idempotency key."""
36
41
  model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -8,6 +8,7 @@ from ..core.node import (
8
8
  Node,
9
9
  Parallel,
10
10
  Schedule,
11
+ Transition,
11
12
  Wait,
12
13
  Target,
13
14
  ParallelTask,
@@ -70,8 +71,10 @@ class Engine:
70
71
 
71
72
  async def _run_node_logic(self, event: Event):
72
73
  subject_id = event.subject_id
73
- # We allow "AWAITING_CALL" because an subject wakes up from that state when a 'Call' resolves
74
+
75
+ # 1. READ & LOCK
74
76
  node_name, ctx_dict = await self.db.lock_and_load(subject_id)
77
+
75
78
  if not node_name or (
76
79
  node_name != event.node_name and node_name != "AWAITING_CALL"
77
80
  ):
@@ -92,33 +95,42 @@ class Engine:
92
95
  if hasattr(ctx_type, "model_validate")
93
96
  else ctx_dict
94
97
  )
98
+
95
99
  payload = (
96
100
  payload_type.model_validate(event.payload)
97
101
  if (event.payload and hasattr(payload_type, "model_validate"))
98
102
  else event.payload
99
103
  )
100
104
 
101
- # Support Soft-Cancel checking inside Node.run
105
+ # Soft cancel check BEFORE compute
102
106
  if hasattr(ctx, "is_cancelled"):
103
107
  ctx.is_cancelled = await self.db.is_cancelled(subject_id)
104
108
 
109
+ # 2. RELEASE LOCK before long-running compute
110
+ await self.db.unlock_subject(subject_id)
111
+
112
+ # 3. COMPUTE (no DB lock held)
105
113
  start_t = asyncio.get_event_loop().time()
106
114
  result = await node_cls().run(ctx, payload)
115
+ duration = (asyncio.get_event_loop().time() - start_t) * 1000
116
+
117
+ # 4. RE-LOCK before writing
118
+ node_name_check, _ = await self.db.lock_and_load(subject_id)
119
+
120
+ # 🔴 IMPORTANT SAFETY CHECK (prevents stale writes)
121
+ if node_name_check != event.node_name and node_name_check != "AWAITING_CALL":
122
+ await self.db.unlock_subject(subject_id)
123
+ return
124
+
125
+ await self._apply_target(subject_id, ctx, result, duration)
107
126
 
108
- await self._apply_target(
109
- subject_id,
110
- ctx,
111
- result,
112
- (asyncio.get_event_loop().time() - start_t) * 1000,
113
- )
114
127
  except Exception as e:
115
128
  await self.observer.on_error(subject_id, event.node_name, e)
116
129
  raise
117
130
  finally:
131
+ # Ensure we never leave a lock hanging
118
132
  await self.db.unlock_subject(subject_id)
119
133
 
120
- # --- RECURSIVE TARGET RESOLVER ---
121
-
122
134
  async def _apply_target(
123
135
  self,
124
136
  subject_id: str,
@@ -335,6 +347,20 @@ class Engine:
335
347
  None,
336
348
  )
337
349
  return
350
+ if isinstance(target, Transition):
351
+ node_name = target.node_cls.get_node_name()
352
+ await self.observer.on_transition(subject_id, "RUN", node_name, duration)
353
+
354
+ # Use the transition's payload, or fallback to the current one
355
+ final_payload = target.payload if target.payload is not None else payload
356
+ p_load = final_payload.model_dump() if hasattr(final_payload, "model_dump") else final_payload
357
+
358
+ evt = Event(subject_id=subject_id, node_name=node_name, payload=p_load)
359
+ ctx_dict = self._dump_ctx(context)
360
+
361
+ await self.db.save_state(subject_id, node_name, ctx_dict, evt)
362
+ await self.bus.publish(evt)
363
+ return
338
364
  # --- EXTERNAL CONTROL & SIGNALS ---
339
365
 
340
366
  async def signal_node(self, subject_id: str, signal_id: str, payload: Any = None):
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "commandnet"
3
- version = "0.5.0"
3
+ version = "0.6.0"
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" }
File without changes