loom-core 0.1.0__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.
Files changed (50) hide show
  1. loom_core-0.1.0.dist-info/METADATA +342 -0
  2. loom_core-0.1.0.dist-info/RECORD +50 -0
  3. loom_core-0.1.0.dist-info/WHEEL +5 -0
  4. loom_core-0.1.0.dist-info/entry_points.txt +2 -0
  5. loom_core-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. loom_core-0.1.0.dist-info/top_level.txt +1 -0
  7. src/__init__.py +45 -0
  8. src/cli/__init__.py +5 -0
  9. src/cli/cli.py +246 -0
  10. src/common/activity.py +30 -0
  11. src/common/config.py +9 -0
  12. src/common/errors.py +64 -0
  13. src/common/workflow.py +56 -0
  14. src/core/__init__.py +0 -0
  15. src/core/compiled.py +41 -0
  16. src/core/context.py +256 -0
  17. src/core/engine.py +106 -0
  18. src/core/handle.py +166 -0
  19. src/core/logger.py +60 -0
  20. src/core/runner.py +53 -0
  21. src/core/state.py +96 -0
  22. src/core/worker.py +147 -0
  23. src/core/workflow.py +168 -0
  24. src/database/__init__.py +0 -0
  25. src/database/db.py +716 -0
  26. src/decorators/__init__.py +0 -0
  27. src/decorators/activity.py +126 -0
  28. src/decorators/workflow.py +46 -0
  29. src/lib/progress.py +109 -0
  30. src/lib/utils.py +25 -0
  31. src/migrations/down/001_setup_pragma.sql +5 -0
  32. src/migrations/down/002_create_workflows.sql +3 -0
  33. src/migrations/down/003.create_events.sql +3 -0
  34. src/migrations/down/004.create_tasks.sql +3 -0
  35. src/migrations/down/005.create_indexes.sql +5 -0
  36. src/migrations/down/006_auto_update_triggers.sql +4 -0
  37. src/migrations/down/007_create_logs.sql +1 -0
  38. src/migrations/up/001_setup_pragma.sql +11 -0
  39. src/migrations/up/002_create_workflows.sql +15 -0
  40. src/migrations/up/003_create_events.sql +13 -0
  41. src/migrations/up/004_create_tasks.sql +23 -0
  42. src/migrations/up/005_create_indexes.sql +11 -0
  43. src/migrations/up/006_auto_update_triggers.sql +19 -0
  44. src/migrations/up/007_create_logs.sql +10 -0
  45. src/schemas/__init__.py +0 -0
  46. src/schemas/activity.py +13 -0
  47. src/schemas/database.py +17 -0
  48. src/schemas/events.py +70 -0
  49. src/schemas/tasks.py +58 -0
  50. src/schemas/workflow.py +33 -0
src/cli/cli.py ADDED
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env python3
2
+ """Loom CLI - Command-line interface for Loom workflow orchestration."""
3
+
4
+ import asyncio
5
+ import sys
6
+ from typing import Any
7
+
8
+ import click
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from ..core.worker import start_worker
13
+ from ..database.db import Database
14
+
15
+ console = Console()
16
+
17
+
18
+ def echo(message: str, **kwargs):
19
+ """Print with rich console."""
20
+ console.print(message)
21
+
22
+
23
+ @click.group()
24
+ @click.version_option(version="0.1.0", prog_name="loom")
25
+ def cli():
26
+ """Loom - Durable Workflow Orchestration Engine.
27
+
28
+ Loom provides event-sourced, deterministic workflow execution with
29
+ automatic recovery and replay capabilities.
30
+ """
31
+ pass
32
+
33
+
34
+ @cli.command()
35
+ @click.option(
36
+ "--workers",
37
+ "-w",
38
+ default=4,
39
+ type=int,
40
+ help="Number of concurrent task workers",
41
+ show_default=True,
42
+ )
43
+ @click.option(
44
+ "--poll-interval",
45
+ "-p",
46
+ default=0.5,
47
+ type=float,
48
+ help="Polling interval in seconds",
49
+ show_default=True,
50
+ )
51
+ def worker(workers: int, poll_interval: float):
52
+ """Start a distributed workflow worker.
53
+
54
+ The worker continuously polls for available tasks and executes them
55
+ concurrently. Supports graceful shutdown via Ctrl+C or SIGTERM.
56
+
57
+ Examples:
58
+ loom worker # Start with defaults (4 workers)
59
+ loom worker -w 8 # Start with 8 concurrent workers
60
+ loom worker -w 2 -p 1.0 # 2 workers, 1 second poll interval
61
+ """
62
+ try:
63
+ console.print("[bold green]Starting Loom workflow worker...[/bold green]")
64
+ asyncio.run(start_worker(workers=workers, poll_interval=poll_interval))
65
+ except KeyboardInterrupt:
66
+ pass
67
+
68
+
69
+ @cli.command()
70
+ def init():
71
+ """Initialize the Loom database and migrations.
72
+
73
+ Creates the database file and applies all pending migrations.
74
+ Safe to run multiple times - will skip if already initialized.
75
+ """
76
+ echo("Initializing Loom database...")
77
+
78
+ async def _init():
79
+ async with Database[Any, Any]() as db:
80
+ await db._init_db()
81
+
82
+ try:
83
+ asyncio.run(_init())
84
+ echo("[green]Database initialized successfully[/green]")
85
+ except Exception as e:
86
+ echo(f"[red]Initialization failed: {e}[/red]")
87
+ sys.exit(1)
88
+
89
+
90
+ @cli.command()
91
+ @click.option(
92
+ "--limit",
93
+ "-l",
94
+ default=50,
95
+ type=int,
96
+ help="Maximum number of workflows to display",
97
+ show_default=True,
98
+ )
99
+ @click.option(
100
+ "--status",
101
+ "-s",
102
+ type=click.Choice(
103
+ ["RUNNING", "COMPLETED", "FAILED", "CANCELED"], case_sensitive=False
104
+ ),
105
+ help="Filter by workflow status",
106
+ )
107
+ def list(limit: int, status: str | None):
108
+ """List workflows with optional filtering.
109
+
110
+ Examples:
111
+ loom list # List recent workflows
112
+ loom list -l 100 # List up to 100 workflows
113
+ loom list -s RUNNING # List only running workflows
114
+ """
115
+
116
+ async def _list():
117
+ async with Database[Any, Any]() as db:
118
+ if status:
119
+ sql = """
120
+ SELECT id, name, status, created_at, updated_at
121
+ FROM workflows
122
+ WHERE status = ?
123
+ ORDER BY created_at DESC
124
+ LIMIT ?
125
+ """
126
+ workflows = await db.query(sql, (status.upper(), limit))
127
+ else:
128
+ sql = """
129
+ SELECT id, name, status, created_at, updated_at
130
+ FROM workflows
131
+ ORDER BY created_at DESC
132
+ LIMIT ?
133
+ """
134
+ workflows = await db.query(sql, (limit,))
135
+
136
+ if not workflows:
137
+ echo("No workflows found")
138
+ return
139
+
140
+ table = Table(
141
+ title="Workflows", show_header=True, header_style="bold magenta"
142
+ )
143
+ table.add_column("ID", style="dim", width=40)
144
+ table.add_column("Name", style="cyan", width=25)
145
+ table.add_column("Status", justify="center", width=15)
146
+ table.add_column("Created", style="green", width=20)
147
+
148
+ for wf in workflows:
149
+ status_style = {
150
+ "RUNNING": "[yellow]RUNNING[/yellow]",
151
+ "COMPLETED": "[green]COMPLETED[/green]",
152
+ "FAILED": "[red]FAILED[/red]",
153
+ "CANCELED": "[dim]CANCELED[/dim]",
154
+ }.get(wf["status"], wf["status"])
155
+
156
+ table.add_row(wf["id"], wf["name"], status_style, wf["created_at"])
157
+
158
+ console.print(table)
159
+
160
+ try:
161
+ asyncio.run(_list())
162
+ except Exception as e:
163
+ echo(f"[red]Error: {e}[/red]")
164
+ sys.exit(1)
165
+
166
+
167
+ @cli.command()
168
+ @click.argument("workflow_id")
169
+ @click.option(
170
+ "--events/--no-events",
171
+ default=False,
172
+ help="Show event history",
173
+ )
174
+ def inspect(workflow_id: str, events: bool):
175
+ """Inspect detailed workflow information.
176
+
177
+ Shows workflow metadata, current state, and optionally event history.
178
+
179
+ Examples:
180
+ loom inspect <workflow-id>
181
+ loom inspect <workflow-id> --events
182
+ """
183
+
184
+ async def _inspect():
185
+ async with Database[Any, Any]() as db:
186
+ # Get workflow info
187
+ workflow = await db.get_workflow_info(workflow_id)
188
+
189
+ echo(f"\n[bold]Workflow: {workflow['name']}[/bold]\n")
190
+ echo(f"ID: {workflow['id']}")
191
+ echo(f"Status: {workflow['status']}")
192
+ echo(f"Module: {workflow['module']}")
193
+ echo(f"Version: {workflow['version']}")
194
+ echo(f"Created: {workflow['created_at']}")
195
+ echo(f"Updated: {workflow['updated_at']}")
196
+
197
+ if events:
198
+ event_list = await db.get_workflow_events(workflow_id)
199
+ echo(f"\n[bold]Events ({len(event_list)}):[/bold]\n")
200
+
201
+ for i, event in enumerate(event_list, 1):
202
+ echo(f"{i:3d}. {event['type']}")
203
+ if event["payload"]:
204
+ echo(f" Payload: {event['payload']}")
205
+
206
+ try:
207
+ asyncio.run(_inspect())
208
+ except Exception as e:
209
+ echo(f"[red]Error: {e}[/red]")
210
+ sys.exit(1)
211
+
212
+
213
+ @cli.command()
214
+ def stats():
215
+ """Show database statistics and health metrics.
216
+
217
+ Displays counts of workflows, events, tasks, and logs.
218
+ """
219
+
220
+ async def _stats():
221
+ async with Database[Any, Any]() as db:
222
+ workflows = await db.query("SELECT COUNT(*) as count FROM workflows")
223
+ events = await db.query("SELECT COUNT(*) as count FROM events")
224
+ tasks = await db.query("SELECT COUNT(*) as count FROM tasks")
225
+ logs = await db.query("SELECT COUNT(*) as count FROM logs")
226
+
227
+ running = await db.query(
228
+ "SELECT COUNT(*) as count FROM workflows WHERE status = 'RUNNING'"
229
+ )
230
+
231
+ echo("\n[bold]Database Statistics:[/bold]\n")
232
+ echo(f"Total Workflows: {list(workflows)[0]['count']}")
233
+ echo(f"Running Workflows: {list(running)[0]['count']}")
234
+ echo(f"Total Events: {list(events)[0]['count']}")
235
+ echo(f"Pending Tasks: {list(tasks)[0]['count']}")
236
+ echo(f"Log Entries: {list(logs)[0]['count']}")
237
+
238
+ try:
239
+ asyncio.run(_stats())
240
+ except Exception as e:
241
+ echo(f"[red]Error: {e}[/red]")
242
+ sys.exit(1)
243
+
244
+
245
+ if __name__ == "__main__":
246
+ cli()
src/common/activity.py ADDED
@@ -0,0 +1,30 @@
1
+ import importlib
2
+ from typing import Any, Awaitable, Callable, cast
3
+
4
+
5
+ def load_activity(module: str, func: str) -> Callable[..., Awaitable[Any]]:
6
+
7
+ try:
8
+ activity_module = importlib.import_module(module)
9
+ except ModuleNotFoundError as e:
10
+ raise ModuleNotFoundError(
11
+ f"Cannot import activity module '{module}'. "
12
+ f"Ensure the module exists and is in the Python path."
13
+ ) from e
14
+
15
+ try:
16
+ activity_func = getattr(activity_module, func)
17
+ except AttributeError as e:
18
+ raise AttributeError(
19
+ f"Activity function '{func}' not found in module '{module}'. "
20
+ f"Available functions: {[name for name in dir(activity_module) if not name.startswith('_')]}"
21
+ ) from e
22
+
23
+ # Validate that it's actually a function
24
+ if not callable(activity_func):
25
+ raise TypeError(
26
+ f"'{func}' from module '{module}' is not callable. "
27
+ f"Got {type(activity_func).__name__}."
28
+ )
29
+
30
+ return cast(Callable[..., Awaitable[Any]], activity_func)
src/common/config.py ADDED
@@ -0,0 +1,9 @@
1
+ import os
2
+
3
+ MIGRATION_UPGRADES = os.path.join(os.path.dirname(__file__), "../", "migrations", "up")
4
+
5
+ MIGRATION_DOWNGRADES = os.path.join(
6
+ os.path.dirname(__file__), "../", "migrations", "down"
7
+ )
8
+ DATA_ROOT = ".loom"
9
+ DATABASE = os.path.join(DATA_ROOT, "loom.db")
src/common/errors.py ADDED
@@ -0,0 +1,64 @@
1
+ class WorkflowNotFoundError(Exception):
2
+ """Exception raised when a workflow is not found in the database."""
3
+
4
+ pass
5
+
6
+
7
+ class WorkflowStillRunningError(Exception):
8
+ """Exception raised when attempting to retrieve the result of a running workflow."""
9
+
10
+ pass
11
+
12
+
13
+ class WorkflowExecutionError(Exception):
14
+ """Exception raised when a workflow has failed during execution."""
15
+
16
+ pass
17
+
18
+
19
+ class WorkerCancelledError(Exception):
20
+ """Exception raised when a worker has been cancelled."""
21
+
22
+ pass
23
+
24
+
25
+ class StopReplay(Exception):
26
+ """
27
+ Exception used to signal that workflow replay should stop.
28
+
29
+ This is typically raised internally during workflow execution replay
30
+ when the workflow reaches a point where it needs to wait for external input
31
+ or events, indicating that replay cannot proceed further at this time.
32
+
33
+ Example usage:
34
+
35
+ from loom import loom
36
+ from loom.schemas.workflow import Workflow, WorkflowContext, InputT, StateT
37
+
38
+ class MyInput(InputT):
39
+ pass
40
+
41
+ class MyState(StateT):
42
+ pass
43
+
44
+ result: str = ""
45
+
46
+ @loom.workflow
47
+ class MyWorkflow(Workflow[MyInput, MyState]):
48
+ @loom.step
49
+ async def process(self, ctx: WorkflowContext[MyState]):
50
+ # Workflow logic here
51
+ pass
52
+ """
53
+
54
+ pass
55
+
56
+
57
+ class NonDeterministicWorkflowError(Exception):
58
+ """Exception raised when a workflow execution diverges from its recorded history.
59
+
60
+ This typically indicates that the workflow code has changed in a way that
61
+ affects its execution path, leading to inconsistencies during replay.
62
+ """
63
+
64
+ pass
src/common/workflow.py ADDED
@@ -0,0 +1,56 @@
1
+ import importlib
2
+ from typing import Any, Type
3
+
4
+ from ..core.workflow import Workflow
5
+
6
+
7
+ def workflow_registry(module: str, cls: str) -> Type[Workflow[Any, Any]]:
8
+ """
9
+ Retrieve a workflow class from the global registry.
10
+
11
+ Args:
12
+ module: The module name where the workflow is defined.
13
+ cls: The class name of the workflow.
14
+
15
+ Returns:
16
+ The workflow class with proper typing.
17
+
18
+ Example:
19
+ ```python
20
+ # Get a workflow class dynamically
21
+ WorkflowCls = workflow_registry("myapp.workflows", "UserOnboardingWorkflow")
22
+
23
+ # Compile and use it
24
+ compiled_workflow = WorkflowCls.compile()
25
+ handle = await compiled_workflow.start(input_data)
26
+ ```
27
+
28
+ Raises:
29
+ ModuleNotFoundError: If the module cannot be imported.
30
+ AttributeError: If the class doesn't exist in the module.
31
+ TypeError: If the retrieved class is not a Workflow subclass.
32
+ """
33
+ try:
34
+ workflow_module = importlib.import_module(module)
35
+ except ModuleNotFoundError as e:
36
+ raise ModuleNotFoundError(
37
+ f"Cannot import workflow module '{module}'. "
38
+ f"Ensure the module exists and is in the Python path."
39
+ ) from e
40
+
41
+ try:
42
+ workflow_cls: Type[Workflow[Any, Any]] = getattr(workflow_module, cls)
43
+ except AttributeError as e:
44
+ raise AttributeError(
45
+ f"Workflow class '{cls}' not found in module '{module}'. "
46
+ f"Available classes: {[name for name in dir(workflow_module) if not name.startswith('_')]}"
47
+ ) from e
48
+
49
+ # Validate that it's actually a Workflow subclass
50
+ if not (isinstance(workflow_cls, type) and issubclass(workflow_cls, Workflow)):
51
+ raise TypeError(
52
+ f"Class '{cls}' from module '{module}' is not a Workflow subclass. "
53
+ f"Got {type(workflow_cls).__name__}."
54
+ )
55
+
56
+ return workflow_cls
src/core/__init__.py ADDED
File without changes
src/core/compiled.py ADDED
@@ -0,0 +1,41 @@
1
+ from typing import Generic, List
2
+
3
+ from ..core.handle import WorkflowHandle
4
+ from ..database.db import Database
5
+ from ..schemas.database import WorkflowInput
6
+ from ..schemas.workflow import InputT, StateT, Step
7
+
8
+
9
+ class CompiledWorkflow(Generic[InputT, StateT]):
10
+ name: str
11
+ description: str
12
+ version: str
13
+ module: str
14
+ steps: List[Step]
15
+
16
+ def __init__(
17
+ self,
18
+ name: str,
19
+ description: str,
20
+ version: str,
21
+ module: str,
22
+ steps: List[Step],
23
+ ):
24
+ self.name = name
25
+ self.description = description
26
+ self.version = version
27
+ self.module = module
28
+ self.steps = steps
29
+
30
+ async def start(self, input: InputT):
31
+ workflow: WorkflowInput = {
32
+ "name": self.name,
33
+ "description": self.description,
34
+ "version": self.version,
35
+ "status": "RUNNING",
36
+ "module": self.module,
37
+ "steps": self.steps,
38
+ }
39
+ async with Database[InputT, StateT]() as db:
40
+ workflow_id = await db.create_workflow(workflow, input)
41
+ return WorkflowHandle[InputT, StateT](workflow_id)