loom-core 0.1.4__py3-none-any.whl → 0.1.6__py3-none-any.whl
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.
- loom/core/context.py +39 -0
- loom/core/engine.py +33 -0
- loom/core/runner.py +9 -0
- loom/database/db.py +66 -0
- {loom_core-0.1.4.dist-info → loom_core-0.1.6.dist-info}/METADATA +1 -1
- {loom_core-0.1.4.dist-info → loom_core-0.1.6.dist-info}/RECORD +10 -10
- {loom_core-0.1.4.dist-info → loom_core-0.1.6.dist-info}/WHEEL +0 -0
- {loom_core-0.1.4.dist-info → loom_core-0.1.6.dist-info}/entry_points.txt +0 -0
- {loom_core-0.1.4.dist-info → loom_core-0.1.6.dist-info}/licenses/LICENSE +0 -0
- {loom_core-0.1.4.dist-info → loom_core-0.1.6.dist-info}/top_level.txt +0 -0
loom/core/context.py
CHANGED
|
@@ -82,6 +82,19 @@ class WorkflowContext(Generic[InputT, StateT]):
|
|
|
82
82
|
self.cursor += 1
|
|
83
83
|
return event
|
|
84
84
|
|
|
85
|
+
def _skip_step_events(self) -> None:
|
|
86
|
+
"""Skip over STEP_START and STEP_END events during replay.
|
|
87
|
+
|
|
88
|
+
These are internal workflow management events that don't affect
|
|
89
|
+
the deterministic execution logic.
|
|
90
|
+
"""
|
|
91
|
+
while True:
|
|
92
|
+
event = self._peek()
|
|
93
|
+
if event and event["type"] in ("STEP_START", "STEP_END"):
|
|
94
|
+
self._consume()
|
|
95
|
+
else:
|
|
96
|
+
break
|
|
97
|
+
|
|
85
98
|
def _match_event(self, expected_type: str) -> Event | None:
|
|
86
99
|
"""
|
|
87
100
|
Safe helper to check if the NEXT event matches what we expect.
|
|
@@ -102,6 +115,15 @@ class WorkflowContext(Generic[InputT, StateT]):
|
|
|
102
115
|
"""
|
|
103
116
|
return self.cursor < len(self.history)
|
|
104
117
|
|
|
118
|
+
@property
|
|
119
|
+
def is_at_end_of_history(self) -> bool:
|
|
120
|
+
"""Check if we've consumed all events in history.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
True if we're at the end of event history, False otherwise
|
|
124
|
+
"""
|
|
125
|
+
return self.cursor >= len(self.history)
|
|
126
|
+
|
|
105
127
|
def _extract_activity_metadata[FuncReturn](
|
|
106
128
|
self, fn: Callable[..., Awaitable[FuncReturn]], args: tuple[Any, ...]
|
|
107
129
|
) -> ActivityMetadata:
|
|
@@ -136,6 +158,8 @@ class WorkflowContext(Generic[InputT, StateT]):
|
|
|
136
158
|
) -> FuncReturn:
|
|
137
159
|
metadata = self._extract_activity_metadata(fn, args)
|
|
138
160
|
|
|
161
|
+
# Skip any step events first
|
|
162
|
+
self._skip_step_events()
|
|
139
163
|
scheduled_event = self._match_event("ACTIVITY_SCHEDULED")
|
|
140
164
|
|
|
141
165
|
if scheduled_event:
|
|
@@ -147,6 +171,8 @@ class WorkflowContext(Generic[InputT, StateT]):
|
|
|
147
171
|
|
|
148
172
|
self._consume()
|
|
149
173
|
|
|
174
|
+
# Skip step events before checking for completion
|
|
175
|
+
self._skip_step_events()
|
|
150
176
|
completed_event = self._match_event("ACTIVITY_COMPLETED")
|
|
151
177
|
|
|
152
178
|
if completed_event:
|
|
@@ -155,6 +181,8 @@ class WorkflowContext(Generic[InputT, StateT]):
|
|
|
155
181
|
|
|
156
182
|
raise StopReplay
|
|
157
183
|
|
|
184
|
+
# Skip step events before checking for unexpected events
|
|
185
|
+
self._skip_step_events()
|
|
158
186
|
unexpected_event = self._peek()
|
|
159
187
|
if unexpected_event:
|
|
160
188
|
raise NonDeterministicWorkflowError(
|
|
@@ -180,11 +208,16 @@ class WorkflowContext(Generic[InputT, StateT]):
|
|
|
180
208
|
datetime.datetime.now(datetime.timezone.utc) + delta if delta else until # type: ignore
|
|
181
209
|
)
|
|
182
210
|
|
|
211
|
+
# Skip any step events first
|
|
212
|
+
self._skip_step_events()
|
|
213
|
+
|
|
183
214
|
scheduled_event = self._match_event("TIMER_SCHEDULED")
|
|
184
215
|
|
|
185
216
|
if scheduled_event:
|
|
186
217
|
self._consume()
|
|
187
218
|
|
|
219
|
+
# Skip step events before checking for timer fired
|
|
220
|
+
self._skip_step_events()
|
|
188
221
|
fired_event = self._match_event("TIMER_FIRED")
|
|
189
222
|
if fired_event:
|
|
190
223
|
self._consume()
|
|
@@ -192,6 +225,8 @@ class WorkflowContext(Generic[InputT, StateT]):
|
|
|
192
225
|
|
|
193
226
|
raise StopReplay
|
|
194
227
|
|
|
228
|
+
# Skip step events before checking for unexpected events
|
|
229
|
+
self._skip_step_events()
|
|
195
230
|
unexpected_event = self._peek()
|
|
196
231
|
if unexpected_event:
|
|
197
232
|
raise NonDeterministicWorkflowError(
|
|
@@ -210,6 +245,8 @@ class WorkflowContext(Generic[InputT, StateT]):
|
|
|
210
245
|
If the signal is already in history (replay), it returns the data immediately.
|
|
211
246
|
If not, it raises StopReplay to suspend execution until the signal arrives.
|
|
212
247
|
"""
|
|
248
|
+
# Skip any step events first
|
|
249
|
+
self._skip_step_events()
|
|
213
250
|
# 1. Check if the signal is next in history
|
|
214
251
|
event = self._match_event("SIGNAL_RECEIVED")
|
|
215
252
|
|
|
@@ -226,6 +263,8 @@ class WorkflowContext(Generic[InputT, StateT]):
|
|
|
226
263
|
self._consume()
|
|
227
264
|
return event["payload"]["data"]
|
|
228
265
|
|
|
266
|
+
# Skip step events before checking for unexpected events
|
|
267
|
+
self._skip_step_events()
|
|
229
268
|
unexpected_event = self._peek()
|
|
230
269
|
if unexpected_event:
|
|
231
270
|
raise NonDeterministicWorkflowError(
|
loom/core/engine.py
CHANGED
|
@@ -93,9 +93,42 @@ class Engine(Generic[InputT, StateT]):
|
|
|
93
93
|
|
|
94
94
|
try:
|
|
95
95
|
for step in steps:
|
|
96
|
+
# Check for and consume STEP_START event during replay
|
|
97
|
+
step_start_event = ctx._match_event("STEP_START")
|
|
98
|
+
if step_start_event:
|
|
99
|
+
if step_start_event["payload"]["step_name"] != step["name"]:
|
|
100
|
+
raise StopReplay # Step mismatch, something changed
|
|
101
|
+
ctx._consume()
|
|
102
|
+
else:
|
|
103
|
+
# Not replaying, emit STEP_START event
|
|
104
|
+
if not ctx.is_replaying:
|
|
105
|
+
await ctx._append_event(
|
|
106
|
+
"STEP_START",
|
|
107
|
+
{
|
|
108
|
+
"step_name": step["name"],
|
|
109
|
+
"step_fn": step["fn"],
|
|
110
|
+
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
|
|
96
114
|
step_fn = getattr(workflow, step["fn"])
|
|
97
115
|
await step_fn(ctx)
|
|
98
116
|
|
|
117
|
+
# Check for and consume STEP_END event during replay
|
|
118
|
+
step_end_event = ctx._match_event("STEP_END")
|
|
119
|
+
if step_end_event:
|
|
120
|
+
ctx._consume()
|
|
121
|
+
else:
|
|
122
|
+
# Not replaying, emit STEP_END event
|
|
123
|
+
if not ctx.is_replaying:
|
|
124
|
+
await ctx._append_event(
|
|
125
|
+
"STEP_END",
|
|
126
|
+
{
|
|
127
|
+
"step_name": step["name"],
|
|
128
|
+
"completed_at": datetime.now(timezone.utc).isoformat(),
|
|
129
|
+
},
|
|
130
|
+
)
|
|
131
|
+
|
|
99
132
|
except StopReplay:
|
|
100
133
|
last = ctx.last_emitted_event_type()
|
|
101
134
|
if last in ("STATE_SET", "STATE_UPDATE"):
|
loom/core/runner.py
CHANGED
|
@@ -50,4 +50,13 @@ async def run_once() -> bool:
|
|
|
50
50
|
except Exception as e:
|
|
51
51
|
traceback.print_exc()
|
|
52
52
|
await db.task_failed(task["id"], str(e))
|
|
53
|
+
|
|
54
|
+
# Mark workflow as failed when unhandled exception occurs
|
|
55
|
+
await db.workflow_failed(
|
|
56
|
+
workflow_id=workflow_id,
|
|
57
|
+
error=str(e),
|
|
58
|
+
task_id=task["id"],
|
|
59
|
+
task_kind=task["kind"],
|
|
60
|
+
)
|
|
61
|
+
|
|
53
62
|
return True
|
loom/database/db.py
CHANGED
|
@@ -344,6 +344,72 @@ class Database(Generic[InputT, StateT]):
|
|
|
344
344
|
(workflow_id, json.dumps(signal_payload)),
|
|
345
345
|
)
|
|
346
346
|
|
|
347
|
+
async def workflow_failed(
|
|
348
|
+
self, workflow_id: str, error: str, task_id: str = None, task_kind: str = None
|
|
349
|
+
) -> None:
|
|
350
|
+
"""Mark a workflow as failed due to an unhandled exception.
|
|
351
|
+
|
|
352
|
+
Creates a WORKFLOW_FAILED event and updates the workflow status.
|
|
353
|
+
Also cancels any remaining pending tasks for the workflow.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
workflow_id: Workflow identifier to mark as failed
|
|
357
|
+
error: Error message describing the failure
|
|
358
|
+
task_id: Optional task ID that caused the failure
|
|
359
|
+
task_kind: Optional task kind that caused the failure
|
|
360
|
+
|
|
361
|
+
Raises:
|
|
362
|
+
WorkflowNotFoundError: If the workflow doesn't exist
|
|
363
|
+
"""
|
|
364
|
+
# Get workflow info (this will raise WorkflowNotFoundError if not found)
|
|
365
|
+
workflow = await self.get_workflow_info(workflow_id)
|
|
366
|
+
|
|
367
|
+
# Skip if already in terminal state
|
|
368
|
+
if workflow["status"] in ("COMPLETED", "FAILED", "CANCELED"):
|
|
369
|
+
return
|
|
370
|
+
|
|
371
|
+
# Prepare failure payload
|
|
372
|
+
payload = {
|
|
373
|
+
"error": error,
|
|
374
|
+
"failed_at": datetime.now(timezone.utc).isoformat(),
|
|
375
|
+
}
|
|
376
|
+
if task_id:
|
|
377
|
+
payload["task_id"] = task_id
|
|
378
|
+
if task_kind:
|
|
379
|
+
payload["task_kind"] = task_kind
|
|
380
|
+
|
|
381
|
+
# Create failure event
|
|
382
|
+
await self.execute(
|
|
383
|
+
"""
|
|
384
|
+
INSERT INTO events (workflow_id, type, payload)
|
|
385
|
+
VALUES (?, 'WORKFLOW_FAILED', ?)
|
|
386
|
+
""",
|
|
387
|
+
(workflow_id, json.dumps(payload)),
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
# Update workflow status
|
|
391
|
+
await self.execute(
|
|
392
|
+
"""
|
|
393
|
+
UPDATE workflows
|
|
394
|
+
SET status = 'FAILED',
|
|
395
|
+
updated_at = CURRENT_TIMESTAMP
|
|
396
|
+
WHERE id = ?
|
|
397
|
+
""",
|
|
398
|
+
(workflow_id,),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
# Cancel all pending tasks
|
|
402
|
+
await self.execute(
|
|
403
|
+
"""
|
|
404
|
+
UPDATE tasks
|
|
405
|
+
SET status = 'FAILED',
|
|
406
|
+
last_error = 'workflow failed'
|
|
407
|
+
WHERE workflow_id = ?
|
|
408
|
+
AND status = 'PENDING'
|
|
409
|
+
""",
|
|
410
|
+
(workflow_id,),
|
|
411
|
+
)
|
|
412
|
+
|
|
347
413
|
async def cancel_workflow(
|
|
348
414
|
self, workflow_id: str, reason: str | None = None
|
|
349
415
|
) -> None:
|
|
@@ -7,16 +7,16 @@ loom/common/errors.py,sha256=jc7_XS8dUPS8fg5pb7kJaTuIR1e6eKBZyZtbsHW3lgM,1712
|
|
|
7
7
|
loom/common/workflow.py,sha256=R4X1vt6duqTJz6kGk6YI866O-CleOiyw0dXdUVo_qAE,2118
|
|
8
8
|
loom/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
9
|
loom/core/compiled.py,sha256=Y2NQDMZVLee65ILgJoK2Ny_OcR1T4LJnOQWQcyg0Z7U,1178
|
|
10
|
-
loom/core/context.py,sha256=
|
|
11
|
-
loom/core/engine.py,sha256=
|
|
10
|
+
loom/core/context.py,sha256=jxmaA_4Xdmi5FPxnXi3VWNPtyjMbdCKYi4j-Ju6Hi_g,10676
|
|
11
|
+
loom/core/engine.py,sha256=6EvacrZCfVhJQ7yPurRdCeTzlGRaGk5uI-s7yxu9IUs,5822
|
|
12
12
|
loom/core/handle.py,sha256=beOVmJit-gSrk7II4c2KuSubmr5OIPls82_vJ-pI04U,5532
|
|
13
13
|
loom/core/logger.py,sha256=4ej95IdHOdBh-y7PQoRFz_H22PJYDXvAioBoId8mOhg,1729
|
|
14
|
-
loom/core/runner.py,sha256=
|
|
14
|
+
loom/core/runner.py,sha256=nNA-EcdJt3UEgwzKHyb22xD7aLT8kI01x5UVdLteSI8,1998
|
|
15
15
|
loom/core/state.py,sha256=oXiZ442PL2kl_JyQXLZOFeBLs_cTF2iEjykKVVUNiAI,2997
|
|
16
16
|
loom/core/worker.py,sha256=jpTtvM1rIToVkG4SwJu1W9Q-eL3bw8b3N8KsxxrRovA,5322
|
|
17
17
|
loom/core/workflow.py,sha256=E_A2rI6iaW3liZmVy3X98oxSeBDXMGiQnRZRq3Cp3BA,5755
|
|
18
18
|
loom/database/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
|
-
loom/database/db.py,sha256=
|
|
19
|
+
loom/database/db.py,sha256=MSNXWhUzfwvjPXaVWY3-UK1lqw-Qas0Cc1FYcU8qdWw,25700
|
|
20
20
|
loom/decorators/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
21
|
loom/decorators/activity.py,sha256=xt7oeYOKJxhKIEcwWsSVA26c_jk4yQB1PHtbcg3tCFI,4824
|
|
22
22
|
loom/decorators/workflow.py,sha256=rBpbnpLfDTTAWdgiERpUPIgNUJpnYJKvBZPmzx-ECcc,1516
|
|
@@ -42,9 +42,9 @@ loom/schemas/database.py,sha256=DlMBvacsJ9oceBHkrMiJC7uD3_rCHFe6Kwa2STFcKOw,283
|
|
|
42
42
|
loom/schemas/events.py,sha256=Gz-R836nVXNSsp4Y54idhKs_WTvzFPmx5KlA8PunP28,2216
|
|
43
43
|
loom/schemas/tasks.py,sha256=DJbLInggIGxI-CfP1kSyK79Bz83e3sZyEKh6C2HE8q4,1341
|
|
44
44
|
loom/schemas/workflow.py,sha256=F1cNEJRuEWLjPhGinEZE2T6vwchDC8RuTHmxEIaH6Ls,671
|
|
45
|
-
loom_core-0.1.
|
|
46
|
-
loom_core-0.1.
|
|
47
|
-
loom_core-0.1.
|
|
48
|
-
loom_core-0.1.
|
|
49
|
-
loom_core-0.1.
|
|
50
|
-
loom_core-0.1.
|
|
45
|
+
loom_core-0.1.6.dist-info/licenses/LICENSE,sha256=8EpC-clAYRUfJQ92T3iQEIIWYjx2A3Kfk28zOd8lh7I,1095
|
|
46
|
+
loom_core-0.1.6.dist-info/METADATA,sha256=L3FvzaOTy5AXGihpTZcxGeRfXTl4KKGiaYx3uKnCL2k,8897
|
|
47
|
+
loom_core-0.1.6.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
48
|
+
loom_core-0.1.6.dist-info/entry_points.txt,sha256=Jx5HXHL2y7jvSjkwkH3QqF954cbSxiE6OGwL2coldyE,42
|
|
49
|
+
loom_core-0.1.6.dist-info/top_level.txt,sha256=cAfRgAuCuit-cU9iBrf0bS4ovvmq-URykNd9fmYMojg,5
|
|
50
|
+
loom_core-0.1.6.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|