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,54 @@
|
|
|
1
|
+
import importlib.metadata
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from .builder import WorkflowBuilder, workflow_to_dict
|
|
5
|
+
from .errors import (
|
|
6
|
+
OrchestrationError,
|
|
7
|
+
PersistenceError,
|
|
8
|
+
WorkflowDefinitionError,
|
|
9
|
+
WorkflowValidationError,
|
|
10
|
+
)
|
|
11
|
+
from .loader import (
|
|
12
|
+
load_workflow_from_json,
|
|
13
|
+
load_workflow_from_yaml,
|
|
14
|
+
run_workflow_from_file,
|
|
15
|
+
)
|
|
16
|
+
from .models import Hooks, RetryPolicy, Task, TaskResult, TaskState, Workflow, WorkflowStatus
|
|
17
|
+
from .persistence import get_connection, get_run_history, get_workflow_history, list_runs
|
|
18
|
+
from .runner import resume_run, run_workflow
|
|
19
|
+
from .status import get_run_status, get_workflow_status
|
|
20
|
+
|
|
21
|
+
logging.getLogger("orchestration_engine").addHandler(logging.NullHandler())
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
__version__ = importlib.metadata.version("orchestration-engine")
|
|
25
|
+
except importlib.metadata.PackageNotFoundError:
|
|
26
|
+
__version__ = "0.9.0"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"__version__",
|
|
30
|
+
"Task",
|
|
31
|
+
"TaskResult",
|
|
32
|
+
"Workflow",
|
|
33
|
+
"RetryPolicy",
|
|
34
|
+
"TaskState",
|
|
35
|
+
"WorkflowStatus",
|
|
36
|
+
"Hooks",
|
|
37
|
+
"WorkflowBuilder",
|
|
38
|
+
"workflow_to_dict",
|
|
39
|
+
"run_workflow",
|
|
40
|
+
"run_workflow_from_file",
|
|
41
|
+
"resume_run",
|
|
42
|
+
"load_workflow_from_json",
|
|
43
|
+
"load_workflow_from_yaml",
|
|
44
|
+
"get_workflow_status",
|
|
45
|
+
"get_run_status",
|
|
46
|
+
"get_workflow_history",
|
|
47
|
+
"get_run_history",
|
|
48
|
+
"list_runs",
|
|
49
|
+
"get_connection",
|
|
50
|
+
"OrchestrationError",
|
|
51
|
+
"WorkflowDefinitionError",
|
|
52
|
+
"WorkflowValidationError",
|
|
53
|
+
"PersistenceError",
|
|
54
|
+
]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Self
|
|
4
|
+
|
|
5
|
+
from orchestration_engine.errors import WorkflowValidationError
|
|
6
|
+
from orchestration_engine.models import RetryPolicy, Task, TaskState, Workflow
|
|
7
|
+
from orchestration_engine.validation import get_workflow_validation_error
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WorkflowBuilder:
|
|
11
|
+
"""
|
|
12
|
+
Fluent builder for constructing and validating Workflow instances.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
workflow_id: str,
|
|
18
|
+
name: str = "",
|
|
19
|
+
description: str = "",
|
|
20
|
+
default_timeout_seconds: float | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
self._workflow_id = workflow_id
|
|
23
|
+
self._name = name or workflow_id
|
|
24
|
+
self._description = description
|
|
25
|
+
self._default_timeout_seconds = default_timeout_seconds
|
|
26
|
+
self._tasks: list[Task] = []
|
|
27
|
+
|
|
28
|
+
def name(self, name: str) -> Self:
|
|
29
|
+
self._name = name
|
|
30
|
+
return self
|
|
31
|
+
|
|
32
|
+
def description(self, description: str) -> Self:
|
|
33
|
+
self._description = description
|
|
34
|
+
return self
|
|
35
|
+
|
|
36
|
+
def default_timeout(self, seconds: float | None) -> Self:
|
|
37
|
+
self._default_timeout_seconds = seconds
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
def task(
|
|
41
|
+
self,
|
|
42
|
+
task_id: str,
|
|
43
|
+
name: str = "",
|
|
44
|
+
description: str = "",
|
|
45
|
+
dependencies: list[str] | None = None,
|
|
46
|
+
max_attempts: int = 1,
|
|
47
|
+
delay_seconds: float = 1.0,
|
|
48
|
+
timeout_seconds: float | None = 30.0,
|
|
49
|
+
retry_policy: RetryPolicy | None = None,
|
|
50
|
+
) -> Self:
|
|
51
|
+
if retry_policy is None:
|
|
52
|
+
policy = RetryPolicy(
|
|
53
|
+
max_attempts=max_attempts, delay_seconds=delay_seconds
|
|
54
|
+
)
|
|
55
|
+
else:
|
|
56
|
+
policy = retry_policy
|
|
57
|
+
|
|
58
|
+
effective_timeout = timeout_seconds
|
|
59
|
+
if (
|
|
60
|
+
timeout_seconds == 30.0
|
|
61
|
+
and self._default_timeout_seconds is not None
|
|
62
|
+
):
|
|
63
|
+
effective_timeout = self._default_timeout_seconds
|
|
64
|
+
|
|
65
|
+
task = Task(
|
|
66
|
+
task_id=task_id,
|
|
67
|
+
workflow_id=self._workflow_id,
|
|
68
|
+
name=name or task_id,
|
|
69
|
+
description=description,
|
|
70
|
+
dependencies=list(dependencies or []),
|
|
71
|
+
state=TaskState.PENDING,
|
|
72
|
+
retry_policy=policy,
|
|
73
|
+
timeout_seconds=effective_timeout,
|
|
74
|
+
)
|
|
75
|
+
self._tasks.append(task)
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
def build(self) -> Workflow:
|
|
79
|
+
workflow = Workflow(
|
|
80
|
+
workflow_id=self._workflow_id,
|
|
81
|
+
name=self._name,
|
|
82
|
+
description=self._description,
|
|
83
|
+
tasks=list(self._tasks),
|
|
84
|
+
default_timeout_seconds=self._default_timeout_seconds,
|
|
85
|
+
)
|
|
86
|
+
error = get_workflow_validation_error(workflow)
|
|
87
|
+
if error:
|
|
88
|
+
raise WorkflowValidationError(f"Invalid workflow: {error}")
|
|
89
|
+
return workflow
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def workflow_to_dict(workflow: Workflow) -> dict[str, Any]:
|
|
93
|
+
"""
|
|
94
|
+
Serialize a Workflow instance to a JSON-compatible dictionary.
|
|
95
|
+
|
|
96
|
+
Round-trips losslessly with load_workflow_from_json.
|
|
97
|
+
"""
|
|
98
|
+
return {
|
|
99
|
+
"workflow_id": workflow.workflow_id,
|
|
100
|
+
"name": workflow.name,
|
|
101
|
+
"description": workflow.description,
|
|
102
|
+
"default_timeout_seconds": workflow.default_timeout_seconds,
|
|
103
|
+
"tasks": [
|
|
104
|
+
{
|
|
105
|
+
"task_id": t.task_id,
|
|
106
|
+
"name": t.name,
|
|
107
|
+
"description": t.description,
|
|
108
|
+
"dependencies": list(t.dependencies),
|
|
109
|
+
"retry_policy": {
|
|
110
|
+
"max_attempts": t.retry_policy.max_attempts,
|
|
111
|
+
"delay_seconds": t.retry_policy.delay_seconds,
|
|
112
|
+
},
|
|
113
|
+
"timeout_seconds": t.timeout_seconds,
|
|
114
|
+
}
|
|
115
|
+
for t in workflow.tasks
|
|
116
|
+
],
|
|
117
|
+
}
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""Command-line interface for the orchestration engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import importlib
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import Any, cast
|
|
14
|
+
|
|
15
|
+
from orchestration_engine.errors import (
|
|
16
|
+
OrchestrationError,
|
|
17
|
+
PersistenceError,
|
|
18
|
+
WorkflowDefinitionError,
|
|
19
|
+
WorkflowValidationError,
|
|
20
|
+
)
|
|
21
|
+
from orchestration_engine.loader import load_workflow_from_json
|
|
22
|
+
from orchestration_engine.models import Workflow, WorkflowStatus
|
|
23
|
+
from orchestration_engine.persistence import (
|
|
24
|
+
get_connection,
|
|
25
|
+
get_run_history,
|
|
26
|
+
list_runs,
|
|
27
|
+
)
|
|
28
|
+
from orchestration_engine.runner import resume_run, run_workflow
|
|
29
|
+
from orchestration_engine.status import get_run_status
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("orchestration_engine")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _load_runner(runner_spec: str) -> Callable[..., Any]:
|
|
35
|
+
"""
|
|
36
|
+
Resolve a task runner callable from a 'module:function' string via importlib.
|
|
37
|
+
|
|
38
|
+
Note: This dynamically imports user-specified code and prepends os.getcwd()
|
|
39
|
+
to sys.path. It operates under the same trust model as executing a Python script.
|
|
40
|
+
"""
|
|
41
|
+
if ":" not in runner_spec:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"Invalid runner specification '{runner_spec}'. Expected format 'module:function'."
|
|
44
|
+
)
|
|
45
|
+
module_name, func_name = runner_spec.split(":", 1)
|
|
46
|
+
if not module_name.strip() or not func_name.strip():
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"Invalid runner specification '{runner_spec}'. Expected format 'module:function'."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
cwd = os.getcwd()
|
|
52
|
+
if cwd not in sys.path:
|
|
53
|
+
sys.path.insert(0, cwd)
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
mod = importlib.import_module(module_name)
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
raise ImportError(f"Could not import module '{module_name}': {exc}") from exc
|
|
59
|
+
|
|
60
|
+
if not hasattr(mod, func_name):
|
|
61
|
+
raise AttributeError(f"Module '{module_name}' has no attribute '{func_name}'")
|
|
62
|
+
|
|
63
|
+
runner = getattr(mod, func_name)
|
|
64
|
+
if not callable(runner):
|
|
65
|
+
raise TypeError(f"Runner '{runner_spec}' is not callable.")
|
|
66
|
+
|
|
67
|
+
return cast(Callable[..., Any], runner)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _load_workflow_file(path: str) -> Workflow:
|
|
71
|
+
"""Load a workflow definition from a JSON or YAML file."""
|
|
72
|
+
if not os.path.exists(path):
|
|
73
|
+
raise FileNotFoundError(f"Workflow file not found: '{path}'")
|
|
74
|
+
|
|
75
|
+
if os.path.getsize(path) > 10 * 1024 * 1024:
|
|
76
|
+
raise WorkflowDefinitionError(f"Workflow file '{path}' exceeds maximum size of 10 MB")
|
|
77
|
+
|
|
78
|
+
ext = os.path.splitext(path)[1].lower()
|
|
79
|
+
if ext in (".yaml", ".yml"):
|
|
80
|
+
try:
|
|
81
|
+
from orchestration_engine.loader import load_workflow_from_yaml
|
|
82
|
+
except ImportError as exc:
|
|
83
|
+
raise WorkflowValidationError(
|
|
84
|
+
"YAML support requires the 'yaml' extra. "
|
|
85
|
+
"Install with: pip install 'orchestration-engine[yaml]'"
|
|
86
|
+
) from exc
|
|
87
|
+
return load_workflow_from_yaml(path, collect_errors=True)
|
|
88
|
+
|
|
89
|
+
with open(path, encoding="utf-8") as f:
|
|
90
|
+
try:
|
|
91
|
+
data = json.load(f)
|
|
92
|
+
except json.JSONDecodeError as exc:
|
|
93
|
+
raise WorkflowDefinitionError(f"Invalid JSON in '{path}': {exc}") from exc
|
|
94
|
+
|
|
95
|
+
if not isinstance(data, dict):
|
|
96
|
+
raise WorkflowDefinitionError(f"Root of workflow JSON in '{path}' must be an object")
|
|
97
|
+
|
|
98
|
+
return load_workflow_from_json(data, collect_errors=True)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _cmd_validate(args: argparse.Namespace) -> int:
|
|
102
|
+
try:
|
|
103
|
+
workflow = _load_workflow_file(args.workflow_file)
|
|
104
|
+
except (WorkflowValidationError, WorkflowDefinitionError, FileNotFoundError, ValueError) as exc:
|
|
105
|
+
print(f"Validation error: {exc}", file=sys.stderr)
|
|
106
|
+
return 2
|
|
107
|
+
except Exception as exc:
|
|
108
|
+
print(f"Internal error: {exc}", file=sys.stderr)
|
|
109
|
+
return 3
|
|
110
|
+
|
|
111
|
+
if args.json:
|
|
112
|
+
print(
|
|
113
|
+
json.dumps(
|
|
114
|
+
{
|
|
115
|
+
"workflow_id": workflow.workflow_id,
|
|
116
|
+
"tasks_count": len(workflow.tasks),
|
|
117
|
+
"valid": True,
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
else:
|
|
122
|
+
print(f"Workflow '{workflow.workflow_id}' is valid ({len(workflow.tasks)} tasks).")
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
127
|
+
try:
|
|
128
|
+
workflow = _load_workflow_file(args.workflow_file)
|
|
129
|
+
runner = _load_runner(args.runner)
|
|
130
|
+
if args.max_concurrency < 1:
|
|
131
|
+
raise ValueError(f"max_concurrency must be >= 1, got {args.max_concurrency}")
|
|
132
|
+
except (
|
|
133
|
+
WorkflowValidationError,
|
|
134
|
+
WorkflowDefinitionError,
|
|
135
|
+
FileNotFoundError,
|
|
136
|
+
ValueError,
|
|
137
|
+
ImportError,
|
|
138
|
+
AttributeError,
|
|
139
|
+
TypeError,
|
|
140
|
+
) as exc:
|
|
141
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
142
|
+
return 2
|
|
143
|
+
except Exception as exc:
|
|
144
|
+
print(f"Internal error: {exc}", file=sys.stderr)
|
|
145
|
+
return 3
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
conn = get_connection(args.db) if args.db else get_connection(":memory:")
|
|
149
|
+
except PersistenceError as exc:
|
|
150
|
+
print(f"Database error: {exc}", file=sys.stderr)
|
|
151
|
+
return 3
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
actual_run_id = run_workflow(
|
|
155
|
+
workflow,
|
|
156
|
+
task_states={},
|
|
157
|
+
time_source=time.time,
|
|
158
|
+
task_runner=runner,
|
|
159
|
+
conn=conn,
|
|
160
|
+
run_id=args.run_id,
|
|
161
|
+
max_concurrency=args.max_concurrency,
|
|
162
|
+
executor=args.executor,
|
|
163
|
+
)
|
|
164
|
+
st = get_run_status(conn, actual_run_id)
|
|
165
|
+
except (
|
|
166
|
+
WorkflowValidationError,
|
|
167
|
+
WorkflowDefinitionError,
|
|
168
|
+
OrchestrationError,
|
|
169
|
+
ValueError,
|
|
170
|
+
) as exc:
|
|
171
|
+
print(f"Execution error: {exc}", file=sys.stderr)
|
|
172
|
+
return 2
|
|
173
|
+
except PersistenceError as exc:
|
|
174
|
+
print(f"Database error during run: {exc}", file=sys.stderr)
|
|
175
|
+
return 3
|
|
176
|
+
except Exception as exc:
|
|
177
|
+
print(f"Internal error during run: {exc}", file=sys.stderr)
|
|
178
|
+
return 3
|
|
179
|
+
|
|
180
|
+
status_str = st.value if st is not None else "unknown"
|
|
181
|
+
if args.json:
|
|
182
|
+
print(
|
|
183
|
+
json.dumps(
|
|
184
|
+
{
|
|
185
|
+
"run_id": actual_run_id,
|
|
186
|
+
"workflow_id": workflow.workflow_id,
|
|
187
|
+
"status": status_str,
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
else:
|
|
192
|
+
print(f"Workflow run '{actual_run_id}' completed with status: {status_str}")
|
|
193
|
+
|
|
194
|
+
return 0 if st == WorkflowStatus.SUCCEEDED else 1
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _cmd_resume(args: argparse.Namespace) -> int:
|
|
198
|
+
try:
|
|
199
|
+
runner = _load_runner(args.runner)
|
|
200
|
+
if args.max_concurrency < 1:
|
|
201
|
+
raise ValueError(f"max_concurrency must be >= 1, got {args.max_concurrency}")
|
|
202
|
+
except (ValueError, ImportError, AttributeError, TypeError) as exc:
|
|
203
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
204
|
+
return 2
|
|
205
|
+
except Exception as exc:
|
|
206
|
+
print(f"Internal error: {exc}", file=sys.stderr)
|
|
207
|
+
return 3
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
conn = get_connection(args.db)
|
|
211
|
+
except PersistenceError as exc:
|
|
212
|
+
print(f"Database error: {exc}", file=sys.stderr)
|
|
213
|
+
return 3
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
st = resume_run(
|
|
217
|
+
conn=conn,
|
|
218
|
+
run_id=args.run_id,
|
|
219
|
+
task_runner=runner,
|
|
220
|
+
time_source=time.time,
|
|
221
|
+
max_concurrency=args.max_concurrency,
|
|
222
|
+
executor=args.executor,
|
|
223
|
+
)
|
|
224
|
+
except (
|
|
225
|
+
WorkflowValidationError,
|
|
226
|
+
WorkflowDefinitionError,
|
|
227
|
+
OrchestrationError,
|
|
228
|
+
ValueError,
|
|
229
|
+
) as exc:
|
|
230
|
+
print(f"Resume error: {exc}", file=sys.stderr)
|
|
231
|
+
return 2
|
|
232
|
+
except PersistenceError as exc:
|
|
233
|
+
print(f"Database error during resume: {exc}", file=sys.stderr)
|
|
234
|
+
return 3
|
|
235
|
+
except Exception as exc:
|
|
236
|
+
print(f"Internal error during resume: {exc}", file=sys.stderr)
|
|
237
|
+
return 3
|
|
238
|
+
|
|
239
|
+
status_str = st.value
|
|
240
|
+
if args.json:
|
|
241
|
+
print(json.dumps({"run_id": args.run_id, "status": status_str}))
|
|
242
|
+
else:
|
|
243
|
+
print(f"Resumed run '{args.run_id}' completed with status: {status_str}")
|
|
244
|
+
|
|
245
|
+
return 0 if st == WorkflowStatus.SUCCEEDED else 1
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _cmd_status(args: argparse.Namespace) -> int:
|
|
249
|
+
try:
|
|
250
|
+
conn = get_connection(args.db)
|
|
251
|
+
st = get_run_status(conn, args.run_id)
|
|
252
|
+
except PersistenceError as exc:
|
|
253
|
+
print(f"Database error: {exc}", file=sys.stderr)
|
|
254
|
+
return 3
|
|
255
|
+
except OrchestrationError as exc:
|
|
256
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
257
|
+
return 2
|
|
258
|
+
except Exception as exc:
|
|
259
|
+
print(f"Internal error: {exc}", file=sys.stderr)
|
|
260
|
+
return 3
|
|
261
|
+
|
|
262
|
+
if args.json:
|
|
263
|
+
print(json.dumps({"run_id": args.run_id, "status": st.value}))
|
|
264
|
+
else:
|
|
265
|
+
print(f"Run {args.run_id}: {st.value}")
|
|
266
|
+
return 0
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _cmd_history(args: argparse.Namespace) -> int:
|
|
270
|
+
try:
|
|
271
|
+
conn = get_connection(args.db)
|
|
272
|
+
get_run_status(conn, args.run_id)
|
|
273
|
+
events = get_run_history(conn, args.run_id)
|
|
274
|
+
except PersistenceError as exc:
|
|
275
|
+
print(f"Database error: {exc}", file=sys.stderr)
|
|
276
|
+
return 3
|
|
277
|
+
except OrchestrationError as exc:
|
|
278
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
279
|
+
return 2
|
|
280
|
+
except Exception as exc:
|
|
281
|
+
print(f"Internal error: {exc}", file=sys.stderr)
|
|
282
|
+
return 3
|
|
283
|
+
|
|
284
|
+
if args.json:
|
|
285
|
+
output_events = [
|
|
286
|
+
{
|
|
287
|
+
"id": row[0],
|
|
288
|
+
"workflow_id": row[1],
|
|
289
|
+
"task_id": row[2],
|
|
290
|
+
"old_state": row[3],
|
|
291
|
+
"new_state": row[4],
|
|
292
|
+
"timestamp": row[5],
|
|
293
|
+
"attempt_count": row[6],
|
|
294
|
+
}
|
|
295
|
+
for row in events
|
|
296
|
+
]
|
|
297
|
+
print(json.dumps(output_events, indent=2))
|
|
298
|
+
else:
|
|
299
|
+
if not events:
|
|
300
|
+
print(f"No history events recorded for run '{args.run_id}'.")
|
|
301
|
+
else:
|
|
302
|
+
for row in events:
|
|
303
|
+
print(f"[{row[5]:.2f}] Task '{row[2]}': {row[3]} -> {row[4]} (attempt {row[6]})")
|
|
304
|
+
return 0
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _cmd_runs(args: argparse.Namespace) -> int:
|
|
308
|
+
try:
|
|
309
|
+
conn = get_connection(args.db)
|
|
310
|
+
runs_list = list_runs(conn, workflow_id=args.workflow)
|
|
311
|
+
except PersistenceError as exc:
|
|
312
|
+
print(f"Database error: {exc}", file=sys.stderr)
|
|
313
|
+
return 3
|
|
314
|
+
except Exception as exc:
|
|
315
|
+
print(f"Internal error: {exc}", file=sys.stderr)
|
|
316
|
+
return 3
|
|
317
|
+
|
|
318
|
+
if args.json:
|
|
319
|
+
runs_data = [
|
|
320
|
+
{
|
|
321
|
+
"run_id": row[0],
|
|
322
|
+
"workflow_id": row[1],
|
|
323
|
+
"started_at": row[2],
|
|
324
|
+
"finished_at": row[3],
|
|
325
|
+
"status": row[4],
|
|
326
|
+
}
|
|
327
|
+
for row in runs_list
|
|
328
|
+
]
|
|
329
|
+
print(json.dumps(runs_data, indent=2))
|
|
330
|
+
else:
|
|
331
|
+
if not runs_list:
|
|
332
|
+
print("No runs found.")
|
|
333
|
+
else:
|
|
334
|
+
for row in runs_list:
|
|
335
|
+
print(
|
|
336
|
+
f"{row[0]} workflow={row[1]} status={row[4]} "
|
|
337
|
+
f"started={row[2]} finished={row[3]}"
|
|
338
|
+
)
|
|
339
|
+
return 0
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
343
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
344
|
+
common.add_argument(
|
|
345
|
+
"--log-level",
|
|
346
|
+
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
|
347
|
+
type=str.upper,
|
|
348
|
+
help="Configure log level for orchestration_engine.",
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
parser = argparse.ArgumentParser(
|
|
352
|
+
prog="orchestration-engine",
|
|
353
|
+
description="Fault-tolerant, dependency-aware workflow orchestration engine.",
|
|
354
|
+
parents=[common],
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
subparsers = parser.add_subparsers(dest="command", help="Available subcommands")
|
|
358
|
+
|
|
359
|
+
# validate
|
|
360
|
+
val_p = subparsers.add_parser(
|
|
361
|
+
"validate", parents=[common], help="Validate a workflow definition file."
|
|
362
|
+
)
|
|
363
|
+
val_p.add_argument("workflow_file", help="Path to workflow JSON or YAML file.")
|
|
364
|
+
val_p.add_argument("--json", action="store_true", help="Output result as JSON.")
|
|
365
|
+
val_p.set_defaults(func=_cmd_validate)
|
|
366
|
+
|
|
367
|
+
# run
|
|
368
|
+
run_p = subparsers.add_parser("run", parents=[common], help="Run a workflow to completion.")
|
|
369
|
+
run_p.add_argument("workflow_file", help="Path to workflow JSON or YAML file.")
|
|
370
|
+
run_p.add_argument(
|
|
371
|
+
"--runner", required=True, help="Task runner callable in 'module:function' format."
|
|
372
|
+
)
|
|
373
|
+
run_p.add_argument(
|
|
374
|
+
"--db", default=None, help="Path to SQLite database (defaults to in-memory)."
|
|
375
|
+
)
|
|
376
|
+
run_p.add_argument("--run-id", default=None, help="Custom identifier for this run.")
|
|
377
|
+
run_p.add_argument(
|
|
378
|
+
"--max-concurrency", type=int, default=1, help="Maximum concurrent tasks (default: 1)."
|
|
379
|
+
)
|
|
380
|
+
run_p.add_argument(
|
|
381
|
+
"--executor",
|
|
382
|
+
choices=["thread", "process"],
|
|
383
|
+
default="thread",
|
|
384
|
+
help="Coordinator executor backend.",
|
|
385
|
+
)
|
|
386
|
+
run_p.add_argument("--json", action="store_true", help="Output result as JSON.")
|
|
387
|
+
run_p.set_defaults(func=_cmd_run)
|
|
388
|
+
|
|
389
|
+
# resume
|
|
390
|
+
res_p = subparsers.add_parser(
|
|
391
|
+
"resume", parents=[common], help="Resume an interrupted run."
|
|
392
|
+
)
|
|
393
|
+
res_p.add_argument("run_id", help="Run identifier to resume.")
|
|
394
|
+
res_p.add_argument(
|
|
395
|
+
"--runner", required=True, help="Task runner callable in 'module:function' format."
|
|
396
|
+
)
|
|
397
|
+
res_p.add_argument("--db", required=True, help="Path to SQLite database.")
|
|
398
|
+
res_p.add_argument(
|
|
399
|
+
"--max-concurrency", type=int, default=1, help="Maximum concurrent tasks (default: 1)."
|
|
400
|
+
)
|
|
401
|
+
res_p.add_argument(
|
|
402
|
+
"--executor",
|
|
403
|
+
choices=["thread", "process"],
|
|
404
|
+
default="thread",
|
|
405
|
+
help="Coordinator executor backend.",
|
|
406
|
+
)
|
|
407
|
+
res_p.add_argument("--json", action="store_true", help="Output result as JSON.")
|
|
408
|
+
res_p.set_defaults(func=_cmd_resume)
|
|
409
|
+
|
|
410
|
+
# status
|
|
411
|
+
stat_p = subparsers.add_parser("status", parents=[common], help="Check run status.")
|
|
412
|
+
stat_p.add_argument("run_id", help="Run identifier.")
|
|
413
|
+
stat_p.add_argument("--db", required=True, help="Path to SQLite database.")
|
|
414
|
+
stat_p.add_argument("--json", action="store_true", help="Output result as JSON.")
|
|
415
|
+
stat_p.set_defaults(func=_cmd_status)
|
|
416
|
+
|
|
417
|
+
# history
|
|
418
|
+
hist_p = subparsers.add_parser(
|
|
419
|
+
"history", parents=[common], help="Show task transition history."
|
|
420
|
+
)
|
|
421
|
+
hist_p.add_argument("run_id", help="Run identifier.")
|
|
422
|
+
hist_p.add_argument("--db", required=True, help="Path to SQLite database.")
|
|
423
|
+
hist_p.add_argument("--json", action="store_true", help="Output result as JSON.")
|
|
424
|
+
hist_p.set_defaults(func=_cmd_history)
|
|
425
|
+
|
|
426
|
+
# runs
|
|
427
|
+
runs_p = subparsers.add_parser("runs", parents=[common], help="List workflow runs.")
|
|
428
|
+
runs_p.add_argument("--db", required=True, help="Path to SQLite database.")
|
|
429
|
+
runs_p.add_argument("--workflow", default=None, help="Filter runs by workflow identifier.")
|
|
430
|
+
runs_p.add_argument("--json", action="store_true", help="Output result as JSON.")
|
|
431
|
+
runs_p.set_defaults(func=_cmd_runs)
|
|
432
|
+
|
|
433
|
+
return parser
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def main(argv: list[str] | None = None) -> int:
|
|
437
|
+
"""Entry point for the orchestration-engine CLI."""
|
|
438
|
+
parser = build_parser()
|
|
439
|
+
try:
|
|
440
|
+
args = parser.parse_args(argv if argv is not None else sys.argv[1:])
|
|
441
|
+
except SystemExit as exc:
|
|
442
|
+
return int(exc.code) if isinstance(exc.code, int) else 2
|
|
443
|
+
|
|
444
|
+
if args.log_level:
|
|
445
|
+
logging.basicConfig(
|
|
446
|
+
level=getattr(logging, args.log_level.upper()),
|
|
447
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
if not hasattr(args, "func"):
|
|
451
|
+
parser.print_help(sys.stderr)
|
|
452
|
+
return 2
|
|
453
|
+
|
|
454
|
+
return int(args.func(args))
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Error hierarchy for orchestration engine.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OrchestrationError(Exception):
|
|
7
|
+
"""Base class for all orchestration engine exceptions."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WorkflowDefinitionError(OrchestrationError, ValueError):
|
|
11
|
+
"""Raised when a workflow definition is invalid, incomplete, or malformed."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str, errors: list[str] | None = None) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.errors: list[str] = list(errors or [])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WorkflowValidationError(OrchestrationError, ValueError):
|
|
19
|
+
"""Raised when a workflow fails validation (cycles, invalid IDs, self-dependencies)."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PersistenceError(OrchestrationError):
|
|
23
|
+
"""Raised when a persistence or database operation fails."""
|