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,226 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base message handler classes.
|
|
3
|
+
|
|
4
|
+
This module provides the base classes for all message handlers in the
|
|
5
|
+
pipeline execution engine.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from datetime import timedelta
|
|
15
|
+
from typing import TYPE_CHECKING, Generic, TypeVar
|
|
16
|
+
|
|
17
|
+
from stabilize.models.stage import StageExecution
|
|
18
|
+
from stabilize.models.task import TaskExecution
|
|
19
|
+
from stabilize.models.workflow import Workflow
|
|
20
|
+
from stabilize.queue.messages import (
|
|
21
|
+
CompleteWorkflow,
|
|
22
|
+
ContinueParentStage,
|
|
23
|
+
InvalidStageId,
|
|
24
|
+
InvalidTaskId,
|
|
25
|
+
InvalidWorkflowId,
|
|
26
|
+
Message,
|
|
27
|
+
StageLevel,
|
|
28
|
+
StartStage,
|
|
29
|
+
TaskLevel,
|
|
30
|
+
WorkflowLevel,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from stabilize.persistence.store import WorkflowStore
|
|
35
|
+
from stabilize.queue.queue import Queue
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
M = TypeVar("M", bound=Message)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MessageHandler(ABC, Generic[M]):
|
|
43
|
+
"""
|
|
44
|
+
Base class for message handlers.
|
|
45
|
+
|
|
46
|
+
Each handler processes a specific type of message.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def message_type(self) -> type[M]:
|
|
52
|
+
"""Return the type of message this handler processes."""
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def handle(self, message: M) -> None:
|
|
57
|
+
"""Handle a message."""
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class StabilizeHandler(MessageHandler[M], ABC):
|
|
62
|
+
"""
|
|
63
|
+
Base handler with common utilities.
|
|
64
|
+
|
|
65
|
+
Provides helper methods for retrieving executions, stages, and tasks,
|
|
66
|
+
as well as the startNext() implementation.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
queue: Queue,
|
|
72
|
+
repository: WorkflowStore,
|
|
73
|
+
retry_delay: timedelta = timedelta(seconds=15),
|
|
74
|
+
) -> None:
|
|
75
|
+
self.queue = queue
|
|
76
|
+
self.repository = repository
|
|
77
|
+
self.retry_delay = retry_delay
|
|
78
|
+
|
|
79
|
+
# ========== Execution Retrieval ==========
|
|
80
|
+
|
|
81
|
+
def with_execution(
|
|
82
|
+
self,
|
|
83
|
+
message: WorkflowLevel,
|
|
84
|
+
block: Callable[[Workflow], None],
|
|
85
|
+
) -> None:
|
|
86
|
+
"""
|
|
87
|
+
Execute a block with the execution for a message.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
message: Message containing execution ID
|
|
91
|
+
block: Function to call with the execution
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
execution = self.repository.retrieve(message.execution_id)
|
|
95
|
+
block(execution)
|
|
96
|
+
except Exception as e:
|
|
97
|
+
logger.error("Failed to retrieve execution %s: %s", message.execution_id, e)
|
|
98
|
+
self.queue.push(
|
|
99
|
+
InvalidWorkflowId(
|
|
100
|
+
execution_type=message.execution_type,
|
|
101
|
+
execution_id=message.execution_id,
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def with_stage(
|
|
106
|
+
self,
|
|
107
|
+
message: StageLevel,
|
|
108
|
+
block: Callable[[StageExecution], None],
|
|
109
|
+
) -> None:
|
|
110
|
+
"""
|
|
111
|
+
Execute a block with the stage for a message.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
message: Message containing stage ID
|
|
115
|
+
block: Function to call with the stage
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def on_execution(execution: Workflow) -> None:
|
|
119
|
+
try:
|
|
120
|
+
stage = execution.stage_by_id(message.stage_id)
|
|
121
|
+
block(stage)
|
|
122
|
+
except ValueError:
|
|
123
|
+
logger.error("Stage not found: %s", message.stage_id)
|
|
124
|
+
self.queue.push(
|
|
125
|
+
InvalidStageId(
|
|
126
|
+
execution_type=message.execution_type,
|
|
127
|
+
execution_id=message.execution_id,
|
|
128
|
+
stage_id=message.stage_id,
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
self.with_execution(message, on_execution)
|
|
133
|
+
|
|
134
|
+
def with_task(
|
|
135
|
+
self,
|
|
136
|
+
message: TaskLevel,
|
|
137
|
+
block: Callable[[StageExecution, TaskExecution], None],
|
|
138
|
+
) -> None:
|
|
139
|
+
"""
|
|
140
|
+
Execute a block with the stage and task for a message.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
message: Message containing task ID
|
|
144
|
+
block: Function to call with (stage, task)
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def on_stage(stage: StageExecution) -> None:
|
|
148
|
+
task = self._find_task(stage, message.task_id)
|
|
149
|
+
if task is None:
|
|
150
|
+
logger.error("Task not found: %s", message.task_id)
|
|
151
|
+
self.queue.push(
|
|
152
|
+
InvalidTaskId(
|
|
153
|
+
execution_type=message.execution_type,
|
|
154
|
+
execution_id=message.execution_id,
|
|
155
|
+
stage_id=message.stage_id,
|
|
156
|
+
task_id=message.task_id,
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
else:
|
|
160
|
+
block(stage, task)
|
|
161
|
+
|
|
162
|
+
self.with_stage(message, on_stage)
|
|
163
|
+
|
|
164
|
+
def _find_task(
|
|
165
|
+
self,
|
|
166
|
+
stage: StageExecution,
|
|
167
|
+
task_id: str,
|
|
168
|
+
) -> TaskExecution | None:
|
|
169
|
+
"""Find a task by ID in a stage."""
|
|
170
|
+
for task in stage.tasks:
|
|
171
|
+
if task.id == task_id:
|
|
172
|
+
return task
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
# ========== Stage Navigation ==========
|
|
176
|
+
|
|
177
|
+
def start_next(self, stage: StageExecution) -> None:
|
|
178
|
+
"""
|
|
179
|
+
Start the next stage(s) after a stage completes.
|
|
180
|
+
|
|
181
|
+
This is the critical method for DAG traversal:
|
|
182
|
+
1. Find downstream stages (those that depend on this stage)
|
|
183
|
+
2. Push StartStage for each downstream stage
|
|
184
|
+
3. If this is a synthetic stage, notify parent
|
|
185
|
+
4. If no downstream and not synthetic, complete execution
|
|
186
|
+
"""
|
|
187
|
+
execution = stage.execution
|
|
188
|
+
downstream_stages = stage.downstream_stages()
|
|
189
|
+
phase = stage.synthetic_stage_owner
|
|
190
|
+
|
|
191
|
+
if downstream_stages:
|
|
192
|
+
# Start all downstream stages
|
|
193
|
+
for downstream in downstream_stages:
|
|
194
|
+
self.queue.push(
|
|
195
|
+
StartStage(
|
|
196
|
+
execution_type=execution.type.value,
|
|
197
|
+
execution_id=execution.id,
|
|
198
|
+
stage_id=downstream.id,
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
elif phase is not None:
|
|
202
|
+
# Synthetic stage - notify parent
|
|
203
|
+
parent = stage.parent()
|
|
204
|
+
self.queue.ensure(
|
|
205
|
+
ContinueParentStage(
|
|
206
|
+
execution_type=execution.type.value,
|
|
207
|
+
execution_id=execution.id,
|
|
208
|
+
stage_id=parent.id,
|
|
209
|
+
phase=phase,
|
|
210
|
+
),
|
|
211
|
+
timedelta(seconds=0),
|
|
212
|
+
)
|
|
213
|
+
else:
|
|
214
|
+
# Top-level stage with no downstream - complete execution
|
|
215
|
+
self.queue.push(
|
|
216
|
+
CompleteWorkflow(
|
|
217
|
+
execution_type=execution.type.value,
|
|
218
|
+
execution_id=execution.id,
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# ========== Utility Methods ==========
|
|
223
|
+
|
|
224
|
+
def current_time_millis(self) -> int:
|
|
225
|
+
"""Get current time in milliseconds."""
|
|
226
|
+
return int(time.time() * 1000)
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CompleteStageHandler - handles stage completion.
|
|
3
|
+
|
|
4
|
+
This is a critical handler that:
|
|
5
|
+
1. Determines stage status from tasks and synthetic stages
|
|
6
|
+
2. Plans and starts after stages
|
|
7
|
+
3. Plans and starts on-failure stages
|
|
8
|
+
4. Triggers downstream stages via startNext()
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from stabilize.handlers.base import StabilizeHandler
|
|
17
|
+
from stabilize.models.status import WorkflowStatus
|
|
18
|
+
from stabilize.queue.messages import (
|
|
19
|
+
CancelStage,
|
|
20
|
+
CompleteStage,
|
|
21
|
+
CompleteWorkflow,
|
|
22
|
+
StartStage,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from stabilize.models.stage import StageExecution
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CompleteStageHandler(StabilizeHandler[CompleteStage]):
|
|
32
|
+
"""
|
|
33
|
+
Handler for CompleteStage messages.
|
|
34
|
+
|
|
35
|
+
Execution flow:
|
|
36
|
+
1. Check if stage already complete
|
|
37
|
+
2. Determine status from synthetic stages and tasks
|
|
38
|
+
3. If success: Plan and start after stages
|
|
39
|
+
4. If failure: Plan and start on-failure stages
|
|
40
|
+
5. Update stage status and end time
|
|
41
|
+
6. If status allows continuation: startNext()
|
|
42
|
+
7. Otherwise: CancelStage + CompleteWorkflow
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def message_type(self) -> type[CompleteStage]:
|
|
47
|
+
return CompleteStage
|
|
48
|
+
|
|
49
|
+
def handle(self, message: CompleteStage) -> None:
|
|
50
|
+
"""Handle the CompleteStage message."""
|
|
51
|
+
|
|
52
|
+
def on_stage(stage: StageExecution) -> None:
|
|
53
|
+
# Check if already complete
|
|
54
|
+
if stage.status not in {WorkflowStatus.RUNNING, WorkflowStatus.NOT_STARTED}:
|
|
55
|
+
logger.debug(
|
|
56
|
+
"Stage %s already has status %s, ignoring CompleteStage",
|
|
57
|
+
stage.name,
|
|
58
|
+
stage.status,
|
|
59
|
+
)
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
# Determine status from tasks and synthetic stages
|
|
64
|
+
status = stage.determine_status()
|
|
65
|
+
|
|
66
|
+
# Handle after stages
|
|
67
|
+
if status.is_complete and not status.is_halt:
|
|
68
|
+
after_stages = stage.first_after_stages()
|
|
69
|
+
if not after_stages:
|
|
70
|
+
self._plan_after_stages(stage)
|
|
71
|
+
after_stages = stage.first_after_stages()
|
|
72
|
+
|
|
73
|
+
not_started = [s for s in after_stages if s.status == WorkflowStatus.NOT_STARTED]
|
|
74
|
+
if not_started:
|
|
75
|
+
for s in not_started:
|
|
76
|
+
self.queue.push(
|
|
77
|
+
StartStage(
|
|
78
|
+
execution_type=message.execution_type,
|
|
79
|
+
execution_id=message.execution_id,
|
|
80
|
+
stage_id=s.id,
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
# If status is NOT_STARTED with no after stages, it's weird
|
|
86
|
+
if status == WorkflowStatus.NOT_STARTED:
|
|
87
|
+
logger.warning("Stage %s had no tasks or synthetic stages", stage.name)
|
|
88
|
+
status = WorkflowStatus.SKIPPED
|
|
89
|
+
|
|
90
|
+
# Handle failure - plan on-failure stages
|
|
91
|
+
elif status.is_failure:
|
|
92
|
+
has_on_failure = self._plan_on_failure_stages(stage)
|
|
93
|
+
if has_on_failure:
|
|
94
|
+
after_stages = stage.first_after_stages()
|
|
95
|
+
for s in after_stages:
|
|
96
|
+
self.queue.push(
|
|
97
|
+
StartStage(
|
|
98
|
+
execution_type=message.execution_type,
|
|
99
|
+
execution_id=message.execution_id,
|
|
100
|
+
stage_id=s.id,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
# Update stage status
|
|
106
|
+
stage.status = status
|
|
107
|
+
stage.end_time = self.current_time_millis()
|
|
108
|
+
self.repository.store_stage(stage)
|
|
109
|
+
|
|
110
|
+
logger.info("Stage %s completed with status %s", stage.name, status)
|
|
111
|
+
|
|
112
|
+
# Handle FAILED_CONTINUE propagation to parent
|
|
113
|
+
if (
|
|
114
|
+
status == WorkflowStatus.FAILED_CONTINUE
|
|
115
|
+
and stage.synthetic_stage_owner is not None
|
|
116
|
+
and not stage.allow_sibling_stages_to_continue_on_failure
|
|
117
|
+
and stage.parent_stage_id is not None
|
|
118
|
+
):
|
|
119
|
+
# Propagate failure to parent
|
|
120
|
+
self.queue.push(
|
|
121
|
+
CompleteStage(
|
|
122
|
+
execution_type=message.execution_type,
|
|
123
|
+
execution_id=message.execution_id,
|
|
124
|
+
stage_id=stage.parent_stage_id,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
elif status in {
|
|
128
|
+
WorkflowStatus.SUCCEEDED,
|
|
129
|
+
WorkflowStatus.FAILED_CONTINUE,
|
|
130
|
+
WorkflowStatus.SKIPPED,
|
|
131
|
+
}:
|
|
132
|
+
# Continue to downstream stages
|
|
133
|
+
self.start_next(stage)
|
|
134
|
+
else:
|
|
135
|
+
# Failure - cancel and complete execution
|
|
136
|
+
self.queue.push(
|
|
137
|
+
CancelStage(
|
|
138
|
+
execution_type=message.execution_type,
|
|
139
|
+
execution_id=message.execution_id,
|
|
140
|
+
stage_id=message.stage_id,
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
if stage.synthetic_stage_owner is None or stage.parent_stage_id is None:
|
|
144
|
+
self.queue.push(
|
|
145
|
+
CompleteWorkflow(
|
|
146
|
+
execution_type=message.execution_type,
|
|
147
|
+
execution_id=message.execution_id,
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
else:
|
|
151
|
+
# Propagate to parent
|
|
152
|
+
self.queue.push(
|
|
153
|
+
CompleteStage(
|
|
154
|
+
execution_type=message.execution_type,
|
|
155
|
+
execution_id=message.execution_id,
|
|
156
|
+
stage_id=stage.parent_stage_id,
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
except Exception as e:
|
|
161
|
+
logger.error(
|
|
162
|
+
"Error completing stage %s: %s",
|
|
163
|
+
stage.name,
|
|
164
|
+
e,
|
|
165
|
+
exc_info=True,
|
|
166
|
+
)
|
|
167
|
+
stage.context["exception"] = {
|
|
168
|
+
"details": {"error": str(e)},
|
|
169
|
+
}
|
|
170
|
+
stage.status = WorkflowStatus.TERMINAL
|
|
171
|
+
stage.end_time = self.current_time_millis()
|
|
172
|
+
self.repository.store_stage(stage)
|
|
173
|
+
|
|
174
|
+
self.queue.push(
|
|
175
|
+
CancelStage(
|
|
176
|
+
execution_type=message.execution_type,
|
|
177
|
+
execution_id=message.execution_id,
|
|
178
|
+
stage_id=message.stage_id,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
self.queue.push(
|
|
182
|
+
CompleteWorkflow(
|
|
183
|
+
execution_type=message.execution_type,
|
|
184
|
+
execution_id=message.execution_id,
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
self.with_stage(message, on_stage)
|
|
189
|
+
|
|
190
|
+
def _plan_after_stages(self, stage: StageExecution) -> None:
|
|
191
|
+
"""
|
|
192
|
+
Plan after stages using the stage definition builder.
|
|
193
|
+
|
|
194
|
+
TODO: Implement StageDefinitionBuilder integration
|
|
195
|
+
"""
|
|
196
|
+
# For now, do nothing - after stages should be pre-defined
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
def _plan_on_failure_stages(self, stage: StageExecution) -> bool:
|
|
200
|
+
"""
|
|
201
|
+
Plan on-failure stages using the stage definition builder.
|
|
202
|
+
|
|
203
|
+
TODO: Implement StageDefinitionBuilder integration
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
True if on-failure stages were added
|
|
207
|
+
"""
|
|
208
|
+
# For now, return False - no on-failure stages
|
|
209
|
+
return False
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CompleteTaskHandler - handles task completion.
|
|
3
|
+
|
|
4
|
+
This handler updates task status and triggers either the next task
|
|
5
|
+
or stage completion.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from stabilize.handlers.base import StabilizeHandler
|
|
14
|
+
from stabilize.queue.messages import (
|
|
15
|
+
CompleteStage,
|
|
16
|
+
CompleteTask,
|
|
17
|
+
StartTask,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from stabilize.models.stage import StageExecution
|
|
22
|
+
from stabilize.models.task import TaskExecution
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CompleteTaskHandler(StabilizeHandler[CompleteTask]):
|
|
28
|
+
"""
|
|
29
|
+
Handler for CompleteTask messages.
|
|
30
|
+
|
|
31
|
+
Execution flow:
|
|
32
|
+
1. Update task status and end time
|
|
33
|
+
2. If there's a next task: Push StartTask
|
|
34
|
+
3. Otherwise: Push CompleteStage
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def message_type(self) -> type[CompleteTask]:
|
|
39
|
+
return CompleteTask
|
|
40
|
+
|
|
41
|
+
def handle(self, message: CompleteTask) -> None:
|
|
42
|
+
"""Handle the CompleteTask message."""
|
|
43
|
+
|
|
44
|
+
def on_task(stage: StageExecution, task: TaskExecution) -> None:
|
|
45
|
+
# Update task status
|
|
46
|
+
task.status = message.status
|
|
47
|
+
task.end_time = self.current_time_millis()
|
|
48
|
+
|
|
49
|
+
# Save the stage
|
|
50
|
+
self.repository.store_stage(stage)
|
|
51
|
+
|
|
52
|
+
logger.debug("Task %s completed with status %s", task.name, message.status)
|
|
53
|
+
|
|
54
|
+
# Check for next task
|
|
55
|
+
next_task = stage.next_task(task)
|
|
56
|
+
if next_task is not None:
|
|
57
|
+
self.queue.push(
|
|
58
|
+
StartTask(
|
|
59
|
+
execution_type=message.execution_type,
|
|
60
|
+
execution_id=message.execution_id,
|
|
61
|
+
stage_id=message.stage_id,
|
|
62
|
+
task_id=next_task.id,
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
else:
|
|
66
|
+
# No more tasks - complete stage
|
|
67
|
+
self.queue.push(
|
|
68
|
+
CompleteStage(
|
|
69
|
+
execution_type=message.execution_type,
|
|
70
|
+
execution_id=message.execution_id,
|
|
71
|
+
stage_id=message.stage_id,
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
self.with_task(message, on_task)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CompleteWorkflowHandler - handles execution completion.
|
|
3
|
+
|
|
4
|
+
This handler determines the final execution status based on all
|
|
5
|
+
top-level stages and marks the execution as complete.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from stabilize.handlers.base import StabilizeHandler
|
|
14
|
+
from stabilize.models.status import CONTINUABLE_STATUSES, WorkflowStatus
|
|
15
|
+
from stabilize.queue.messages import (
|
|
16
|
+
CancelStage,
|
|
17
|
+
CompleteWorkflow,
|
|
18
|
+
StartWaitingWorkflows,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from stabilize.models.stage import StageExecution
|
|
23
|
+
from stabilize.models.workflow import Workflow
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CompleteWorkflowHandler(StabilizeHandler[CompleteWorkflow]):
|
|
29
|
+
"""
|
|
30
|
+
Handler for CompleteWorkflow messages.
|
|
31
|
+
|
|
32
|
+
Execution flow:
|
|
33
|
+
1. Check if execution already complete
|
|
34
|
+
2. Determine final status from top-level stages
|
|
35
|
+
3. Update execution status
|
|
36
|
+
4. Cancel any running stages if failed
|
|
37
|
+
5. Start waiting executions if queue is enabled
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def message_type(self) -> type[CompleteWorkflow]:
|
|
42
|
+
return CompleteWorkflow
|
|
43
|
+
|
|
44
|
+
def handle(self, message: CompleteWorkflow) -> None:
|
|
45
|
+
"""Handle the CompleteWorkflow message."""
|
|
46
|
+
|
|
47
|
+
def on_execution(execution: Workflow) -> None:
|
|
48
|
+
# Check if already complete
|
|
49
|
+
if execution.status.is_complete:
|
|
50
|
+
logger.debug(
|
|
51
|
+
"Execution %s already complete with status %s",
|
|
52
|
+
execution.id,
|
|
53
|
+
execution.status,
|
|
54
|
+
)
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
# Determine final status
|
|
58
|
+
status = self._determine_final_status(execution, message)
|
|
59
|
+
if status is None:
|
|
60
|
+
# Not ready to complete - stages still running
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
# Update execution
|
|
64
|
+
execution.status = status
|
|
65
|
+
execution.end_time = self.current_time_millis()
|
|
66
|
+
self.repository.update_status(execution)
|
|
67
|
+
|
|
68
|
+
logger.info("Execution %s completed with status %s", execution.id, status)
|
|
69
|
+
|
|
70
|
+
# Cancel any running stages if not successful
|
|
71
|
+
if status != WorkflowStatus.SUCCEEDED:
|
|
72
|
+
running_stages = [s for s in execution.top_level_stages() if s.status == WorkflowStatus.RUNNING]
|
|
73
|
+
for stage in running_stages:
|
|
74
|
+
self.queue.push(
|
|
75
|
+
CancelStage(
|
|
76
|
+
execution_type=message.execution_type,
|
|
77
|
+
execution_id=message.execution_id,
|
|
78
|
+
stage_id=stage.id,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Start waiting executions if configured
|
|
83
|
+
if execution.status != WorkflowStatus.RUNNING and execution.pipeline_config_id:
|
|
84
|
+
self.queue.push(
|
|
85
|
+
StartWaitingWorkflows(
|
|
86
|
+
pipeline_config_id=execution.pipeline_config_id,
|
|
87
|
+
purge_queue=not execution.keep_waiting_pipelines,
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
self.with_execution(message, on_execution)
|
|
92
|
+
|
|
93
|
+
def _determine_final_status(
|
|
94
|
+
self,
|
|
95
|
+
execution: Workflow,
|
|
96
|
+
message: CompleteWorkflow,
|
|
97
|
+
) -> WorkflowStatus | None:
|
|
98
|
+
"""
|
|
99
|
+
Determine the final execution status.
|
|
100
|
+
|
|
101
|
+
Returns None if execution is not ready to complete.
|
|
102
|
+
"""
|
|
103
|
+
stages = execution.top_level_stages()
|
|
104
|
+
statuses = [s.status for s in stages]
|
|
105
|
+
|
|
106
|
+
# All succeeded/skipped/failed_continue -> SUCCEEDED
|
|
107
|
+
if all(s in CONTINUABLE_STATUSES for s in statuses):
|
|
108
|
+
return WorkflowStatus.SUCCEEDED
|
|
109
|
+
|
|
110
|
+
# Any TERMINAL -> TERMINAL
|
|
111
|
+
if WorkflowStatus.TERMINAL in statuses:
|
|
112
|
+
return WorkflowStatus.TERMINAL
|
|
113
|
+
|
|
114
|
+
# Any CANCELED -> CANCELED
|
|
115
|
+
if WorkflowStatus.CANCELED in statuses:
|
|
116
|
+
return WorkflowStatus.CANCELED
|
|
117
|
+
|
|
118
|
+
# Any STOPPED and no other branches incomplete
|
|
119
|
+
if WorkflowStatus.STOPPED in statuses:
|
|
120
|
+
if not self._other_branches_incomplete(stages):
|
|
121
|
+
# Check for override
|
|
122
|
+
if self._should_override_success(execution):
|
|
123
|
+
return WorkflowStatus.TERMINAL
|
|
124
|
+
return WorkflowStatus.SUCCEEDED
|
|
125
|
+
|
|
126
|
+
# Still running - re-queue
|
|
127
|
+
logger.debug(
|
|
128
|
+
"Re-queuing CompleteWorkflow for %s - stages not complete. Statuses: %s",
|
|
129
|
+
execution.id,
|
|
130
|
+
statuses,
|
|
131
|
+
)
|
|
132
|
+
self.queue.push(message, self.retry_delay)
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
def _other_branches_incomplete(self, stages: list[StageExecution]) -> bool:
|
|
136
|
+
"""Check if any other branches are incomplete."""
|
|
137
|
+
for stage in stages:
|
|
138
|
+
if stage.status == WorkflowStatus.RUNNING:
|
|
139
|
+
return True
|
|
140
|
+
if stage.status == WorkflowStatus.NOT_STARTED and stage.all_upstream_stages_complete():
|
|
141
|
+
return True
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
def _should_override_success(self, execution: Workflow) -> bool:
|
|
145
|
+
"""Check if success should be overridden to failure."""
|
|
146
|
+
for stage in execution.stages:
|
|
147
|
+
if stage.status == WorkflowStatus.STOPPED:
|
|
148
|
+
if stage.context.get("completeOtherBranchesThenFail"):
|
|
149
|
+
return True
|
|
150
|
+
return False
|