commandnet 0.4.2__tar.gz → 0.5.1__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.4.2
3
+ Version: 0.5.1
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
@@ -11,7 +11,6 @@ class Event(BaseModel):
11
11
  subject_id: str
12
12
  node_name: str
13
13
  payload: Optional[Dict[str, Any]] = None
14
- headers: Dict[str, str] = Field(default_factory=dict)
15
14
 
16
15
  timestamp: str = Field(default_factory=utcnow_iso)
17
16
  run_at: str = Field(default_factory=utcnow_iso)
@@ -70,8 +70,10 @@ class Engine:
70
70
 
71
71
  async def _run_node_logic(self, event: Event):
72
72
  subject_id = event.subject_id
73
- # We allow "AWAITING_CALL" because an subject wakes up from that state when a 'Call' resolves
73
+
74
+ # 1. READ & LOCK
74
75
  node_name, ctx_dict = await self.db.lock_and_load(subject_id)
76
+
75
77
  if not node_name or (
76
78
  node_name != event.node_name and node_name != "AWAITING_CALL"
77
79
  ):
@@ -92,33 +94,42 @@ class Engine:
92
94
  if hasattr(ctx_type, "model_validate")
93
95
  else ctx_dict
94
96
  )
97
+
95
98
  payload = (
96
99
  payload_type.model_validate(event.payload)
97
100
  if (event.payload and hasattr(payload_type, "model_validate"))
98
101
  else event.payload
99
102
  )
100
103
 
101
- # Support Soft-Cancel checking inside Node.run
104
+ # Soft cancel check BEFORE compute
102
105
  if hasattr(ctx, "is_cancelled"):
103
106
  ctx.is_cancelled = await self.db.is_cancelled(subject_id)
104
107
 
108
+ # 2. RELEASE LOCK before long-running compute
109
+ await self.db.unlock_subject(subject_id)
110
+
111
+ # 3. COMPUTE (no DB lock held)
105
112
  start_t = asyncio.get_event_loop().time()
106
113
  result = await node_cls().run(ctx, payload)
114
+ duration = (asyncio.get_event_loop().time() - start_t) * 1000
115
+
116
+ # 4. RE-LOCK before writing
117
+ node_name_check, _ = await self.db.lock_and_load(subject_id)
118
+
119
+ # 🔴 IMPORTANT SAFETY CHECK (prevents stale writes)
120
+ if node_name_check != event.node_name and node_name_check != "AWAITING_CALL":
121
+ await self.db.unlock_subject(subject_id)
122
+ return
123
+
124
+ await self._apply_target(subject_id, ctx, result, duration)
107
125
 
108
- await self._apply_target(
109
- subject_id,
110
- ctx,
111
- result,
112
- (asyncio.get_event_loop().time() - start_t) * 1000,
113
- )
114
126
  except Exception as e:
115
127
  await self.observer.on_error(subject_id, event.node_name, e)
116
128
  raise
117
129
  finally:
130
+ # Ensure we never leave a lock hanging
118
131
  await self.db.unlock_subject(subject_id)
119
132
 
120
- # --- RECURSIVE TARGET RESOLVER ---
121
-
122
133
  async def _apply_target(
123
134
  self,
124
135
  subject_id: str,
@@ -127,9 +138,6 @@ class Engine:
127
138
  duration: float = 0.0,
128
139
  payload: Any = None,
129
140
  ):
130
- headers = {}
131
- if hasattr(context, "trace_headers") and context.trace_headers:
132
- headers = context.trace_headers
133
141
  # 1. Interrupt (Cancellation)
134
142
  if isinstance(target, Interrupt):
135
143
  await self.cancel_subject(target.subject_id, target.hard)
@@ -168,7 +176,6 @@ class Engine:
168
176
  key = subject_id.split("#")[1]
169
177
  waiters = await self.db.resolve_call_group(key)
170
178
  for w in waiters:
171
- # Resume waiters using the final context of the shared node as payload
172
179
  await self._apply_target(
173
180
  w["subject_id"],
174
181
  w["context"],
@@ -194,14 +201,23 @@ class Engine:
194
201
  node_name = target.get_node_name()
195
202
  await self.observer.on_transition(subject_id, "RUN", node_name, duration)
196
203
 
204
+ # PROPAGATION: Use provided payload
197
205
  p_load = payload.model_dump() if hasattr(payload, "model_dump") else payload
198
206
 
199
- evt = Event(subject_id=subject_id, node_name=node_name, payload=p_load, headers=headers)
207
+ evt = Event(
208
+ subject_id=subject_id,
209
+ node_name=node_name,
210
+ payload=p_load,
211
+ )
200
212
  ctx_dict = self._dump_ctx(context)
201
213
 
202
214
  if "#" in subject_id:
203
215
  await self.db.save_sub_state(
204
- subject_id, subject_id.split("#")[0], node_name, ctx_dict, evt
216
+ subject_id,
217
+ subject_id.split("#")[0],
218
+ node_name,
219
+ ctx_dict,
220
+ evt,
205
221
  )
206
222
  else:
207
223
  await self.db.save_state(subject_id, node_name, ctx_dict, evt)
@@ -213,6 +229,7 @@ class Engine:
213
229
  if isinstance(target, Wait):
214
230
  actual_id = subject_id
215
231
  actual_ctx = context
232
+
216
233
  if target.sub_context_path and "#" not in subject_id:
217
234
  actual_id = f"{subject_id}#{target.sub_context_path}"
218
235
  actual_ctx = self._get_path(context, target.sub_context_path)
@@ -220,6 +237,7 @@ class Engine:
220
237
  await self.observer.on_transition(
221
238
  actual_id, "RUN", f"WAIT:{target.signal_id}", duration
222
239
  )
240
+
223
241
  await self.db.park_subject(
224
242
  actual_id,
225
243
  target.signal_id,
@@ -230,7 +248,12 @@ class Engine:
230
248
 
231
249
  # 6. Parallel Fan-out
232
250
  if isinstance(target, Parallel):
233
- join_name = target.join_node.get_node_name() if target.join_node else "FORK"
251
+ join_name = (
252
+ target.join_node.get_node_name()
253
+ if target.join_node
254
+ else "FORK"
255
+ )
256
+
234
257
  await self.observer.on_transition(
235
258
  subject_id, "RUN", f"PARALLEL:{join_name}", duration
236
259
  )
@@ -241,11 +264,10 @@ class Engine:
241
264
  )
242
265
 
243
266
  for branch in target.branches:
244
- # Normalize branch to ParallelTask without stripping Wait wrappers
267
+ # Normalize branch into ParallelTask
245
268
  if isinstance(branch, ParallelTask):
246
269
  task = branch
247
270
  else:
248
- # If it's a Wait or a Node class, wrap it but keep the action intact
249
271
  path = (
250
272
  branch.sub_context_path
251
273
  if isinstance(branch, Wait) and branch.sub_context_path
@@ -253,30 +275,42 @@ class Engine:
253
275
  )
254
276
  task = ParallelTask(action=branch, sub_context_path=path)
255
277
 
278
+ # PROPAGATION: fallback to incoming payload if branch has none
279
+ branch_payload = (
280
+ task.payload if task.payload is not None else payload
281
+ )
282
+
256
283
  sub_ctx = self._get_path(context, task.sub_context_path)
284
+
257
285
  await self._apply_target(
258
286
  f"{subject_id}#{task.sub_context_path}",
259
287
  sub_ctx,
260
288
  task.action,
261
- payload=task.payload,
289
+ payload=branch_payload,
262
290
  )
263
291
 
264
292
  if target.join_node:
265
293
  await self.db.save_state(
266
- subject_id, "WAITING_FOR_JOIN", self._dump_ctx(context), None
294
+ subject_id,
295
+ "WAITING_FOR_JOIN",
296
+ self._dump_ctx(context),
297
+ None,
267
298
  )
268
299
  else:
269
300
  await self._apply_target(subject_id, context, None)
301
+
270
302
  return
271
303
 
272
304
  # 7. Schedule (Delayed Execution)
273
305
  if isinstance(target, Schedule):
274
306
  if not (
275
- isinstance(target.action, type) and issubclass(target.action, Node)
307
+ isinstance(target.action, type)
308
+ and issubclass(target.action, Node)
276
309
  ):
277
310
  raise TypeError("Schedule.action must be a Node class.")
278
311
 
279
312
  target_node_name = target.action.get_node_name()
313
+
280
314
  await self.observer.on_transition(
281
315
  subject_id, "RUN", f"SCHEDULED:{target_node_name}", duration
282
316
  )
@@ -284,10 +318,16 @@ class Engine:
284
318
  run_at_dt = datetime.now(timezone.utc) + timedelta(
285
319
  seconds=target.delay_seconds
286
320
  )
321
+
322
+ # PROPAGATION: fallback to incoming payload if schedule payload is missing
323
+ final_payload = (
324
+ target.payload if target.payload is not None else payload
325
+ )
326
+
287
327
  p_load = (
288
- target.payload.model_dump()
289
- if hasattr(target.payload, "model_dump")
290
- else target.payload
328
+ final_payload.model_dump()
329
+ if hasattr(final_payload, "model_dump")
330
+ else final_payload
291
331
  )
292
332
 
293
333
  scheduled_evt = Event(
@@ -296,15 +336,16 @@ class Engine:
296
336
  payload=p_load,
297
337
  run_at=run_at_dt.isoformat(),
298
338
  idempotency_key=target.idempotency_key,
299
- headers=headers,
300
339
  )
301
340
 
302
341
  await self.db.schedule_event(scheduled_evt)
303
342
  await self.db.save_state(
304
- subject_id, target_node_name, self._dump_ctx(context), None
343
+ subject_id,
344
+ target_node_name,
345
+ self._dump_ctx(context),
346
+ None,
305
347
  )
306
348
  return
307
-
308
349
  # --- EXTERNAL CONTROL & SIGNALS ---
309
350
 
310
351
  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.4.2"
3
+ version = "0.5.1"
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