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.
Files changed (61) hide show
  1. stabilize/__init__.py +29 -0
  2. stabilize/cli.py +1193 -0
  3. stabilize/context/__init__.py +7 -0
  4. stabilize/context/stage_context.py +170 -0
  5. stabilize/dag/__init__.py +15 -0
  6. stabilize/dag/graph.py +215 -0
  7. stabilize/dag/topological.py +199 -0
  8. stabilize/examples/__init__.py +1 -0
  9. stabilize/examples/docker-example.py +759 -0
  10. stabilize/examples/golden-standard-expected-result.txt +1 -0
  11. stabilize/examples/golden-standard.py +488 -0
  12. stabilize/examples/http-example.py +606 -0
  13. stabilize/examples/llama-example.py +662 -0
  14. stabilize/examples/python-example.py +731 -0
  15. stabilize/examples/shell-example.py +399 -0
  16. stabilize/examples/ssh-example.py +603 -0
  17. stabilize/handlers/__init__.py +53 -0
  18. stabilize/handlers/base.py +226 -0
  19. stabilize/handlers/complete_stage.py +209 -0
  20. stabilize/handlers/complete_task.py +75 -0
  21. stabilize/handlers/complete_workflow.py +150 -0
  22. stabilize/handlers/run_task.py +369 -0
  23. stabilize/handlers/start_stage.py +262 -0
  24. stabilize/handlers/start_task.py +74 -0
  25. stabilize/handlers/start_workflow.py +136 -0
  26. stabilize/launcher.py +307 -0
  27. stabilize/migrations/01KDQ4N9QPJ6Q4MCV3V9GHWPV4_initial_schema.sql +97 -0
  28. stabilize/migrations/01KDRK3TXW4R2GERC1WBCQYJGG_rag_embeddings.sql +25 -0
  29. stabilize/migrations/__init__.py +1 -0
  30. stabilize/models/__init__.py +15 -0
  31. stabilize/models/stage.py +389 -0
  32. stabilize/models/status.py +146 -0
  33. stabilize/models/task.py +125 -0
  34. stabilize/models/workflow.py +317 -0
  35. stabilize/orchestrator.py +113 -0
  36. stabilize/persistence/__init__.py +28 -0
  37. stabilize/persistence/connection.py +185 -0
  38. stabilize/persistence/factory.py +136 -0
  39. stabilize/persistence/memory.py +214 -0
  40. stabilize/persistence/postgres.py +655 -0
  41. stabilize/persistence/sqlite.py +674 -0
  42. stabilize/persistence/store.py +235 -0
  43. stabilize/queue/__init__.py +59 -0
  44. stabilize/queue/messages.py +377 -0
  45. stabilize/queue/processor.py +312 -0
  46. stabilize/queue/queue.py +526 -0
  47. stabilize/queue/sqlite_queue.py +354 -0
  48. stabilize/rag/__init__.py +19 -0
  49. stabilize/rag/assistant.py +459 -0
  50. stabilize/rag/cache.py +294 -0
  51. stabilize/stages/__init__.py +11 -0
  52. stabilize/stages/builder.py +253 -0
  53. stabilize/tasks/__init__.py +19 -0
  54. stabilize/tasks/interface.py +335 -0
  55. stabilize/tasks/registry.py +255 -0
  56. stabilize/tasks/result.py +283 -0
  57. stabilize-0.9.2.dist-info/METADATA +301 -0
  58. stabilize-0.9.2.dist-info/RECORD +61 -0
  59. stabilize-0.9.2.dist-info/WHEEL +4 -0
  60. stabilize-0.9.2.dist-info/entry_points.txt +2 -0
  61. 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)