stabilize 0.9.2__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.
- stabilize/__init__.py +29 -0
- stabilize/cli.py +1193 -0
- stabilize/context/__init__.py +7 -0
- stabilize/context/stage_context.py +170 -0
- stabilize/dag/__init__.py +15 -0
- stabilize/dag/graph.py +215 -0
- stabilize/dag/topological.py +199 -0
- stabilize/examples/__init__.py +1 -0
- stabilize/examples/docker-example.py +759 -0
- stabilize/examples/golden-standard-expected-result.txt +1 -0
- stabilize/examples/golden-standard.py +488 -0
- stabilize/examples/http-example.py +606 -0
- stabilize/examples/llama-example.py +662 -0
- stabilize/examples/python-example.py +731 -0
- stabilize/examples/shell-example.py +399 -0
- stabilize/examples/ssh-example.py +603 -0
- stabilize/handlers/__init__.py +53 -0
- stabilize/handlers/base.py +226 -0
- stabilize/handlers/complete_stage.py +209 -0
- stabilize/handlers/complete_task.py +75 -0
- stabilize/handlers/complete_workflow.py +150 -0
- stabilize/handlers/run_task.py +369 -0
- stabilize/handlers/start_stage.py +262 -0
- stabilize/handlers/start_task.py +74 -0
- stabilize/handlers/start_workflow.py +136 -0
- stabilize/launcher.py +307 -0
- stabilize/migrations/01KDQ4N9QPJ6Q4MCV3V9GHWPV4_initial_schema.sql +97 -0
- stabilize/migrations/01KDRK3TXW4R2GERC1WBCQYJGG_rag_embeddings.sql +25 -0
- stabilize/migrations/__init__.py +1 -0
- stabilize/models/__init__.py +15 -0
- stabilize/models/stage.py +389 -0
- stabilize/models/status.py +146 -0
- stabilize/models/task.py +125 -0
- stabilize/models/workflow.py +317 -0
- stabilize/orchestrator.py +113 -0
- stabilize/persistence/__init__.py +28 -0
- stabilize/persistence/connection.py +185 -0
- stabilize/persistence/factory.py +136 -0
- stabilize/persistence/memory.py +214 -0
- stabilize/persistence/postgres.py +655 -0
- stabilize/persistence/sqlite.py +674 -0
- stabilize/persistence/store.py +235 -0
- stabilize/queue/__init__.py +59 -0
- stabilize/queue/messages.py +377 -0
- stabilize/queue/processor.py +312 -0
- stabilize/queue/queue.py +526 -0
- stabilize/queue/sqlite_queue.py +354 -0
- stabilize/rag/__init__.py +19 -0
- stabilize/rag/assistant.py +459 -0
- stabilize/rag/cache.py +294 -0
- stabilize/stages/__init__.py +11 -0
- stabilize/stages/builder.py +253 -0
- stabilize/tasks/__init__.py +19 -0
- stabilize/tasks/interface.py +335 -0
- stabilize/tasks/registry.py +255 -0
- stabilize/tasks/result.py +283 -0
- stabilize-0.9.2.dist-info/METADATA +301 -0
- stabilize-0.9.2.dist-info/RECORD +61 -0
- stabilize-0.9.2.dist-info/WHEEL +4 -0
- stabilize-0.9.2.dist-info/entry_points.txt +2 -0
- stabilize-0.9.2.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RunTaskHandler - executes tasks.
|
|
3
|
+
|
|
4
|
+
This is the handler that actually runs task implementations.
|
|
5
|
+
It handles execution, retries, timeouts, and result processing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from datetime import timedelta
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from stabilize.handlers.base import StabilizeHandler
|
|
15
|
+
from stabilize.models.status import WorkflowStatus
|
|
16
|
+
from stabilize.queue.messages import (
|
|
17
|
+
CompleteTask,
|
|
18
|
+
PauseTask,
|
|
19
|
+
RunTask,
|
|
20
|
+
)
|
|
21
|
+
from stabilize.tasks.interface import RetryableTask, Task
|
|
22
|
+
from stabilize.tasks.registry import TaskNotFoundError, TaskRegistry
|
|
23
|
+
from stabilize.tasks.result import TaskResult
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from stabilize.models.stage import StageExecution
|
|
27
|
+
from stabilize.models.task import TaskExecution
|
|
28
|
+
from stabilize.persistence.store import WorkflowStore
|
|
29
|
+
from stabilize.queue.queue import Queue
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TimeoutError(Exception):
|
|
35
|
+
"""Raised when a task times out."""
|
|
36
|
+
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RunTaskHandler(StabilizeHandler[RunTask]):
|
|
41
|
+
"""
|
|
42
|
+
Handler for RunTask messages.
|
|
43
|
+
|
|
44
|
+
This is where tasks are actually executed. The handler:
|
|
45
|
+
1. Resolves the task implementation
|
|
46
|
+
2. Checks for cancellation/pause
|
|
47
|
+
3. Checks for timeout
|
|
48
|
+
4. Executes the task
|
|
49
|
+
5. Processes the result
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
queue: Queue,
|
|
55
|
+
repository: WorkflowStore,
|
|
56
|
+
task_registry: TaskRegistry,
|
|
57
|
+
retry_delay: timedelta = timedelta(seconds=15),
|
|
58
|
+
) -> None:
|
|
59
|
+
super().__init__(queue, repository, retry_delay)
|
|
60
|
+
self.task_registry = task_registry
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def message_type(self) -> type[RunTask]:
|
|
64
|
+
return RunTask
|
|
65
|
+
|
|
66
|
+
def handle(self, message: RunTask) -> None:
|
|
67
|
+
"""Handle the RunTask message."""
|
|
68
|
+
|
|
69
|
+
def on_task(stage: StageExecution, task_model: TaskExecution) -> None:
|
|
70
|
+
execution = stage.execution
|
|
71
|
+
|
|
72
|
+
# Resolve task implementation
|
|
73
|
+
try:
|
|
74
|
+
task = self._resolve_task(message.task_type, task_model)
|
|
75
|
+
except TaskNotFoundError as e:
|
|
76
|
+
logger.error("Task type not found: %s", message.task_type)
|
|
77
|
+
self._complete_with_error(stage, task_model, message, str(e))
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
# Check execution state
|
|
81
|
+
if execution.is_canceled:
|
|
82
|
+
self._handle_cancellation(stage, task_model, task, message)
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
if execution.status.is_complete:
|
|
86
|
+
self.queue.push(
|
|
87
|
+
CompleteTask(
|
|
88
|
+
execution_type=message.execution_type,
|
|
89
|
+
execution_id=message.execution_id,
|
|
90
|
+
stage_id=message.stage_id,
|
|
91
|
+
task_id=message.task_id,
|
|
92
|
+
status=WorkflowStatus.CANCELED,
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
if execution.status == WorkflowStatus.PAUSED:
|
|
98
|
+
self.queue.push(
|
|
99
|
+
PauseTask(
|
|
100
|
+
execution_type=message.execution_type,
|
|
101
|
+
execution_id=message.execution_id,
|
|
102
|
+
stage_id=message.stage_id,
|
|
103
|
+
task_id=message.task_id,
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
# Check for manual skip
|
|
109
|
+
if stage.context.get("manualSkip"):
|
|
110
|
+
self.queue.push(
|
|
111
|
+
CompleteTask(
|
|
112
|
+
execution_type=message.execution_type,
|
|
113
|
+
execution_id=message.execution_id,
|
|
114
|
+
stage_id=message.stage_id,
|
|
115
|
+
task_id=message.task_id,
|
|
116
|
+
status=WorkflowStatus.SKIPPED,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
# Check for timeout
|
|
122
|
+
try:
|
|
123
|
+
self._check_for_timeout(stage, task_model, task)
|
|
124
|
+
except TimeoutError as e:
|
|
125
|
+
logger.info("Task %s timed out: %s", task_model.name, e)
|
|
126
|
+
result = task.on_timeout(stage) if hasattr(task, "on_timeout") else None
|
|
127
|
+
if result is None:
|
|
128
|
+
self._complete_with_error(stage, task_model, message, str(e))
|
|
129
|
+
return
|
|
130
|
+
else:
|
|
131
|
+
self._process_result(stage, task_model, result, message)
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
# Execute the task
|
|
135
|
+
try:
|
|
136
|
+
result = task.execute(stage)
|
|
137
|
+
self._process_result(stage, task_model, result, message)
|
|
138
|
+
except Exception as e:
|
|
139
|
+
logger.error(
|
|
140
|
+
"Error executing task %s: %s",
|
|
141
|
+
task_model.name,
|
|
142
|
+
e,
|
|
143
|
+
exc_info=True,
|
|
144
|
+
)
|
|
145
|
+
self._handle_exception(stage, task_model, task, message, e)
|
|
146
|
+
|
|
147
|
+
self.with_task(message, on_task)
|
|
148
|
+
|
|
149
|
+
def _resolve_task(
|
|
150
|
+
self,
|
|
151
|
+
task_type: str,
|
|
152
|
+
task_model: TaskExecution,
|
|
153
|
+
) -> Task:
|
|
154
|
+
"""Resolve the task implementation."""
|
|
155
|
+
# Try by class name first
|
|
156
|
+
try:
|
|
157
|
+
return self.task_registry.get_by_class(task_model.implementing_class)
|
|
158
|
+
except TaskNotFoundError:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
# Try by type name
|
|
162
|
+
return self.task_registry.get(task_type)
|
|
163
|
+
|
|
164
|
+
def _check_for_timeout(
|
|
165
|
+
self,
|
|
166
|
+
stage: StageExecution,
|
|
167
|
+
task_model: TaskExecution,
|
|
168
|
+
task: Task,
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Check if task has timed out."""
|
|
171
|
+
if not isinstance(task, RetryableTask):
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
if task_model.start_time is None:
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
start_time = task_model.start_time
|
|
178
|
+
current_time = self.current_time_millis()
|
|
179
|
+
elapsed = timedelta(milliseconds=current_time - start_time)
|
|
180
|
+
|
|
181
|
+
# Get timeout (potentially dynamic)
|
|
182
|
+
timeout = task.get_dynamic_timeout(stage)
|
|
183
|
+
|
|
184
|
+
# Account for paused time
|
|
185
|
+
paused_duration = timedelta(milliseconds=stage.execution.paused_duration_relative_to(start_time))
|
|
186
|
+
actual_elapsed = elapsed - paused_duration
|
|
187
|
+
|
|
188
|
+
if actual_elapsed > timeout:
|
|
189
|
+
raise TimeoutError(f"Task timed out after {actual_elapsed} (timeout: {timeout})")
|
|
190
|
+
|
|
191
|
+
def _process_result(
|
|
192
|
+
self,
|
|
193
|
+
stage: StageExecution,
|
|
194
|
+
task_model: TaskExecution,
|
|
195
|
+
result: TaskResult,
|
|
196
|
+
message: RunTask,
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Process a task result."""
|
|
199
|
+
# Store outputs in stage
|
|
200
|
+
if result.context:
|
|
201
|
+
stage.context.update(result.context)
|
|
202
|
+
if result.outputs:
|
|
203
|
+
stage.outputs.update(result.outputs)
|
|
204
|
+
|
|
205
|
+
# Handle based on status
|
|
206
|
+
if result.status == WorkflowStatus.RUNNING:
|
|
207
|
+
# Task needs to be re-executed
|
|
208
|
+
self.repository.store_stage(stage)
|
|
209
|
+
|
|
210
|
+
# Calculate backoff
|
|
211
|
+
delay = self._get_backoff_period(stage, task_model, message)
|
|
212
|
+
self.queue.push(message, delay)
|
|
213
|
+
|
|
214
|
+
logger.debug(
|
|
215
|
+
"Task %s still running, re-queuing with %s delay",
|
|
216
|
+
task_model.name,
|
|
217
|
+
delay,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
elif result.status in {
|
|
221
|
+
WorkflowStatus.SUCCEEDED,
|
|
222
|
+
WorkflowStatus.REDIRECT,
|
|
223
|
+
WorkflowStatus.SKIPPED,
|
|
224
|
+
WorkflowStatus.FAILED_CONTINUE,
|
|
225
|
+
WorkflowStatus.STOPPED,
|
|
226
|
+
}:
|
|
227
|
+
self.repository.store_stage(stage)
|
|
228
|
+
self.queue.push(
|
|
229
|
+
CompleteTask(
|
|
230
|
+
execution_type=message.execution_type,
|
|
231
|
+
execution_id=message.execution_id,
|
|
232
|
+
stage_id=message.stage_id,
|
|
233
|
+
task_id=message.task_id,
|
|
234
|
+
status=result.status,
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
elif result.status == WorkflowStatus.CANCELED:
|
|
239
|
+
self.repository.store_stage(stage)
|
|
240
|
+
status = stage.failure_status(default=result.status)
|
|
241
|
+
self.queue.push(
|
|
242
|
+
CompleteTask(
|
|
243
|
+
execution_type=message.execution_type,
|
|
244
|
+
execution_id=message.execution_id,
|
|
245
|
+
stage_id=message.stage_id,
|
|
246
|
+
task_id=message.task_id,
|
|
247
|
+
status=status,
|
|
248
|
+
original_status=result.status,
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
elif result.status == WorkflowStatus.TERMINAL:
|
|
253
|
+
self.repository.store_stage(stage)
|
|
254
|
+
status = stage.failure_status(default=result.status)
|
|
255
|
+
self.queue.push(
|
|
256
|
+
CompleteTask(
|
|
257
|
+
execution_type=message.execution_type,
|
|
258
|
+
execution_id=message.execution_id,
|
|
259
|
+
stage_id=message.stage_id,
|
|
260
|
+
task_id=message.task_id,
|
|
261
|
+
status=status,
|
|
262
|
+
original_status=result.status,
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
else:
|
|
267
|
+
logger.warning("Unhandled task status: %s", result.status)
|
|
268
|
+
self.repository.store_stage(stage)
|
|
269
|
+
|
|
270
|
+
def _get_backoff_period(
|
|
271
|
+
self,
|
|
272
|
+
stage: StageExecution,
|
|
273
|
+
task_model: TaskExecution,
|
|
274
|
+
message: RunTask,
|
|
275
|
+
) -> timedelta:
|
|
276
|
+
"""Calculate backoff period for retry."""
|
|
277
|
+
# Try to get the task and use its backoff
|
|
278
|
+
try:
|
|
279
|
+
task = self._resolve_task(message.task_type, task_model)
|
|
280
|
+
if isinstance(task, RetryableTask):
|
|
281
|
+
elapsed = timedelta(milliseconds=self.current_time_millis() - (task_model.start_time or 0))
|
|
282
|
+
return task.get_dynamic_backoff_period(stage, elapsed)
|
|
283
|
+
except TaskNotFoundError:
|
|
284
|
+
pass
|
|
285
|
+
|
|
286
|
+
# Default backoff
|
|
287
|
+
return timedelta(seconds=1)
|
|
288
|
+
|
|
289
|
+
def _handle_cancellation(
|
|
290
|
+
self,
|
|
291
|
+
stage: StageExecution,
|
|
292
|
+
task_model: TaskExecution,
|
|
293
|
+
task: Task,
|
|
294
|
+
message: RunTask,
|
|
295
|
+
) -> None:
|
|
296
|
+
"""Handle execution cancellation."""
|
|
297
|
+
result = task.on_cancel(stage) if hasattr(task, "on_cancel") else None
|
|
298
|
+
if result:
|
|
299
|
+
stage.context.update(result.context)
|
|
300
|
+
stage.outputs.update(result.outputs)
|
|
301
|
+
self.repository.store_stage(stage)
|
|
302
|
+
|
|
303
|
+
self.queue.push(
|
|
304
|
+
CompleteTask(
|
|
305
|
+
execution_type=message.execution_type,
|
|
306
|
+
execution_id=message.execution_id,
|
|
307
|
+
stage_id=message.stage_id,
|
|
308
|
+
task_id=message.task_id,
|
|
309
|
+
status=WorkflowStatus.CANCELED,
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def _handle_exception(
|
|
314
|
+
self,
|
|
315
|
+
stage: StageExecution,
|
|
316
|
+
task_model: TaskExecution,
|
|
317
|
+
task: Task,
|
|
318
|
+
message: RunTask,
|
|
319
|
+
exception: Exception,
|
|
320
|
+
) -> None:
|
|
321
|
+
"""Handle task execution exception."""
|
|
322
|
+
# TODO: Check if exception is retryable
|
|
323
|
+
# For now, treat all exceptions as terminal
|
|
324
|
+
|
|
325
|
+
exception_details = {
|
|
326
|
+
"details": {
|
|
327
|
+
"error": str(exception),
|
|
328
|
+
"errors": [str(exception)],
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
stage.context["exception"] = exception_details
|
|
333
|
+
task_model.task_exception_details["exception"] = exception_details
|
|
334
|
+
self.repository.store_stage(stage)
|
|
335
|
+
|
|
336
|
+
status = stage.failure_status(default=WorkflowStatus.TERMINAL)
|
|
337
|
+
self.queue.push(
|
|
338
|
+
CompleteTask(
|
|
339
|
+
execution_type=message.execution_type,
|
|
340
|
+
execution_id=message.execution_id,
|
|
341
|
+
stage_id=message.stage_id,
|
|
342
|
+
task_id=message.task_id,
|
|
343
|
+
status=status,
|
|
344
|
+
original_status=WorkflowStatus.TERMINAL,
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
def _complete_with_error(
|
|
349
|
+
self,
|
|
350
|
+
stage: StageExecution,
|
|
351
|
+
task_model: TaskExecution,
|
|
352
|
+
message: RunTask,
|
|
353
|
+
error: str,
|
|
354
|
+
) -> None:
|
|
355
|
+
"""Complete task with an error."""
|
|
356
|
+
stage.context["exception"] = {
|
|
357
|
+
"details": {"error": error},
|
|
358
|
+
}
|
|
359
|
+
self.repository.store_stage(stage)
|
|
360
|
+
|
|
361
|
+
self.queue.push(
|
|
362
|
+
CompleteTask(
|
|
363
|
+
execution_type=message.execution_type,
|
|
364
|
+
execution_id=message.execution_id,
|
|
365
|
+
stage_id=message.stage_id,
|
|
366
|
+
task_id=message.task_id,
|
|
367
|
+
status=WorkflowStatus.TERMINAL,
|
|
368
|
+
)
|
|
369
|
+
)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""
|
|
2
|
+
StartStageHandler - handles stage startup.
|
|
3
|
+
|
|
4
|
+
This is one of the most critical handlers in the execution engine.
|
|
5
|
+
It checks if upstream stages are complete, plans the stage's tasks
|
|
6
|
+
and synthetic stages, and starts execution.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from stabilize.handlers.base import StabilizeHandler
|
|
15
|
+
from stabilize.models.status import WorkflowStatus
|
|
16
|
+
from stabilize.queue.messages import (
|
|
17
|
+
CompleteStage,
|
|
18
|
+
CompleteWorkflow,
|
|
19
|
+
SkipStage,
|
|
20
|
+
StartStage,
|
|
21
|
+
StartTask,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from stabilize.models.stage import StageExecution
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class StartStageHandler(StabilizeHandler[StartStage]):
|
|
31
|
+
"""
|
|
32
|
+
Handler for StartStage messages.
|
|
33
|
+
|
|
34
|
+
Execution flow:
|
|
35
|
+
1. Check if any upstream stages failed -> CompleteWorkflow
|
|
36
|
+
2. Check if all upstream stages complete
|
|
37
|
+
- If not: Re-queue with retry delay
|
|
38
|
+
- If yes: Continue to step 3
|
|
39
|
+
3. Check if stage should be skipped -> SkipStage
|
|
40
|
+
4. Check if start time expired -> SkipStage
|
|
41
|
+
5. Plan the stage (build tasks and before stages)
|
|
42
|
+
6. Start the stage:
|
|
43
|
+
- If has before stages: StartStage for each
|
|
44
|
+
- Else if has tasks: StartTask for first task
|
|
45
|
+
- Else: CompleteStage
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def message_type(self) -> type[StartStage]:
|
|
50
|
+
return StartStage
|
|
51
|
+
|
|
52
|
+
def handle(self, message: StartStage) -> None:
|
|
53
|
+
"""Handle the StartStage message."""
|
|
54
|
+
|
|
55
|
+
def on_stage(stage: StageExecution) -> None:
|
|
56
|
+
try:
|
|
57
|
+
# Check if upstream stages have failed
|
|
58
|
+
if stage.any_upstream_stages_failed():
|
|
59
|
+
logger.warning(
|
|
60
|
+
"Upstream stage failed for %s (%s), completing execution",
|
|
61
|
+
stage.name,
|
|
62
|
+
stage.id,
|
|
63
|
+
)
|
|
64
|
+
self.queue.push(
|
|
65
|
+
CompleteWorkflow(
|
|
66
|
+
execution_type=message.execution_type,
|
|
67
|
+
execution_id=message.execution_id,
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
# Check if all upstream stages are complete
|
|
73
|
+
if stage.all_upstream_stages_complete():
|
|
74
|
+
self._start_if_ready(stage, message)
|
|
75
|
+
else:
|
|
76
|
+
# Upstream not complete - re-queue
|
|
77
|
+
logger.debug(
|
|
78
|
+
"Re-queuing %s (%s) - upstream stages not complete",
|
|
79
|
+
stage.name,
|
|
80
|
+
stage.id,
|
|
81
|
+
)
|
|
82
|
+
self.queue.push(message, self.retry_delay)
|
|
83
|
+
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.error(
|
|
86
|
+
"Error starting stage %s (%s): %s",
|
|
87
|
+
stage.name,
|
|
88
|
+
stage.id,
|
|
89
|
+
e,
|
|
90
|
+
exc_info=True,
|
|
91
|
+
)
|
|
92
|
+
stage.context["exception"] = {
|
|
93
|
+
"details": {"error": str(e)},
|
|
94
|
+
}
|
|
95
|
+
stage.context["beforeStagePlanningFailed"] = True
|
|
96
|
+
self.repository.store_stage(stage)
|
|
97
|
+
self.queue.push(
|
|
98
|
+
CompleteStage(
|
|
99
|
+
execution_type=message.execution_type,
|
|
100
|
+
execution_id=message.execution_id,
|
|
101
|
+
stage_id=message.stage_id,
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
self.with_stage(message, on_stage)
|
|
106
|
+
|
|
107
|
+
def _start_if_ready(
|
|
108
|
+
self,
|
|
109
|
+
stage: StageExecution,
|
|
110
|
+
message: StartStage,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Start the stage if it's ready to run."""
|
|
113
|
+
# Check if already processed
|
|
114
|
+
if stage.status != WorkflowStatus.NOT_STARTED:
|
|
115
|
+
logger.warning(
|
|
116
|
+
"Ignoring StartStage for %s - already %s",
|
|
117
|
+
stage.name,
|
|
118
|
+
stage.status,
|
|
119
|
+
)
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
# Check if should skip
|
|
123
|
+
if self._should_skip(stage):
|
|
124
|
+
logger.info("Skipping optional stage %s", stage.name)
|
|
125
|
+
self.queue.push(
|
|
126
|
+
SkipStage(
|
|
127
|
+
execution_type=message.execution_type,
|
|
128
|
+
execution_id=message.execution_id,
|
|
129
|
+
stage_id=message.stage_id,
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
# Check if start time expired
|
|
135
|
+
if self._is_after_start_time_expiry(stage):
|
|
136
|
+
logger.warning("Stage %s start time expired, skipping", stage.name)
|
|
137
|
+
self.queue.push(
|
|
138
|
+
SkipStage(
|
|
139
|
+
execution_type=message.execution_type,
|
|
140
|
+
execution_id=message.execution_id,
|
|
141
|
+
stage_id=message.stage_id,
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
# Plan and start the stage
|
|
147
|
+
stage.start_time = self.current_time_millis()
|
|
148
|
+
self._plan_stage(stage)
|
|
149
|
+
stage.status = WorkflowStatus.RUNNING
|
|
150
|
+
self.repository.store_stage(stage)
|
|
151
|
+
|
|
152
|
+
self._start_stage(stage, message)
|
|
153
|
+
|
|
154
|
+
logger.info("Started stage %s (%s)", stage.name, stage.id)
|
|
155
|
+
|
|
156
|
+
def _plan_stage(self, stage: StageExecution) -> None:
|
|
157
|
+
"""
|
|
158
|
+
Plan the stage - build tasks and before stages.
|
|
159
|
+
|
|
160
|
+
This is where StageDefinitionBuilder.buildTasks() and
|
|
161
|
+
buildBeforeStages() would be called.
|
|
162
|
+
|
|
163
|
+
For now, we assume tasks are already defined on the stage.
|
|
164
|
+
"""
|
|
165
|
+
# Mark first and last tasks
|
|
166
|
+
if stage.tasks:
|
|
167
|
+
stage.tasks[0].stage_start = True
|
|
168
|
+
stage.tasks[-1].stage_end = True
|
|
169
|
+
|
|
170
|
+
# TODO: Call stage definition builder to:
|
|
171
|
+
# 1. Build tasks
|
|
172
|
+
# 2. Build before stages
|
|
173
|
+
# 3. Add context flags
|
|
174
|
+
|
|
175
|
+
def _start_stage(
|
|
176
|
+
self,
|
|
177
|
+
stage: StageExecution,
|
|
178
|
+
message: StartStage,
|
|
179
|
+
) -> None:
|
|
180
|
+
"""
|
|
181
|
+
Start stage execution.
|
|
182
|
+
|
|
183
|
+
Order of execution:
|
|
184
|
+
1. Before stages (if any)
|
|
185
|
+
2. Tasks (if any)
|
|
186
|
+
3. After stages (if any)
|
|
187
|
+
4. Complete stage
|
|
188
|
+
"""
|
|
189
|
+
# Check for before stages
|
|
190
|
+
before_stages = stage.first_before_stages()
|
|
191
|
+
if before_stages:
|
|
192
|
+
for before in before_stages:
|
|
193
|
+
self.queue.push(
|
|
194
|
+
StartStage(
|
|
195
|
+
execution_type=message.execution_type,
|
|
196
|
+
execution_id=message.execution_id,
|
|
197
|
+
stage_id=before.id,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
# No before stages - start first task
|
|
203
|
+
first_task = stage.first_task()
|
|
204
|
+
if first_task:
|
|
205
|
+
self.queue.push(
|
|
206
|
+
StartTask(
|
|
207
|
+
execution_type=message.execution_type,
|
|
208
|
+
execution_id=message.execution_id,
|
|
209
|
+
stage_id=message.stage_id,
|
|
210
|
+
task_id=first_task.id,
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
# No tasks - check for after stages
|
|
216
|
+
after_stages = stage.first_after_stages()
|
|
217
|
+
if after_stages:
|
|
218
|
+
for after in after_stages:
|
|
219
|
+
self.queue.push(
|
|
220
|
+
StartStage(
|
|
221
|
+
execution_type=message.execution_type,
|
|
222
|
+
execution_id=message.execution_id,
|
|
223
|
+
stage_id=after.id,
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
# No tasks or synthetic stages - complete immediately
|
|
229
|
+
self.queue.push(
|
|
230
|
+
CompleteStage(
|
|
231
|
+
execution_type=message.execution_type,
|
|
232
|
+
execution_id=message.execution_id,
|
|
233
|
+
stage_id=message.stage_id,
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def _should_skip(self, stage: StageExecution) -> bool:
|
|
238
|
+
"""
|
|
239
|
+
Check if stage should be skipped.
|
|
240
|
+
|
|
241
|
+
Checks the 'stageEnabled' context for conditional execution.
|
|
242
|
+
"""
|
|
243
|
+
stage_enabled = stage.context.get("stageEnabled")
|
|
244
|
+
if stage_enabled is None:
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
if isinstance(stage_enabled, dict):
|
|
248
|
+
expr_type = stage_enabled.get("type")
|
|
249
|
+
if expr_type == "expression":
|
|
250
|
+
# TODO: Evaluate expression
|
|
251
|
+
expression = stage_enabled.get("expression", "true")
|
|
252
|
+
# For now, just check if it's explicitly "false"
|
|
253
|
+
if isinstance(expression, str):
|
|
254
|
+
return expression.lower() == "false"
|
|
255
|
+
|
|
256
|
+
return False
|
|
257
|
+
|
|
258
|
+
def _is_after_start_time_expiry(self, stage: StageExecution) -> bool:
|
|
259
|
+
"""Check if current time is past start time expiry."""
|
|
260
|
+
if stage.start_time_expiry is None:
|
|
261
|
+
return False
|
|
262
|
+
return self.current_time_millis() > stage.start_time_expiry
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
StartTaskHandler - handles task startup.
|
|
3
|
+
|
|
4
|
+
This handler prepares a task for execution and triggers RunTask.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from stabilize.handlers.base import StabilizeHandler
|
|
13
|
+
from stabilize.models.status import WorkflowStatus
|
|
14
|
+
from stabilize.queue.messages import (
|
|
15
|
+
RunTask,
|
|
16
|
+
StartTask,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from stabilize.models.stage import StageExecution
|
|
21
|
+
from stabilize.models.task import TaskExecution
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StartTaskHandler(StabilizeHandler[StartTask]):
|
|
27
|
+
"""
|
|
28
|
+
Handler for StartTask messages.
|
|
29
|
+
|
|
30
|
+
Execution flow:
|
|
31
|
+
1. Check if task is enabled (SkippableTask)
|
|
32
|
+
- If not: Push CompleteTask(SKIPPED)
|
|
33
|
+
2. Set task status to RUNNING
|
|
34
|
+
3. Set task start time
|
|
35
|
+
4. Push RunTask
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def message_type(self) -> type[StartTask]:
|
|
40
|
+
return StartTask
|
|
41
|
+
|
|
42
|
+
def handle(self, message: StartTask) -> None:
|
|
43
|
+
"""Handle the StartTask message."""
|
|
44
|
+
|
|
45
|
+
def on_task(stage: StageExecution, task: TaskExecution) -> None:
|
|
46
|
+
# Check if task should be skipped
|
|
47
|
+
# TODO: Check SkippableTask.isEnabled()
|
|
48
|
+
|
|
49
|
+
# Update task status
|
|
50
|
+
task.status = WorkflowStatus.RUNNING
|
|
51
|
+
task.start_time = self.current_time_millis()
|
|
52
|
+
|
|
53
|
+
# Save the stage (which includes the task)
|
|
54
|
+
self.repository.store_stage(stage)
|
|
55
|
+
|
|
56
|
+
# Push RunTask
|
|
57
|
+
self.queue.push(
|
|
58
|
+
RunTask(
|
|
59
|
+
execution_type=message.execution_type,
|
|
60
|
+
execution_id=message.execution_id,
|
|
61
|
+
stage_id=message.stage_id,
|
|
62
|
+
task_id=message.task_id,
|
|
63
|
+
task_type=task.implementing_class,
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
logger.debug(
|
|
68
|
+
"Started task %s (%s) in stage %s",
|
|
69
|
+
task.name,
|
|
70
|
+
task.id,
|
|
71
|
+
stage.name,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
self.with_task(message, on_task)
|