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.
- {commandnet-0.4.2 → commandnet-0.5.1}/PKG-INFO +1 -1
- {commandnet-0.4.2 → commandnet-0.5.1}/commandnet/core/models.py +0 -1
- {commandnet-0.4.2 → commandnet-0.5.1}/commandnet/engine/runtime.py +69 -28
- {commandnet-0.4.2 → commandnet-0.5.1}/pyproject.toml +1 -1
- {commandnet-0.4.2 → commandnet-0.5.1}/README.md +0 -0
- {commandnet-0.4.2 → commandnet-0.5.1}/commandnet/__init__.py +0 -0
- {commandnet-0.4.2 → commandnet-0.5.1}/commandnet/core/graph.py +0 -0
- {commandnet-0.4.2 → commandnet-0.5.1}/commandnet/core/node.py +0 -0
- {commandnet-0.4.2 → commandnet-0.5.1}/commandnet/interfaces/event_bus.py +0 -0
- {commandnet-0.4.2 → commandnet-0.5.1}/commandnet/interfaces/observer.py +0 -0
- {commandnet-0.4.2 → commandnet-0.5.1}/commandnet/interfaces/persistence.py +0 -0
|
@@ -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
|
-
|
|
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
|
-
#
|
|
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(
|
|
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,
|
|
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 =
|
|
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
|
|
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=
|
|
289
|
+
payload=branch_payload,
|
|
262
290
|
)
|
|
263
291
|
|
|
264
292
|
if target.join_node:
|
|
265
293
|
await self.db.save_state(
|
|
266
|
-
subject_id,
|
|
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)
|
|
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
|
-
|
|
289
|
-
if hasattr(
|
|
290
|
-
else
|
|
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,
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|