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.
- loom_core-0.1.0.dist-info/METADATA +342 -0
- loom_core-0.1.0.dist-info/RECORD +50 -0
- loom_core-0.1.0.dist-info/WHEEL +5 -0
- loom_core-0.1.0.dist-info/entry_points.txt +2 -0
- loom_core-0.1.0.dist-info/licenses/LICENSE +21 -0
- loom_core-0.1.0.dist-info/top_level.txt +1 -0
- src/__init__.py +45 -0
- src/cli/__init__.py +5 -0
- src/cli/cli.py +246 -0
- src/common/activity.py +30 -0
- src/common/config.py +9 -0
- src/common/errors.py +64 -0
- src/common/workflow.py +56 -0
- src/core/__init__.py +0 -0
- src/core/compiled.py +41 -0
- src/core/context.py +256 -0
- src/core/engine.py +106 -0
- src/core/handle.py +166 -0
- src/core/logger.py +60 -0
- src/core/runner.py +53 -0
- src/core/state.py +96 -0
- src/core/worker.py +147 -0
- src/core/workflow.py +168 -0
- src/database/__init__.py +0 -0
- src/database/db.py +716 -0
- src/decorators/__init__.py +0 -0
- src/decorators/activity.py +126 -0
- src/decorators/workflow.py +46 -0
- src/lib/progress.py +109 -0
- src/lib/utils.py +25 -0
- src/migrations/down/001_setup_pragma.sql +5 -0
- src/migrations/down/002_create_workflows.sql +3 -0
- src/migrations/down/003.create_events.sql +3 -0
- src/migrations/down/004.create_tasks.sql +3 -0
- src/migrations/down/005.create_indexes.sql +5 -0
- src/migrations/down/006_auto_update_triggers.sql +4 -0
- src/migrations/down/007_create_logs.sql +1 -0
- src/migrations/up/001_setup_pragma.sql +11 -0
- src/migrations/up/002_create_workflows.sql +15 -0
- src/migrations/up/003_create_events.sql +13 -0
- src/migrations/up/004_create_tasks.sql +23 -0
- src/migrations/up/005_create_indexes.sql +11 -0
- src/migrations/up/006_auto_update_triggers.sql +19 -0
- src/migrations/up/007_create_logs.sql +10 -0
- src/schemas/__init__.py +0 -0
- src/schemas/activity.py +13 -0
- src/schemas/database.py +17 -0
- src/schemas/events.py +70 -0
- src/schemas/tasks.py +58 -0
- 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)
|