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 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