orchestration-engine 0.9.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.
- orchestration_engine/__init__.py +54 -0
- orchestration_engine/__main__.py +6 -0
- orchestration_engine/builder.py +117 -0
- orchestration_engine/cli.py +454 -0
- orchestration_engine/errors.py +23 -0
- orchestration_engine/executor.py +94 -0
- orchestration_engine/loader.py +325 -0
- orchestration_engine/models.py +115 -0
- orchestration_engine/persistence.py +323 -0
- orchestration_engine/py.typed +0 -0
- orchestration_engine/readiness.py +114 -0
- orchestration_engine/runner.py +877 -0
- orchestration_engine/scheduler.py +24 -0
- orchestration_engine/status.py +85 -0
- orchestration_engine/transitions.py +111 -0
- orchestration_engine/validation.py +117 -0
- orchestration_engine/workflow.schema.json +83 -0
- orchestration_engine-0.9.0.dist-info/METADATA +280 -0
- orchestration_engine-0.9.0.dist-info/RECORD +23 -0
- orchestration_engine-0.9.0.dist-info/WHEEL +4 -0
- orchestration_engine-0.9.0.dist-info/entry_points.txt +3 -0
- orchestration_engine-0.9.0.dist-info/licenses/LICENSE +202 -0
- orchestration_engine-0.9.0.dist-info/licenses/LICENSE-MIT +21 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sqlite3
|
|
3
|
+
|
|
4
|
+
from orchestration_engine.models import Task, TaskState
|
|
5
|
+
from orchestration_engine.transitions import transition_task
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger("orchestration_engine")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def execute_task(task: Task, conn: sqlite3.Connection, timestamp: float) -> bool:
|
|
11
|
+
if task.state != TaskState.READY:
|
|
12
|
+
return False
|
|
13
|
+
|
|
14
|
+
task.attempt_count += 1
|
|
15
|
+
if not transition_task(task, TaskState.RUNNING, conn, timestamp):
|
|
16
|
+
task.attempt_count -= 1
|
|
17
|
+
return False
|
|
18
|
+
return True
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def complete_task(
|
|
22
|
+
task: Task,
|
|
23
|
+
conn: sqlite3.Connection,
|
|
24
|
+
timestamp: float,
|
|
25
|
+
task_states: dict[str, TaskState] | None = None,
|
|
26
|
+
) -> bool:
|
|
27
|
+
if task.state != TaskState.RUNNING:
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
return transition_task(task, TaskState.SUCCEEDED, conn, timestamp, task_states=task_states)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def fail_task(
|
|
34
|
+
task: Task,
|
|
35
|
+
current_time: float,
|
|
36
|
+
conn: sqlite3.Connection,
|
|
37
|
+
task_states: dict[str, TaskState] | None = None,
|
|
38
|
+
) -> bool:
|
|
39
|
+
if task.state != TaskState.RUNNING:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
if task.attempt_count < task.retry_policy.max_attempts:
|
|
43
|
+
task.next_retry_at = current_time + calculate_retry_delay(task)
|
|
44
|
+
logger.warning(
|
|
45
|
+
"Task '%s' failed attempt %s/%s; retrying at %s",
|
|
46
|
+
task.task_id,
|
|
47
|
+
task.attempt_count,
|
|
48
|
+
task.retry_policy.max_attempts,
|
|
49
|
+
task.next_retry_at,
|
|
50
|
+
extra={
|
|
51
|
+
"workflow_id": task.workflow_id,
|
|
52
|
+
"run_id": getattr(task, "_run_id", None),
|
|
53
|
+
"task_id": task.task_id,
|
|
54
|
+
"old_state": task.state.value,
|
|
55
|
+
"new_state": TaskState.RETRY_WAIT.value,
|
|
56
|
+
"attempt": task.attempt_count,
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
return transition_task(
|
|
60
|
+
task, TaskState.RETRY_WAIT, conn, current_time, task_states=task_states
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return transition_task(
|
|
64
|
+
task, TaskState.FAILED, conn, current_time, task_states=task_states
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def retry_task(
|
|
69
|
+
task: Task,
|
|
70
|
+
current_time: float,
|
|
71
|
+
conn: sqlite3.Connection,
|
|
72
|
+
task_states: dict[str, TaskState] | None = None,
|
|
73
|
+
) -> bool:
|
|
74
|
+
if task.state != TaskState.RETRY_WAIT:
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
if task.next_retry_at is None:
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
if current_time < task.next_retry_at:
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
return transition_task(task, TaskState.READY, conn, current_time, task_states=task_states)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def calculate_retry_delay(task: Task) -> float:
|
|
87
|
+
"""
|
|
88
|
+
Calculate retry delay using exponential backoff:
|
|
89
|
+
delay = delay_seconds * (2 ** (attempt_count - 1))
|
|
90
|
+
"""
|
|
91
|
+
base_delay = task.retry_policy.delay_seconds
|
|
92
|
+
attempt_count = task.attempt_count
|
|
93
|
+
|
|
94
|
+
return float(base_delay * (2 ** (attempt_count - 1)))
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sqlite3
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from orchestration_engine.errors import OrchestrationError, WorkflowDefinitionError
|
|
8
|
+
from orchestration_engine.models import (
|
|
9
|
+
Hooks,
|
|
10
|
+
RetryPolicy,
|
|
11
|
+
Task,
|
|
12
|
+
TaskResult,
|
|
13
|
+
TaskState,
|
|
14
|
+
Workflow,
|
|
15
|
+
WorkflowStatus,
|
|
16
|
+
)
|
|
17
|
+
from orchestration_engine.runner import run_workflow
|
|
18
|
+
from orchestration_engine.status import get_workflow_status
|
|
19
|
+
|
|
20
|
+
MAX_WORKFLOW_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_workflow_from_json(
|
|
24
|
+
json_data: dict[str, Any],
|
|
25
|
+
*,
|
|
26
|
+
collect_errors: bool = False,
|
|
27
|
+
) -> Workflow:
|
|
28
|
+
"""
|
|
29
|
+
Parse a workflow definition dict (e.g. loaded from JSON) into a Workflow.
|
|
30
|
+
|
|
31
|
+
Does not run the workflow or touch the database. Raises WorkflowDefinitionError
|
|
32
|
+
if the workflow or any task is missing a required field or has invalid types.
|
|
33
|
+
When collect_errors=True, all errors are accumulated and raised as a single
|
|
34
|
+
WorkflowDefinitionError with an .errors list containing JSON-pointer paths.
|
|
35
|
+
"""
|
|
36
|
+
if collect_errors:
|
|
37
|
+
errors: list[str] = []
|
|
38
|
+
if "workflow_id" not in json_data:
|
|
39
|
+
errors.append("/workflow_id: missing required field")
|
|
40
|
+
elif not isinstance(json_data["workflow_id"], str) or not json_data["workflow_id"]:
|
|
41
|
+
errors.append("/workflow_id: must be a non-empty string")
|
|
42
|
+
|
|
43
|
+
if "name" not in json_data:
|
|
44
|
+
errors.append("/name: missing required field")
|
|
45
|
+
elif not isinstance(json_data["name"], str):
|
|
46
|
+
errors.append("/name: must be a string")
|
|
47
|
+
|
|
48
|
+
if "description" not in json_data:
|
|
49
|
+
errors.append("/description: missing required field")
|
|
50
|
+
elif not isinstance(json_data["description"], str):
|
|
51
|
+
errors.append("/description: must be a string")
|
|
52
|
+
|
|
53
|
+
raw_default_to = json_data.get("default_timeout_seconds")
|
|
54
|
+
if raw_default_to is not None:
|
|
55
|
+
if (
|
|
56
|
+
isinstance(raw_default_to, bool)
|
|
57
|
+
or not isinstance(raw_default_to, (int, float))
|
|
58
|
+
or raw_default_to < 0
|
|
59
|
+
):
|
|
60
|
+
errors.append(
|
|
61
|
+
"/default_timeout_seconds: must be a non-negative number or null"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if "tasks" not in json_data:
|
|
65
|
+
errors.append("/tasks: missing required field")
|
|
66
|
+
elif not isinstance(json_data["tasks"], list):
|
|
67
|
+
errors.append("/tasks: must be a list")
|
|
68
|
+
else:
|
|
69
|
+
for index, task_data in enumerate(json_data["tasks"]):
|
|
70
|
+
ptr = f"/tasks/{index}"
|
|
71
|
+
if not isinstance(task_data, dict):
|
|
72
|
+
errors.append(f"{ptr}: must be an object")
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
if "task_id" not in task_data:
|
|
76
|
+
errors.append(f"{ptr}/task_id: missing required field")
|
|
77
|
+
elif not isinstance(task_data["task_id"], str) or not task_data["task_id"]:
|
|
78
|
+
errors.append(f"{ptr}/task_id: must be a non-empty string")
|
|
79
|
+
|
|
80
|
+
if "name" not in task_data:
|
|
81
|
+
errors.append(f"{ptr}/name: missing required field")
|
|
82
|
+
elif not isinstance(task_data["name"], str):
|
|
83
|
+
errors.append(f"{ptr}/name: must be a string")
|
|
84
|
+
|
|
85
|
+
if "description" not in task_data:
|
|
86
|
+
errors.append(f"{ptr}/description: missing required field")
|
|
87
|
+
elif not isinstance(task_data["description"], str):
|
|
88
|
+
errors.append(f"{ptr}/description: must be a string")
|
|
89
|
+
|
|
90
|
+
if "dependencies" not in task_data:
|
|
91
|
+
errors.append(f"{ptr}/dependencies: missing required field")
|
|
92
|
+
elif not isinstance(task_data["dependencies"], list):
|
|
93
|
+
errors.append(f"{ptr}/dependencies: must be a list")
|
|
94
|
+
else:
|
|
95
|
+
for d_i, dep in enumerate(task_data["dependencies"]):
|
|
96
|
+
if not isinstance(dep, str):
|
|
97
|
+
errors.append(f"{ptr}/dependencies/{d_i}: must be a string")
|
|
98
|
+
|
|
99
|
+
if "retry_policy" not in task_data:
|
|
100
|
+
errors.append(f"{ptr}/retry_policy: missing required field")
|
|
101
|
+
elif not isinstance(task_data["retry_policy"], dict):
|
|
102
|
+
errors.append(f"{ptr}/retry_policy: must be an object")
|
|
103
|
+
else:
|
|
104
|
+
rp = task_data["retry_policy"]
|
|
105
|
+
if "max_attempts" not in rp:
|
|
106
|
+
errors.append(f"{ptr}/retry_policy/max_attempts: missing required field")
|
|
107
|
+
elif (
|
|
108
|
+
isinstance(rp["max_attempts"], bool)
|
|
109
|
+
or not isinstance(rp["max_attempts"], int)
|
|
110
|
+
or rp["max_attempts"] < 1
|
|
111
|
+
):
|
|
112
|
+
errors.append(f"{ptr}/retry_policy/max_attempts: must be an integer >= 1")
|
|
113
|
+
|
|
114
|
+
if "delay_seconds" not in rp:
|
|
115
|
+
errors.append(f"{ptr}/retry_policy/delay_seconds: missing required field")
|
|
116
|
+
elif (
|
|
117
|
+
isinstance(rp["delay_seconds"], bool)
|
|
118
|
+
or not isinstance(rp["delay_seconds"], (int, float))
|
|
119
|
+
or rp["delay_seconds"] < 0
|
|
120
|
+
):
|
|
121
|
+
errors.append(
|
|
122
|
+
f"{ptr}/retry_policy/delay_seconds: must be a non-negative number"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if "timeout_seconds" in task_data:
|
|
126
|
+
raw_to = task_data["timeout_seconds"]
|
|
127
|
+
if raw_to is not None and (
|
|
128
|
+
isinstance(raw_to, bool)
|
|
129
|
+
or not isinstance(raw_to, (int, float))
|
|
130
|
+
or raw_to < 0
|
|
131
|
+
):
|
|
132
|
+
errors.append(
|
|
133
|
+
f"{ptr}/timeout_seconds: must be a non-negative number or null"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if errors:
|
|
137
|
+
raise WorkflowDefinitionError(
|
|
138
|
+
f"Workflow definition has {len(errors)} error(s): {'; '.join(errors)}",
|
|
139
|
+
errors=errors,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
workflow_id = json_data["workflow_id"]
|
|
144
|
+
name = json_data["name"]
|
|
145
|
+
description = json_data["description"]
|
|
146
|
+
except KeyError as e:
|
|
147
|
+
raise WorkflowDefinitionError(f"workflow missing required field: {e}") from e
|
|
148
|
+
|
|
149
|
+
default_timeout = json_data.get("default_timeout_seconds")
|
|
150
|
+
if default_timeout is not None:
|
|
151
|
+
if (
|
|
152
|
+
isinstance(default_timeout, bool)
|
|
153
|
+
or not isinstance(default_timeout, (int, float))
|
|
154
|
+
or default_timeout < 0
|
|
155
|
+
):
|
|
156
|
+
raise WorkflowDefinitionError(
|
|
157
|
+
"workflow: default_timeout_seconds must be a non-negative number, "
|
|
158
|
+
f"got {default_timeout!r}"
|
|
159
|
+
)
|
|
160
|
+
default_timeout = float(default_timeout)
|
|
161
|
+
|
|
162
|
+
tasks: list[Task] = []
|
|
163
|
+
for index, task_data in enumerate(json_data["tasks"]):
|
|
164
|
+
task_id = task_data.get("task_id", f"index {index}")
|
|
165
|
+
try:
|
|
166
|
+
policy_data = task_data["retry_policy"]
|
|
167
|
+
max_attempts = policy_data["max_attempts"]
|
|
168
|
+
if (
|
|
169
|
+
isinstance(max_attempts, bool)
|
|
170
|
+
or not isinstance(max_attempts, int)
|
|
171
|
+
or max_attempts < 1
|
|
172
|
+
):
|
|
173
|
+
raise WorkflowDefinitionError(
|
|
174
|
+
f"task {task_id}: retry_policy.max_attempts must be an integer >= 1, "
|
|
175
|
+
f"got {max_attempts!r}"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
delay_seconds = policy_data["delay_seconds"]
|
|
179
|
+
if (
|
|
180
|
+
isinstance(delay_seconds, bool)
|
|
181
|
+
or not isinstance(delay_seconds, (int, float))
|
|
182
|
+
or delay_seconds < 0
|
|
183
|
+
):
|
|
184
|
+
raise WorkflowDefinitionError(
|
|
185
|
+
f"task {task_id}: retry_policy.delay_seconds must be a non-negative number, "
|
|
186
|
+
f"got {delay_seconds!r}"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
retry_policy = RetryPolicy(
|
|
190
|
+
max_attempts=max_attempts,
|
|
191
|
+
delay_seconds=float(delay_seconds),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if "timeout_seconds" in task_data:
|
|
195
|
+
raw_timeout = task_data["timeout_seconds"]
|
|
196
|
+
if raw_timeout is not None:
|
|
197
|
+
if (
|
|
198
|
+
isinstance(raw_timeout, bool)
|
|
199
|
+
or not isinstance(raw_timeout, (int, float))
|
|
200
|
+
or raw_timeout < 0
|
|
201
|
+
):
|
|
202
|
+
raise WorkflowDefinitionError(
|
|
203
|
+
f"task {task_id}: timeout_seconds must be a non-negative number "
|
|
204
|
+
f"or null, got {raw_timeout!r}"
|
|
205
|
+
)
|
|
206
|
+
timeout_seconds = float(raw_timeout)
|
|
207
|
+
else:
|
|
208
|
+
timeout_seconds = None
|
|
209
|
+
elif default_timeout is not None:
|
|
210
|
+
timeout_seconds = default_timeout
|
|
211
|
+
else:
|
|
212
|
+
timeout_seconds = 30.0
|
|
213
|
+
|
|
214
|
+
task = Task(
|
|
215
|
+
task_id=task_data["task_id"],
|
|
216
|
+
workflow_id=workflow_id,
|
|
217
|
+
name=task_data["name"],
|
|
218
|
+
description=task_data["description"],
|
|
219
|
+
dependencies=task_data["dependencies"],
|
|
220
|
+
state=TaskState.PENDING,
|
|
221
|
+
retry_policy=retry_policy,
|
|
222
|
+
timeout_seconds=timeout_seconds,
|
|
223
|
+
)
|
|
224
|
+
except KeyError as e:
|
|
225
|
+
raise WorkflowDefinitionError(
|
|
226
|
+
f"task {index} missing required field: {e}") from e
|
|
227
|
+
tasks.append(task)
|
|
228
|
+
|
|
229
|
+
return Workflow(
|
|
230
|
+
workflow_id=workflow_id,
|
|
231
|
+
name=name,
|
|
232
|
+
description=description,
|
|
233
|
+
tasks=tasks,
|
|
234
|
+
default_timeout_seconds=default_timeout,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
ExecutorType = Literal["thread", "process", "_inline"]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def run_workflow_from_file(
|
|
242
|
+
path: str,
|
|
243
|
+
task_runner: Callable[[Task], bool | TaskResult],
|
|
244
|
+
time_source: Callable[[], float],
|
|
245
|
+
conn: sqlite3.Connection,
|
|
246
|
+
*,
|
|
247
|
+
run_id: str | None = None,
|
|
248
|
+
max_concurrency: int = 1,
|
|
249
|
+
executor: ExecutorType = "thread",
|
|
250
|
+
hooks: Hooks | None = None,
|
|
251
|
+
) -> WorkflowStatus:
|
|
252
|
+
"""
|
|
253
|
+
Load a workflow definition from a JSON file and run it to completion.
|
|
254
|
+
|
|
255
|
+
Equivalent to load_workflow_from_json(...) followed by run_workflow(...),
|
|
256
|
+
then returning get_workflow_status(...) on the resulting Workflow. See
|
|
257
|
+
run_workflow for task_runner's contract and retry/timeout behavior.
|
|
258
|
+
"""
|
|
259
|
+
if os.path.exists(path) and os.path.getsize(path) > MAX_WORKFLOW_FILE_SIZE:
|
|
260
|
+
raise WorkflowDefinitionError(
|
|
261
|
+
f"Workflow file '{path}' exceeds maximum allowed size of {MAX_WORKFLOW_FILE_SIZE} bytes"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
with open(path) as f:
|
|
265
|
+
json_data = json.load(f)
|
|
266
|
+
|
|
267
|
+
workflow = load_workflow_from_json(json_data)
|
|
268
|
+
task_states: dict[str, TaskState] = {}
|
|
269
|
+
run_workflow(
|
|
270
|
+
workflow,
|
|
271
|
+
task_states,
|
|
272
|
+
time_source,
|
|
273
|
+
task_runner,
|
|
274
|
+
conn,
|
|
275
|
+
run_id=run_id,
|
|
276
|
+
max_concurrency=max_concurrency,
|
|
277
|
+
executor=executor,
|
|
278
|
+
hooks=hooks,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
return get_workflow_status(workflow)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def load_workflow_from_yaml(
|
|
285
|
+
source: str | os.PathLike[str] | Any,
|
|
286
|
+
*,
|
|
287
|
+
collect_errors: bool = False,
|
|
288
|
+
) -> Workflow:
|
|
289
|
+
"""
|
|
290
|
+
Parse a YAML workflow definition (file path, YAML string, or stream) into a Workflow.
|
|
291
|
+
|
|
292
|
+
Requires the optional 'yaml' extra (PyYAML). Raises OrchestrationError if PyYAML
|
|
293
|
+
is not installed.
|
|
294
|
+
"""
|
|
295
|
+
try:
|
|
296
|
+
import yaml
|
|
297
|
+
except ImportError as e:
|
|
298
|
+
raise OrchestrationError(
|
|
299
|
+
"PyYAML is required to load workflows from YAML. "
|
|
300
|
+
"Install it with: pip install 'orchestration-engine[yaml]'"
|
|
301
|
+
) from e
|
|
302
|
+
|
|
303
|
+
data: Any
|
|
304
|
+
if isinstance(source, (str, os.PathLike)):
|
|
305
|
+
try:
|
|
306
|
+
if os.path.exists(source) and os.path.getsize(source) > MAX_WORKFLOW_FILE_SIZE:
|
|
307
|
+
raise WorkflowDefinitionError(
|
|
308
|
+
f"Workflow file '{source}' exceeds maximum allowed size "
|
|
309
|
+
f"of {MAX_WORKFLOW_FILE_SIZE} bytes"
|
|
310
|
+
)
|
|
311
|
+
with open(source) as f:
|
|
312
|
+
data = yaml.safe_load(f)
|
|
313
|
+
except (OSError, FileNotFoundError):
|
|
314
|
+
if isinstance(source, str) and ("\n" in source or ":" in source):
|
|
315
|
+
data = yaml.safe_load(source)
|
|
316
|
+
else:
|
|
317
|
+
raise
|
|
318
|
+
else:
|
|
319
|
+
data = yaml.safe_load(source)
|
|
320
|
+
|
|
321
|
+
if not isinstance(data, dict):
|
|
322
|
+
raise WorkflowDefinitionError("YAML workflow definition must be a mapping/object")
|
|
323
|
+
|
|
324
|
+
return load_workflow_from_json(data, collect_errors=collect_errors)
|
|
325
|
+
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TaskState(StrEnum):
|
|
8
|
+
"""Lifecycle states a single Task can be in."""
|
|
9
|
+
PENDING = "pending"
|
|
10
|
+
READY = "ready"
|
|
11
|
+
RUNNING = "running"
|
|
12
|
+
RETRY_WAIT = "retry_wait"
|
|
13
|
+
SUCCEEDED = "succeeded"
|
|
14
|
+
FAILED = "failed"
|
|
15
|
+
CANCELLED = "cancelled"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class RetryPolicy:
|
|
20
|
+
"""
|
|
21
|
+
Retry configuration for a single Task.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
max_attempts: Total number of attempts allowed, including the first.
|
|
25
|
+
delay_seconds: Base delay before retries, in seconds.
|
|
26
|
+
"""
|
|
27
|
+
max_attempts: int
|
|
28
|
+
delay_seconds: float
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Task:
|
|
33
|
+
"""
|
|
34
|
+
A single unit of work within a Workflow.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
task_id: Unique identifier within the workflow.
|
|
38
|
+
workflow_id: ID of the Workflow this task belongs to.
|
|
39
|
+
name: Human-readable name.
|
|
40
|
+
description: Human-readable description.
|
|
41
|
+
dependencies: task_ids that must reach TaskState.SUCCEEDED before this task becomes ready.
|
|
42
|
+
state: Current lifecycle state. Mutated in place as the workflow runs.
|
|
43
|
+
retry_policy: Retry behavior for this task.
|
|
44
|
+
attempt_count: Number of attempts made so far.
|
|
45
|
+
next_retry_at: Timestamp after which a retry may run, if in RETRY_WAIT.
|
|
46
|
+
timeout_seconds: Seconds allowed before the task is considered timed out and failed.
|
|
47
|
+
Defaults to 30.0 (None disables timeout).
|
|
48
|
+
last_error: Error message from the last failed attempt, if any.
|
|
49
|
+
"""
|
|
50
|
+
task_id: str
|
|
51
|
+
workflow_id: str
|
|
52
|
+
name: str
|
|
53
|
+
description: str
|
|
54
|
+
dependencies: list[str]
|
|
55
|
+
state: TaskState
|
|
56
|
+
retry_policy: RetryPolicy
|
|
57
|
+
attempt_count: int = 0
|
|
58
|
+
next_retry_at: float | None = None
|
|
59
|
+
timeout_seconds: float | None = 30.0
|
|
60
|
+
last_error: str | None = None
|
|
61
|
+
_run_id: str | None = None
|
|
62
|
+
inputs: dict[str, Any] = field(default_factory=dict)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class TaskResult:
|
|
67
|
+
"""
|
|
68
|
+
Result returned by a task runner callable.
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
ok: Whether the task succeeded.
|
|
72
|
+
output: Output data produced by the task (must be JSON-serializable).
|
|
73
|
+
error: Optional error message if the task failed.
|
|
74
|
+
"""
|
|
75
|
+
ok: bool
|
|
76
|
+
output: Any = None
|
|
77
|
+
error: str | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class Workflow:
|
|
82
|
+
"""
|
|
83
|
+
A named collection of Tasks with dependency relationships between them.
|
|
84
|
+
|
|
85
|
+
Attributes:
|
|
86
|
+
workflow_id: Unique identifier for this workflow.
|
|
87
|
+
name: Human-readable name.
|
|
88
|
+
description: Human-readable description.
|
|
89
|
+
tasks: The tasks that make up this workflow.
|
|
90
|
+
default_timeout_seconds: Optional default timeout in seconds for tasks in this workflow.
|
|
91
|
+
"""
|
|
92
|
+
workflow_id: str
|
|
93
|
+
name: str
|
|
94
|
+
description: str
|
|
95
|
+
tasks: list[Task]
|
|
96
|
+
default_timeout_seconds: float | None = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class WorkflowStatus(StrEnum):
|
|
100
|
+
"""Overall status of a Workflow, derived from its tasks' states."""
|
|
101
|
+
RUNNING = "running"
|
|
102
|
+
SUCCEEDED = "succeeded"
|
|
103
|
+
FAILED = "failed"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class Hooks:
|
|
108
|
+
"""
|
|
109
|
+
Lifecycle callbacks invoked after corresponding persistence steps.
|
|
110
|
+
"""
|
|
111
|
+
on_task_start: Callable[[Task], None] | None = None
|
|
112
|
+
on_task_succeeded: Callable[[Task], None] | None = None
|
|
113
|
+
on_task_failed: Callable[[Task], None] | None = None
|
|
114
|
+
on_task_retry: Callable[[Task], None] | None = None
|
|
115
|
+
on_workflow_done: Callable[[Workflow, WorkflowStatus], None] | None = None
|