loom-core 0.1.3__tar.gz → 0.1.5__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.
- {loom_core-0.1.3 → loom_core-0.1.5}/PKG-INFO +1 -1
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/cli/cli.py +1 -1
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/common/activity.py +6 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/common/workflow.py +6 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/core/context.py +9 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/core/engine.py +36 -1
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/core/runner.py +9 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/core/state.py +1 -1
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/database/db.py +66 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom_core.egg-info/PKG-INFO +1 -1
- {loom_core-0.1.3 → loom_core-0.1.5}/pyproject.toml +1 -1
- {loom_core-0.1.3 → loom_core-0.1.5}/LICENSE +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/MANIFEST.in +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/QUICKSTART.md +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/README.md +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/__init__.py +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/cli/__init__.py +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/common/config.py +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/common/errors.py +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/core/__init__.py +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/core/compiled.py +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/core/handle.py +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/core/logger.py +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/core/worker.py +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/core/workflow.py +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/database/__init__.py +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/decorators/__init__.py +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/decorators/activity.py +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/decorators/workflow.py +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/lib/progress.py +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/lib/utils.py +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/migrations/down/001_setup_pragma.sql +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/migrations/down/002_create_workflows.sql +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/migrations/down/003.create_events.sql +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/migrations/down/004.create_tasks.sql +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/migrations/down/005.create_indexes.sql +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/migrations/down/006_auto_update_triggers.sql +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/migrations/down/007_create_logs.sql +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/migrations/up/001_setup_pragma.sql +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/migrations/up/002_create_workflows.sql +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/migrations/up/003_create_events.sql +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/migrations/up/004_create_tasks.sql +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/migrations/up/005_create_indexes.sql +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/migrations/up/006_auto_update_triggers.sql +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/migrations/up/007_create_logs.sql +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/schemas/__init__.py +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/schemas/activity.py +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/schemas/database.py +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/schemas/events.py +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/schemas/tasks.py +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom/schemas/workflow.py +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom_core.egg-info/SOURCES.txt +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom_core.egg-info/dependency_links.txt +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom_core.egg-info/entry_points.txt +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom_core.egg-info/requires.txt +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/loom_core.egg-info/top_level.txt +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/setup.cfg +0 -0
- {loom_core-0.1.3 → loom_core-0.1.5}/setup.py +0 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import importlib
|
|
2
|
+
import sys
|
|
2
3
|
from typing import Any, Awaitable, Callable, cast
|
|
3
4
|
|
|
4
5
|
|
|
@@ -6,6 +7,11 @@ def load_activity(module: str, func: str) -> Callable[..., Awaitable[Any]]:
|
|
|
6
7
|
|
|
7
8
|
try:
|
|
8
9
|
activity_module = importlib.import_module(module)
|
|
10
|
+
|
|
11
|
+
# Force reload the module to get latest changes
|
|
12
|
+
if module in sys.modules:
|
|
13
|
+
activity_module = importlib.reload(activity_module)
|
|
14
|
+
|
|
9
15
|
except ModuleNotFoundError as e:
|
|
10
16
|
raise ModuleNotFoundError(
|
|
11
17
|
f"Cannot import activity module '{module}'. "
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import importlib
|
|
2
|
+
import sys
|
|
2
3
|
from typing import Any, Type
|
|
3
4
|
|
|
4
5
|
from ..core.workflow import Workflow
|
|
@@ -32,6 +33,11 @@ def workflow_registry(module: str, cls: str) -> Type[Workflow[Any, Any]]:
|
|
|
32
33
|
"""
|
|
33
34
|
try:
|
|
34
35
|
workflow_module = importlib.import_module(module)
|
|
36
|
+
|
|
37
|
+
# Force reload the module to get latest changes
|
|
38
|
+
if module in sys.modules:
|
|
39
|
+
workflow_module = importlib.reload(workflow_module)
|
|
40
|
+
|
|
35
41
|
except ModuleNotFoundError as e:
|
|
36
42
|
raise ModuleNotFoundError(
|
|
37
43
|
f"Cannot import workflow module '{module}'. "
|
|
@@ -102,6 +102,15 @@ class WorkflowContext(Generic[InputT, StateT]):
|
|
|
102
102
|
"""
|
|
103
103
|
return self.cursor < len(self.history)
|
|
104
104
|
|
|
105
|
+
@property
|
|
106
|
+
def is_at_end_of_history(self) -> bool:
|
|
107
|
+
"""Check if we've consumed all events in history.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
True if we're at the end of event history, False otherwise
|
|
111
|
+
"""
|
|
112
|
+
return self.cursor >= len(self.history)
|
|
113
|
+
|
|
105
114
|
def _extract_activity_metadata[FuncReturn](
|
|
106
115
|
self, fn: Callable[..., Awaitable[FuncReturn]], args: tuple[Any, ...]
|
|
107
116
|
) -> ActivityMetadata:
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import json
|
|
1
2
|
from datetime import datetime, timedelta, timezone
|
|
2
3
|
from typing import Generic
|
|
3
4
|
|
|
@@ -76,9 +77,10 @@ class Engine(Generic[InputT, StateT]):
|
|
|
76
77
|
async with Database[InputT, StateT]() as db:
|
|
77
78
|
workflow_def = await db.get_workflow_info(workflow_id)
|
|
78
79
|
history = await db.get_workflow_events(workflow_id=workflow_def["id"])
|
|
80
|
+
workflow_input = json.loads(workflow_def["input"])
|
|
79
81
|
|
|
80
82
|
ctx: WorkflowContext = WorkflowContext(
|
|
81
|
-
workflow_def["id"],
|
|
83
|
+
workflow_def["id"], workflow_input, history, {}
|
|
82
84
|
)
|
|
83
85
|
|
|
84
86
|
first_event = ctx._peek()
|
|
@@ -91,9 +93,42 @@ class Engine(Generic[InputT, StateT]):
|
|
|
91
93
|
|
|
92
94
|
try:
|
|
93
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
|
+
|
|
94
114
|
step_fn = getattr(workflow, step["fn"])
|
|
95
115
|
await step_fn(ctx)
|
|
96
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
|
+
|
|
97
132
|
except StopReplay:
|
|
98
133
|
last = ctx.last_emitted_event_type()
|
|
99
134
|
if last in ("STATE_SET", "STATE_UPDATE"):
|
|
@@ -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
|
|
@@ -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:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|