vibeblocks 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.
- vibeblocks/__init__.py +22 -0
- vibeblocks/components/__init__.py +0 -0
- vibeblocks/components/block.py +195 -0
- vibeblocks/components/chain.py +124 -0
- vibeblocks/components/flow.py +278 -0
- vibeblocks/core/__init__.py +0 -0
- vibeblocks/core/context.py +163 -0
- vibeblocks/core/decorators.py +70 -0
- vibeblocks/core/errors.py +18 -0
- vibeblocks/core/executable.py +40 -0
- vibeblocks/core/outcome.py +14 -0
- vibeblocks/policies/__init__.py +0 -0
- vibeblocks/policies/failure.py +14 -0
- vibeblocks/policies/retry.py +101 -0
- vibeblocks/py.typed +0 -0
- vibeblocks/runtime/__init__.py +0 -0
- vibeblocks/runtime/runner.py +51 -0
- vibeblocks/utils/__init__.py +2 -0
- vibeblocks/utils/execution.py +33 -0
- vibeblocks/utils/inspection.py +33 -0
- vibeblocks/utils/schema.py +121 -0
- vibeblocks/utils/serialization.py +34 -0
- vibeblocks/vibeblocks.py +66 -0
- vibeblocks-0.1.0.dist-info/METADATA +176 -0
- vibeblocks-0.1.0.dist-info/RECORD +27 -0
- vibeblocks-0.1.0.dist-info/WHEEL +4 -0
- vibeblocks-0.1.0.dist-info/licenses/LICENSE +21 -0
vibeblocks/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
2
|
+
|
|
3
|
+
from vibeblocks.core.context import ExecutionContext
|
|
4
|
+
from vibeblocks.components.block import Block
|
|
5
|
+
from vibeblocks.components.chain import Chain
|
|
6
|
+
from vibeblocks.components.flow import Flow
|
|
7
|
+
from vibeblocks.runtime.runner import SyncRunner, AsyncRunner
|
|
8
|
+
from vibeblocks.core.decorators import block
|
|
9
|
+
from vibeblocks.policies.failure import FailureStrategy
|
|
10
|
+
from vibeblocks.utils.execution import execute_flow
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Block",
|
|
14
|
+
"Chain",
|
|
15
|
+
"Flow",
|
|
16
|
+
"ExecutionContext",
|
|
17
|
+
"SyncRunner",
|
|
18
|
+
"AsyncRunner",
|
|
19
|
+
"block",
|
|
20
|
+
"FailureStrategy",
|
|
21
|
+
"execute_flow",
|
|
22
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import concurrent.futures
|
|
3
|
+
import inspect
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Awaitable, Callable, Optional, TypeVar, Union
|
|
6
|
+
|
|
7
|
+
from vibeblocks.core.context import ExecutionContext
|
|
8
|
+
from vibeblocks.core.errors import BlockExecutionError, BlockTimeoutError
|
|
9
|
+
from vibeblocks.core.executable import Executable
|
|
10
|
+
from vibeblocks.core.outcome import Outcome
|
|
11
|
+
from vibeblocks.policies.retry import RetryPolicy
|
|
12
|
+
from vibeblocks.utils.inspection import is_async_callable
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T")
|
|
15
|
+
|
|
16
|
+
# Shared executor for synchronous block timeouts to avoid overhead and thread leakage.
|
|
17
|
+
# Using a large enough number of workers to handle concurrent blocks.
|
|
18
|
+
_TASK_TIMEOUT_EXECUTOR = concurrent.futures.ThreadPoolExecutor(
|
|
19
|
+
thread_name_prefix="BlockTimeout")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Block(Executable[T]):
|
|
23
|
+
"""
|
|
24
|
+
Represents an atomic unit of work in a workflow.
|
|
25
|
+
Executes a function with retry logic and supports compensation.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
name: str,
|
|
31
|
+
func: Callable[[ExecutionContext[T]], Any],
|
|
32
|
+
description: Optional[str] = None,
|
|
33
|
+
retry_policy: Optional[RetryPolicy] = None,
|
|
34
|
+
undo: Optional[Callable[[ExecutionContext[T]], Any]] = None,
|
|
35
|
+
timeout: Optional[float] = None,
|
|
36
|
+
):
|
|
37
|
+
self.name = name
|
|
38
|
+
self.func = func
|
|
39
|
+
self.description = description or func.__doc__
|
|
40
|
+
self.retry_policy = retry_policy or RetryPolicy(max_attempts=1)
|
|
41
|
+
self.undo = undo
|
|
42
|
+
self.timeout = timeout
|
|
43
|
+
|
|
44
|
+
self._is_async = is_async_callable(self.func)
|
|
45
|
+
self._is_undo_async = is_async_callable(
|
|
46
|
+
self.undo) if self.undo else False
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def is_async(self) -> bool:
|
|
50
|
+
"""Determines if the block function is asynchronous."""
|
|
51
|
+
# Optimized: Return pre-calculated value to avoid repeated inspection overhead
|
|
52
|
+
return self._is_async
|
|
53
|
+
|
|
54
|
+
def execute(self, ctx: ExecutionContext[T]) -> Union[Outcome[T], Awaitable[Outcome[T]]]:
|
|
55
|
+
if self.is_async:
|
|
56
|
+
return self._execute_async(ctx)
|
|
57
|
+
else:
|
|
58
|
+
return self._execute_sync(ctx)
|
|
59
|
+
|
|
60
|
+
def _execute_sync(self, ctx: ExecutionContext[T]) -> Outcome[T]:
|
|
61
|
+
ctx.log_event("INFO", self.name, "Block Started")
|
|
62
|
+
start_time = time.time()
|
|
63
|
+
attempt = 1
|
|
64
|
+
|
|
65
|
+
while True:
|
|
66
|
+
try:
|
|
67
|
+
if self.timeout:
|
|
68
|
+
try:
|
|
69
|
+
future = _TASK_TIMEOUT_EXECUTOR.submit(self.func, ctx)
|
|
70
|
+
res = future.result(timeout=self.timeout)
|
|
71
|
+
except concurrent.futures.TimeoutError:
|
|
72
|
+
raise BlockTimeoutError(
|
|
73
|
+
f"Block '{self.name}' timed out after {self.timeout}s"
|
|
74
|
+
) from None
|
|
75
|
+
else:
|
|
76
|
+
res = self.func(ctx)
|
|
77
|
+
|
|
78
|
+
# Runtime check for false-negative async detection (e.g. lambdas)
|
|
79
|
+
if inspect.isawaitable(res):
|
|
80
|
+
if inspect.iscoroutine(res):
|
|
81
|
+
res.close()
|
|
82
|
+
# We cannot await it here because we are in sync mode.
|
|
83
|
+
# We must warn the user that their block logic probably didn't run.
|
|
84
|
+
# Or raise an error? Raising error is safer.
|
|
85
|
+
msg = (
|
|
86
|
+
f"Block '{self.name}' returned an awaitable (coroutine) but "
|
|
87
|
+
"was executed synchronously. Check if the function is "
|
|
88
|
+
"defined correctly or if AsyncRunner should be used."
|
|
89
|
+
)
|
|
90
|
+
raise RuntimeError(msg)
|
|
91
|
+
|
|
92
|
+
duration = int((time.time() - start_time) * 1000)
|
|
93
|
+
ctx.log_event("INFO", self.name, "Block Completed")
|
|
94
|
+
ctx.completed_steps.add(self.name)
|
|
95
|
+
return Outcome(status="SUCCESS", context=ctx, errors=[], duration_ms=duration)
|
|
96
|
+
|
|
97
|
+
except Exception as e:
|
|
98
|
+
duration = int((time.time() - start_time) * 1000)
|
|
99
|
+
ctx.log_event("ERROR", self.name,
|
|
100
|
+
f"Block Failed: {ctx.format_exception(e)}")
|
|
101
|
+
|
|
102
|
+
if self.retry_policy.should_retry(attempt, e):
|
|
103
|
+
delay = self.retry_policy.calculate_delay(attempt)
|
|
104
|
+
msg = (
|
|
105
|
+
f"Retrying in {delay}s (Attempt {attempt}/"
|
|
106
|
+
f"{self.retry_policy.max_attempts})"
|
|
107
|
+
)
|
|
108
|
+
ctx.log_event("INFO", self.name, msg)
|
|
109
|
+
time.sleep(delay)
|
|
110
|
+
attempt += 1
|
|
111
|
+
continue
|
|
112
|
+
else:
|
|
113
|
+
msg = f"Block '{self.name}' failed after {attempt} attempts"
|
|
114
|
+
error = BlockExecutionError(msg)
|
|
115
|
+
error.__cause__ = e
|
|
116
|
+
return Outcome(
|
|
117
|
+
status="FAILED", context=ctx, errors=[error], duration_ms=duration
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
async def _execute_async(self, ctx: ExecutionContext[T]) -> Outcome[T]:
|
|
121
|
+
ctx.log_event("INFO", self.name, "Block Started (Async)")
|
|
122
|
+
start_time = time.time()
|
|
123
|
+
attempt = 1
|
|
124
|
+
|
|
125
|
+
while True:
|
|
126
|
+
try:
|
|
127
|
+
res = self.func(ctx)
|
|
128
|
+
if inspect.isawaitable(res):
|
|
129
|
+
if self.timeout:
|
|
130
|
+
try:
|
|
131
|
+
res = await asyncio.wait_for(res, timeout=self.timeout)
|
|
132
|
+
except asyncio.TimeoutError:
|
|
133
|
+
raise BlockTimeoutError(
|
|
134
|
+
f"Block '{self.name}' timed out after {self.timeout}s"
|
|
135
|
+
) from None
|
|
136
|
+
else:
|
|
137
|
+
res = await res
|
|
138
|
+
|
|
139
|
+
duration = int((time.time() - start_time) * 1000)
|
|
140
|
+
ctx.log_event("INFO", self.name, "Block Completed")
|
|
141
|
+
ctx.completed_steps.add(self.name)
|
|
142
|
+
return Outcome(status="SUCCESS", context=ctx, errors=[], duration_ms=duration)
|
|
143
|
+
|
|
144
|
+
except Exception as e:
|
|
145
|
+
duration = int((time.time() - start_time) * 1000)
|
|
146
|
+
ctx.log_event("ERROR", self.name,
|
|
147
|
+
f"Block Failed: {ctx.format_exception(e)}")
|
|
148
|
+
|
|
149
|
+
if self.retry_policy.should_retry(attempt, e):
|
|
150
|
+
delay = self.retry_policy.calculate_delay(attempt)
|
|
151
|
+
msg = (
|
|
152
|
+
f"Retrying in {delay}s (Attempt {attempt}/"
|
|
153
|
+
f"{self.retry_policy.max_attempts})"
|
|
154
|
+
)
|
|
155
|
+
ctx.log_event("INFO", self.name, msg)
|
|
156
|
+
await asyncio.sleep(delay)
|
|
157
|
+
attempt += 1
|
|
158
|
+
continue
|
|
159
|
+
else:
|
|
160
|
+
msg = f"Block '{self.name}' failed after {attempt} attempts"
|
|
161
|
+
error = BlockExecutionError(msg)
|
|
162
|
+
error.__cause__ = e
|
|
163
|
+
return Outcome(
|
|
164
|
+
status="FAILED", context=ctx, errors=[error], duration_ms=duration
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def compensate(self, ctx: ExecutionContext[T]) -> Union[None, Awaitable[None]]:
|
|
168
|
+
if self.undo is None:
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
ctx.log_event("INFO", self.name, "Compensating Block")
|
|
172
|
+
|
|
173
|
+
if self._is_undo_async:
|
|
174
|
+
return self._compensate_async(ctx)
|
|
175
|
+
else:
|
|
176
|
+
try:
|
|
177
|
+
self.undo(ctx)
|
|
178
|
+
except Exception as e:
|
|
179
|
+
ctx.log_event("ERROR", self.name,
|
|
180
|
+
f"Compensation Failed: {ctx.format_exception(e)}")
|
|
181
|
+
raise
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
async def _compensate_async(self, ctx: ExecutionContext[T]) -> None:
|
|
185
|
+
if self.undo is None:
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
res = self.undo(ctx)
|
|
190
|
+
if inspect.isawaitable(res):
|
|
191
|
+
await res
|
|
192
|
+
except Exception as e:
|
|
193
|
+
ctx.log_event("ERROR", self.name,
|
|
194
|
+
f"Compensation Failed: {ctx.format_exception(e)}")
|
|
195
|
+
raise
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Chain component handling linear execution of multiple blocks.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import time
|
|
7
|
+
from typing import List, Union, Awaitable, TypeVar
|
|
8
|
+
|
|
9
|
+
from vibeblocks.core.context import ExecutionContext
|
|
10
|
+
from vibeblocks.core.outcome import Outcome
|
|
11
|
+
from vibeblocks.core.executable import Executable
|
|
12
|
+
from vibeblocks.core.errors import ChainExecutionError
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Chain(Executable[T]):
|
|
18
|
+
"""
|
|
19
|
+
A linear collection of executables (blocks or sub-chains).
|
|
20
|
+
Executes steps sequentially.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, name: str, steps: List[Executable[T]]):
|
|
24
|
+
self.name = name
|
|
25
|
+
self.steps = list(steps)
|
|
26
|
+
self._is_async = any(step.is_async for step in self.steps)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def is_async(self) -> bool:
|
|
30
|
+
"""Determines if the chain requires asynchronous execution."""
|
|
31
|
+
return self._is_async
|
|
32
|
+
|
|
33
|
+
def execute(self, ctx: ExecutionContext[T]) -> Union[Outcome[T], Awaitable[Outcome[T]]]:
|
|
34
|
+
if self.is_async:
|
|
35
|
+
return self._execute_async(ctx)
|
|
36
|
+
return self._execute_sync(ctx)
|
|
37
|
+
|
|
38
|
+
def _execute_sync(self, ctx: ExecutionContext[T]) -> Outcome[T]:
|
|
39
|
+
start_time = time.perf_counter_ns()
|
|
40
|
+
ctx.log_event("INFO", self.name, "Chain Started")
|
|
41
|
+
|
|
42
|
+
for step in self.steps:
|
|
43
|
+
try:
|
|
44
|
+
result = step.execute(ctx)
|
|
45
|
+
|
|
46
|
+
# Safety check for unexpected async returns in sync mode
|
|
47
|
+
if inspect.isawaitable(result):
|
|
48
|
+
if inspect.iscoroutine(result):
|
|
49
|
+
result.close()
|
|
50
|
+
raise ChainExecutionError(
|
|
51
|
+
f"Step '{getattr(step, 'name', 'Unknown')}' returned a coroutine in a sync chain execution. Use AsyncRunner.")
|
|
52
|
+
|
|
53
|
+
# Check outcome
|
|
54
|
+
if isinstance(result, Outcome):
|
|
55
|
+
if result.status != "SUCCESS":
|
|
56
|
+
# Stop execution and bubble up failure
|
|
57
|
+
return result
|
|
58
|
+
except Exception as e:
|
|
59
|
+
duration = (time.perf_counter_ns() - start_time) // 1_000_000
|
|
60
|
+
ctx.log_event("ERROR", self.name,
|
|
61
|
+
f"Chain Error: {ctx.format_exception(e)}")
|
|
62
|
+
# Return Failed Outcome instead of raising
|
|
63
|
+
return Outcome(status="FAILED", context=ctx, errors=[e], duration_ms=duration)
|
|
64
|
+
|
|
65
|
+
duration = (time.perf_counter_ns() - start_time) // 1_000_000
|
|
66
|
+
ctx.log_event("INFO", self.name, "Chain Completed")
|
|
67
|
+
ctx.completed_steps.add(self.name)
|
|
68
|
+
return Outcome(status="SUCCESS", context=ctx, errors=[], duration_ms=duration)
|
|
69
|
+
|
|
70
|
+
async def _execute_async(self, ctx: ExecutionContext[T]) -> Outcome[T]:
|
|
71
|
+
start_time = time.perf_counter_ns()
|
|
72
|
+
ctx.log_event("INFO", self.name, "Chain Started (Async)")
|
|
73
|
+
|
|
74
|
+
for step in self.steps:
|
|
75
|
+
try:
|
|
76
|
+
result = step.execute(ctx)
|
|
77
|
+
if inspect.isawaitable(result):
|
|
78
|
+
result = await result
|
|
79
|
+
|
|
80
|
+
if isinstance(result, Outcome):
|
|
81
|
+
if result.status != "SUCCESS":
|
|
82
|
+
return result
|
|
83
|
+
except Exception as e:
|
|
84
|
+
duration = (time.perf_counter_ns() - start_time) // 1_000_000
|
|
85
|
+
ctx.log_event("ERROR", self.name,
|
|
86
|
+
f"Chain Error: {ctx.format_exception(e)}")
|
|
87
|
+
# Return Failed Outcome instead of raising
|
|
88
|
+
return Outcome(status="FAILED", context=ctx, errors=[e], duration_ms=duration)
|
|
89
|
+
|
|
90
|
+
duration = (time.perf_counter_ns() - start_time) // 1_000_000
|
|
91
|
+
ctx.log_event("INFO", self.name, "Chain Completed")
|
|
92
|
+
ctx.completed_steps.add(self.name)
|
|
93
|
+
return Outcome(status="SUCCESS", context=ctx, errors=[], duration_ms=duration)
|
|
94
|
+
|
|
95
|
+
def compensate(self, ctx: ExecutionContext[T]) -> Union[None, Awaitable[None]]:
|
|
96
|
+
if self.is_async:
|
|
97
|
+
return self._compensate_async(ctx)
|
|
98
|
+
|
|
99
|
+
ctx.log_event("INFO", self.name, "Compensating Chain")
|
|
100
|
+
for step in reversed(self.steps):
|
|
101
|
+
if self._did_step_succeed(ctx, step):
|
|
102
|
+
res = step.compensate(ctx)
|
|
103
|
+
if inspect.isawaitable(res):
|
|
104
|
+
if inspect.iscoroutine(res):
|
|
105
|
+
res.close()
|
|
106
|
+
raise RuntimeError(
|
|
107
|
+
f"Step '{getattr(step, 'name', 'Unknown')}' returned an async compensation in a sync chain. Use AsyncRunner.")
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
async def _compensate_async(self, ctx: ExecutionContext[T]) -> None:
|
|
111
|
+
ctx.log_event("INFO", self.name, "Compensating Chain (Async)")
|
|
112
|
+
for step in reversed(self.steps):
|
|
113
|
+
if self._did_step_succeed(ctx, step):
|
|
114
|
+
res = step.compensate(ctx)
|
|
115
|
+
if inspect.isawaitable(res):
|
|
116
|
+
await res
|
|
117
|
+
|
|
118
|
+
def _did_step_succeed(self, ctx: ExecutionContext[T], step: Executable[T]) -> bool:
|
|
119
|
+
"""Checks trace to see if the step completed successfully."""
|
|
120
|
+
name = getattr(step, "name", None)
|
|
121
|
+
if not name:
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
return name in ctx.completed_steps
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Flow orchestrator for executing trees of Blocks and Chaines.
|
|
3
|
+
Handles high-level failure policies.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import inspect
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Awaitable, Dict, List, Literal, Optional, Tuple, TypeVar, Union
|
|
9
|
+
|
|
10
|
+
from vibeblocks.core.context import ExecutionContext
|
|
11
|
+
from vibeblocks.core.executable import Executable
|
|
12
|
+
from vibeblocks.core.outcome import Outcome
|
|
13
|
+
from vibeblocks.policies.failure import FailureStrategy
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Flow(Executable[T]):
|
|
19
|
+
"""
|
|
20
|
+
The top-level orchestrator that manages a sequence of steps (Blocks or Chaines)
|
|
21
|
+
and handles failures according to a strategy.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
name: str,
|
|
27
|
+
steps: List[Executable[T]],
|
|
28
|
+
description: Optional[str] = None,
|
|
29
|
+
strategy: FailureStrategy = FailureStrategy.ABORT
|
|
30
|
+
):
|
|
31
|
+
self.name = name
|
|
32
|
+
self.steps = list(steps)
|
|
33
|
+
self.description = description
|
|
34
|
+
self.strategy = strategy
|
|
35
|
+
self._is_async = any(step.is_async for step in self.steps)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def is_async(self) -> bool:
|
|
39
|
+
"""Recursively checks if any step requires async execution."""
|
|
40
|
+
return self._is_async
|
|
41
|
+
|
|
42
|
+
def get_manifest(self) -> Dict[str, Any]:
|
|
43
|
+
"""
|
|
44
|
+
Returns a dictionary representation of the flow structure, including semantic descriptions.
|
|
45
|
+
"""
|
|
46
|
+
steps_info = []
|
|
47
|
+
for step in self.steps:
|
|
48
|
+
step_data = {
|
|
49
|
+
"name": getattr(step, "name", "Unknown"),
|
|
50
|
+
"type": step.__class__.__name__,
|
|
51
|
+
"description": getattr(step, "description", None) or step.__doc__ or "No description provided."
|
|
52
|
+
}
|
|
53
|
+
steps_info.append(step_data)
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
"name": self.name,
|
|
57
|
+
"description": self.description or "No description provided.",
|
|
58
|
+
"steps": steps_info,
|
|
59
|
+
"strategy": self.strategy.name
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
def execute(self, ctx: ExecutionContext[T]) -> Union[Outcome[T], Awaitable[Outcome[T]]]:
|
|
63
|
+
if self.is_async:
|
|
64
|
+
return self._execute_async(ctx)
|
|
65
|
+
return self._execute_sync(ctx)
|
|
66
|
+
|
|
67
|
+
def _execute_sync(self, ctx: ExecutionContext[T]) -> Outcome[T]:
|
|
68
|
+
start_time = time.perf_counter_ns()
|
|
69
|
+
ctx.log_event("INFO", self.name, "Flow Started")
|
|
70
|
+
collected_errors = []
|
|
71
|
+
|
|
72
|
+
for step in self.steps:
|
|
73
|
+
try:
|
|
74
|
+
result = step.execute(ctx)
|
|
75
|
+
|
|
76
|
+
self._ensure_not_awaitable(
|
|
77
|
+
result, getattr(step, "name", "Unknown"), "step execution"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if isinstance(result, Outcome) and result.status != "SUCCESS":
|
|
81
|
+
action, outcome = self._handle_failure_strategy(
|
|
82
|
+
ctx, step, result.errors, start_time
|
|
83
|
+
)
|
|
84
|
+
if action == "ABORT":
|
|
85
|
+
return outcome
|
|
86
|
+
elif action == "CONTINUE":
|
|
87
|
+
collected_errors.extend(result.errors)
|
|
88
|
+
continue
|
|
89
|
+
elif action == "COMPENSATE":
|
|
90
|
+
res = step.compensate(ctx)
|
|
91
|
+
self._ensure_not_awaitable(
|
|
92
|
+
res, getattr(
|
|
93
|
+
step, "name", "Unknown"), "step compensation"
|
|
94
|
+
)
|
|
95
|
+
self.compensate(ctx)
|
|
96
|
+
if outcome:
|
|
97
|
+
outcome.duration_ms = (
|
|
98
|
+
time.perf_counter_ns() - start_time
|
|
99
|
+
) // 1_000_000
|
|
100
|
+
return outcome
|
|
101
|
+
|
|
102
|
+
except Exception as e:
|
|
103
|
+
ctx.log_event("ERROR", self.name,
|
|
104
|
+
f"Flow Error: {ctx.format_exception(e)}")
|
|
105
|
+
action, outcome = self._handle_failure_strategy(
|
|
106
|
+
ctx, step, [e], start_time
|
|
107
|
+
)
|
|
108
|
+
if action == "ABORT":
|
|
109
|
+
return outcome
|
|
110
|
+
elif action == "CONTINUE":
|
|
111
|
+
collected_errors.append(e)
|
|
112
|
+
continue
|
|
113
|
+
elif action == "COMPENSATE":
|
|
114
|
+
res = step.compensate(ctx)
|
|
115
|
+
self._ensure_not_awaitable(
|
|
116
|
+
res, getattr(
|
|
117
|
+
step, "name", "Unknown"), "step compensation"
|
|
118
|
+
)
|
|
119
|
+
res = self.compensate(ctx)
|
|
120
|
+
self._ensure_not_awaitable(
|
|
121
|
+
res, self.name, "flow compensation")
|
|
122
|
+
if outcome:
|
|
123
|
+
outcome.duration_ms = (
|
|
124
|
+
time.perf_counter_ns() - start_time
|
|
125
|
+
) // 1_000_000
|
|
126
|
+
return outcome
|
|
127
|
+
|
|
128
|
+
return self._finish_flow(ctx, collected_errors, start_time)
|
|
129
|
+
|
|
130
|
+
async def _execute_async(self, ctx: ExecutionContext[T]) -> Outcome[T]:
|
|
131
|
+
start_time = time.perf_counter_ns()
|
|
132
|
+
ctx.log_event("INFO", self.name, "Flow Started (Async)")
|
|
133
|
+
collected_errors = []
|
|
134
|
+
|
|
135
|
+
for step in self.steps:
|
|
136
|
+
try:
|
|
137
|
+
result = step.execute(ctx)
|
|
138
|
+
if inspect.isawaitable(result):
|
|
139
|
+
result = await result
|
|
140
|
+
|
|
141
|
+
if isinstance(result, Outcome) and result.status != "SUCCESS":
|
|
142
|
+
action, outcome = self._handle_failure_strategy(
|
|
143
|
+
ctx, step, result.errors, start_time
|
|
144
|
+
)
|
|
145
|
+
if action == "ABORT":
|
|
146
|
+
return outcome
|
|
147
|
+
elif action == "CONTINUE":
|
|
148
|
+
collected_errors.extend(result.errors)
|
|
149
|
+
continue
|
|
150
|
+
elif action == "COMPENSATE":
|
|
151
|
+
res = step.compensate(ctx)
|
|
152
|
+
if inspect.isawaitable(res):
|
|
153
|
+
await res
|
|
154
|
+
|
|
155
|
+
res = self.compensate(ctx)
|
|
156
|
+
if inspect.isawaitable(res):
|
|
157
|
+
await res
|
|
158
|
+
if outcome:
|
|
159
|
+
outcome.duration_ms = (
|
|
160
|
+
time.perf_counter_ns() - start_time
|
|
161
|
+
) // 1_000_000
|
|
162
|
+
return outcome
|
|
163
|
+
|
|
164
|
+
except Exception as e:
|
|
165
|
+
ctx.log_event("ERROR", self.name,
|
|
166
|
+
f"Flow Error: {ctx.format_exception(e)}")
|
|
167
|
+
collected_errors.append(e)
|
|
168
|
+
|
|
169
|
+
action, outcome = self._handle_failure_strategy(
|
|
170
|
+
ctx, step, [e], start_time
|
|
171
|
+
)
|
|
172
|
+
if action == "ABORT":
|
|
173
|
+
return outcome
|
|
174
|
+
elif action == "CONTINUE":
|
|
175
|
+
continue
|
|
176
|
+
elif action == "COMPENSATE":
|
|
177
|
+
res = step.compensate(ctx)
|
|
178
|
+
if inspect.isawaitable(res):
|
|
179
|
+
await res
|
|
180
|
+
|
|
181
|
+
res = self.compensate(ctx)
|
|
182
|
+
if inspect.isawaitable(res):
|
|
183
|
+
await res
|
|
184
|
+
if outcome:
|
|
185
|
+
outcome.duration_ms = (
|
|
186
|
+
time.perf_counter_ns() - start_time
|
|
187
|
+
) // 1_000_000
|
|
188
|
+
return outcome
|
|
189
|
+
|
|
190
|
+
return self._finish_flow(ctx, collected_errors, start_time)
|
|
191
|
+
|
|
192
|
+
def compensate(self, ctx: ExecutionContext[T]) -> Union[None, Awaitable[None]]:
|
|
193
|
+
if self.is_async:
|
|
194
|
+
return self._compensate_async(ctx)
|
|
195
|
+
|
|
196
|
+
ctx.log_event("INFO", self.name, "Compensating Flow")
|
|
197
|
+
for step in reversed(self.steps):
|
|
198
|
+
if self._did_step_succeed(ctx, step):
|
|
199
|
+
res = step.compensate(ctx)
|
|
200
|
+
self._ensure_not_awaitable(
|
|
201
|
+
res, getattr(step, "name", "Unknown"), "compensation"
|
|
202
|
+
)
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
async def _compensate_async(self, ctx: ExecutionContext[T]) -> None:
|
|
206
|
+
ctx.log_event("INFO", self.name, "Compensating Flow (Async)")
|
|
207
|
+
for step in reversed(self.steps):
|
|
208
|
+
if self._did_step_succeed(ctx, step):
|
|
209
|
+
res = step.compensate(ctx)
|
|
210
|
+
if inspect.isawaitable(res):
|
|
211
|
+
await res
|
|
212
|
+
|
|
213
|
+
def _did_step_succeed(self, ctx: ExecutionContext[T], step: Executable[T]) -> bool:
|
|
214
|
+
"""Checks trace to see if the step completed successfully."""
|
|
215
|
+
name = getattr(step, "name", None)
|
|
216
|
+
if not name:
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
return name in ctx.completed_steps
|
|
220
|
+
|
|
221
|
+
def _ensure_not_awaitable(self, result, step_name: str, context: str) -> None:
|
|
222
|
+
if inspect.isawaitable(result):
|
|
223
|
+
if inspect.iscoroutine(result):
|
|
224
|
+
result.close()
|
|
225
|
+
raise RuntimeError(
|
|
226
|
+
f"Step '{step_name}' returned a coroutine "
|
|
227
|
+
f"in a sync flow ({context}). Use AsyncRunner."
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def _handle_failure_strategy(
|
|
231
|
+
self,
|
|
232
|
+
ctx: ExecutionContext[T],
|
|
233
|
+
step: Executable[T],
|
|
234
|
+
errors: List[Exception],
|
|
235
|
+
start_time: int,
|
|
236
|
+
) -> Tuple[Literal["ABORT", "CONTINUE", "COMPENSATE"], Optional[Outcome[T]]]:
|
|
237
|
+
if self.strategy == FailureStrategy.ABORT:
|
|
238
|
+
ctx.log_event(
|
|
239
|
+
"ERROR",
|
|
240
|
+
self.name,
|
|
241
|
+
f"Flow Aborted due to failure in step '{getattr(step, 'name', 'Unknown')}'",
|
|
242
|
+
)
|
|
243
|
+
duration = (time.perf_counter_ns() - start_time) // 1_000_000
|
|
244
|
+
return "ABORT", Outcome("ABORTED", ctx, errors, duration_ms=duration)
|
|
245
|
+
|
|
246
|
+
elif self.strategy == FailureStrategy.CONTINUE:
|
|
247
|
+
ctx.log_event(
|
|
248
|
+
"ERROR",
|
|
249
|
+
self.name,
|
|
250
|
+
f"Flow Continuing after failure in step '{getattr(step, 'name', 'Unknown')}'",
|
|
251
|
+
)
|
|
252
|
+
return "CONTINUE", None
|
|
253
|
+
|
|
254
|
+
elif self.strategy == FailureStrategy.COMPENSATE:
|
|
255
|
+
ctx.log_event(
|
|
256
|
+
"ERROR",
|
|
257
|
+
self.name,
|
|
258
|
+
f"Flow Compensating due to failure in step '{getattr(step, 'name', 'Unknown')}'",
|
|
259
|
+
)
|
|
260
|
+
duration = (time.perf_counter_ns() - start_time) // 1_000_000
|
|
261
|
+
return "COMPENSATE", Outcome("FAILED", ctx, errors, duration_ms=duration)
|
|
262
|
+
|
|
263
|
+
return "CONTINUE", None
|
|
264
|
+
|
|
265
|
+
def _finish_flow(
|
|
266
|
+
self, ctx: ExecutionContext[T], collected_errors: List[Exception], start_time: int
|
|
267
|
+
) -> Outcome[T]:
|
|
268
|
+
final_status: Literal["SUCCESS", "FAILED"] = (
|
|
269
|
+
"FAILED" if collected_errors else "SUCCESS"
|
|
270
|
+
)
|
|
271
|
+
ctx.log_event("INFO", self.name,
|
|
272
|
+
f"Flow Completed with status {final_status}")
|
|
273
|
+
|
|
274
|
+
if final_status == "SUCCESS":
|
|
275
|
+
ctx.completed_steps.add(self.name)
|
|
276
|
+
|
|
277
|
+
duration = (time.perf_counter_ns() - start_time) // 1_000_000
|
|
278
|
+
return Outcome(final_status, ctx, collected_errors, duration_ms=duration)
|
|
File without changes
|