grctl-sdk-python 0.1.1__tar.gz → 0.1.3__tar.gz
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.
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/PKG-INFO +13 -13
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/README.md +5 -5
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/client/client.py +53 -35
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/models/directive.py +2 -2
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/models/history.py +6 -3
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/nats/history_fetch.py +30 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/nats/publisher.py +9 -3
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/nats/subscriber.py +2 -2
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/worker/codec.py +7 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/worker/context.py +10 -5
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/worker/runner.py +64 -80
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/worker/runtime.py +10 -8
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/worker/task.py +21 -16
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/worker/worker.py +41 -21
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/workflow/__init__.py +2 -2
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/workflow/future.py +12 -1
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/workflow/workflow.py +2 -8
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/pyproject.toml +9 -8
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/LICENSE +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/__init__.py +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/client/__init__.py +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/logging_config.py +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/models/__init__.py +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/models/api.py +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/models/command.py +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/models/common.py +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/models/errors.py +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/models/run_info.py +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/models/run_info_helper.py +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/models/worker.py +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/nats/__init__.py +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/nats/connection.py +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/nats/history_sub.py +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/nats/kv_store.py +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/nats/manifest.py +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/nats/nats_client.py +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/nats/nats_manifest.yaml +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/py.typed +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/settings.py +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/worker/__init__.py +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/worker/errors.py +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/worker/logger.py +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/worker/run_manager.py +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/worker/store.py +0 -0
- {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/workflow/handle.py +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: grctl-sdk-python
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: The Python SDK for the Ground Control
|
|
5
|
-
Author:
|
|
6
|
-
Author-email:
|
|
5
|
+
Author: Cem Evren Ates
|
|
6
|
+
Author-email: Cem Evren Ates <cemevre@gmail.com>
|
|
7
7
|
License-Expression: Apache-2.0
|
|
8
8
|
License-File: LICENSE
|
|
9
9
|
Classifier: Development Status :: 3 - Alpha
|
|
@@ -25,18 +25,18 @@ Requires-Dist: pydantic-settings>=2.11.0
|
|
|
25
25
|
Requires-Dist: pyyaml>=6.0.3
|
|
26
26
|
Requires-Dist: pydantic>=2.12.5
|
|
27
27
|
Requires-Python: >=3.13
|
|
28
|
-
Project-URL: Homepage, https://github.com/
|
|
29
|
-
Project-URL: Documentation, https://
|
|
30
|
-
Project-URL: Repository, https://github.com/
|
|
31
|
-
Project-URL: Issues, https://github.com/
|
|
32
|
-
Project-URL: Changelog, https://github.com/
|
|
28
|
+
Project-URL: Homepage, https://github.com/grctl/sdk-python
|
|
29
|
+
Project-URL: Documentation, https://grctl.github.io/grctl/
|
|
30
|
+
Project-URL: Repository, https://github.com/grctl/sdk-python
|
|
31
|
+
Project-URL: Issues, https://github.com/grctl/sdk-python/issues
|
|
32
|
+
Project-URL: Changelog, https://github.com/grctl/sdk-python/blob/main/CHANGELOG.md
|
|
33
33
|
Description-Content-Type: text/markdown
|
|
34
34
|
|
|
35
35
|
# Ground Control Python SDK
|
|
36
36
|
|
|
37
|
-
The official Python SDK for [Ground Control](https://github.com/
|
|
37
|
+
The official Python SDK for [Ground Control](https://github.com/grctl/grctl) — a lightweight workflow orchestration engine built for fail-safe execution.
|
|
38
38
|
|
|
39
|
-
**[Documentation](https://
|
|
39
|
+
**[Documentation](https://grctl.github.io/grctl/)**
|
|
40
40
|
|
|
41
41
|
> [!WARNING]
|
|
42
42
|
> **Status: Pre-alpha**
|
|
@@ -55,12 +55,12 @@ pip install grctl-sdk-python
|
|
|
55
55
|
|
|
56
56
|
## Contributing
|
|
57
57
|
|
|
58
|
-
Contributions are welcome! Please read our [Contributing Guide](https://github.com/
|
|
58
|
+
Contributions are welcome! Please read our [Contributing Guide](https://github.com/grctl/grctl/blob/main/CONTRIBUTING.md) for more information.
|
|
59
59
|
|
|
60
60
|
## Security
|
|
61
61
|
|
|
62
|
-
Please see our [Security Policy](https://github.com/
|
|
62
|
+
Please see our [Security Policy](https://github.com/grctl/grctl/blob/main/SECURITY.md) for reporting vulnerabilities.
|
|
63
63
|
|
|
64
64
|
## License
|
|
65
65
|
|
|
66
|
-
This project is licensed under the Apache License 2.0 - see the [LICENSE](https://github.com/
|
|
66
|
+
This project is licensed under the Apache License 2.0 - see the [LICENSE](https://github.com/grctl/grctl/blob/main/LICENSE) file for details.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Ground Control Python SDK
|
|
2
2
|
|
|
3
|
-
The official Python SDK for [Ground Control](https://github.com/
|
|
3
|
+
The official Python SDK for [Ground Control](https://github.com/grctl/grctl) — a lightweight workflow orchestration engine built for fail-safe execution.
|
|
4
4
|
|
|
5
|
-
**[Documentation](https://
|
|
5
|
+
**[Documentation](https://grctl.github.io/grctl/)**
|
|
6
6
|
|
|
7
7
|
> [!WARNING]
|
|
8
8
|
> **Status: Pre-alpha**
|
|
@@ -21,12 +21,12 @@ pip install grctl-sdk-python
|
|
|
21
21
|
|
|
22
22
|
## Contributing
|
|
23
23
|
|
|
24
|
-
Contributions are welcome! Please read our [Contributing Guide](https://github.com/
|
|
24
|
+
Contributions are welcome! Please read our [Contributing Guide](https://github.com/grctl/grctl/blob/main/CONTRIBUTING.md) for more information.
|
|
25
25
|
|
|
26
26
|
## Security
|
|
27
27
|
|
|
28
|
-
Please see our [Security Policy](https://github.com/
|
|
28
|
+
Please see our [Security Policy](https://github.com/grctl/grctl/blob/main/SECURITY.md) for reporting vulnerabilities.
|
|
29
29
|
|
|
30
30
|
## License
|
|
31
31
|
|
|
32
|
-
This project is licensed under the Apache License 2.0 - see the [LICENSE](https://github.com/
|
|
32
|
+
This project is licensed under the Apache License 2.0 - see the [LICENSE](https://github.com/grctl/grctl/blob/main/LICENSE) file for details.
|
|
@@ -11,10 +11,11 @@ from typing import Any
|
|
|
11
11
|
import msgspec
|
|
12
12
|
from ulid import ULID
|
|
13
13
|
|
|
14
|
-
from grctl.models import DescribeCmd, GrctlAPIResponse, RunInfo
|
|
14
|
+
from grctl.models import DescribeCmd, GrctlAPIResponse, HistoryEvent, RunInfo
|
|
15
15
|
from grctl.models.command import CmdKind, Command
|
|
16
16
|
from grctl.models.errors import WorkflowError, WorkflowNotFoundError
|
|
17
17
|
from grctl.nats.connection import Connection
|
|
18
|
+
from grctl.nats.history_fetch import fetch_run_history
|
|
18
19
|
from grctl.worker.codec import CodecRegistry
|
|
19
20
|
from grctl.workflow.handle import WorkflowHandle
|
|
20
21
|
|
|
@@ -30,36 +31,16 @@ class Client:
|
|
|
30
31
|
self._connection = connection
|
|
31
32
|
self._codec = codec or CodecRegistry()
|
|
32
33
|
|
|
33
|
-
async def
|
|
34
|
-
|
|
35
|
-
workflow_type: str,
|
|
36
|
-
workflow_id: str,
|
|
37
|
-
workflow_input: Any | None = None,
|
|
38
|
-
workflow_timeout: timedelta | None = None,
|
|
39
|
-
) -> Any:
|
|
40
|
-
"""Run a workflow and wait for its result."""
|
|
41
|
-
wf_handle = await self.start_workflow(
|
|
42
|
-
workflow_type=workflow_type,
|
|
43
|
-
workflow_id=workflow_id,
|
|
44
|
-
workflow_input=workflow_input,
|
|
45
|
-
workflow_timeout=workflow_timeout,
|
|
46
|
-
)
|
|
47
|
-
timeout = workflow_timeout.total_seconds() if workflow_timeout else None
|
|
48
|
-
try:
|
|
49
|
-
return await asyncio.wait_for(wf_handle.future, timeout=timeout)
|
|
50
|
-
finally:
|
|
51
|
-
await wf_handle.future.stop()
|
|
52
|
-
|
|
53
|
-
async def get_workflow_handle(self, workflow_id: str) -> WorkflowHandle:
|
|
54
|
-
"""Get a handle for an already-running workflow."""
|
|
34
|
+
async def describe(self, wf_id: str) -> RunInfo:
|
|
35
|
+
"""Describe the latest run for a workflow ID."""
|
|
55
36
|
cmd = Command(
|
|
56
37
|
id=str(ULID()),
|
|
57
38
|
kind=CmdKind.run_describe,
|
|
58
39
|
timestamp=datetime.now(UTC),
|
|
59
|
-
msg=DescribeCmd(wf_id=
|
|
40
|
+
msg=DescribeCmd(wf_id=wf_id),
|
|
60
41
|
)
|
|
61
42
|
# Use a routing-only RunInfo — publish_cmd only needs wf_id for subject routing.
|
|
62
|
-
routing_info = RunInfo(id="", wf_type="", wf_id=
|
|
43
|
+
routing_info = RunInfo(id="", wf_type="", wf_id=wf_id)
|
|
63
44
|
response_bytes = await self._connection.publisher.publish_cmd(routing_info, cmd)
|
|
64
45
|
|
|
65
46
|
response = msgspec.msgpack.decode(response_bytes, type=GrctlAPIResponse)
|
|
@@ -67,10 +48,34 @@ class Client:
|
|
|
67
48
|
error_msg = response.error.message if response.error else "unknown error"
|
|
68
49
|
error_code = response.error.code if response.error else 0
|
|
69
50
|
if error_code == ErrWorkflowRunNotFoundCode:
|
|
70
|
-
raise WorkflowNotFoundError(f"workflow '{
|
|
51
|
+
raise WorkflowNotFoundError(f"workflow '{wf_id}' not found: {error_msg}")
|
|
71
52
|
raise WorkflowError(f"describe failed (code={error_code}): {error_msg}")
|
|
72
53
|
|
|
73
|
-
|
|
54
|
+
return msgspec.msgpack.decode(response.payload, type=RunInfo)
|
|
55
|
+
|
|
56
|
+
async def run_workflow(
|
|
57
|
+
self,
|
|
58
|
+
type: str, # noqa: A002
|
|
59
|
+
id: str, # noqa: A002
|
|
60
|
+
input: Any | None = None, # noqa: A002
|
|
61
|
+
timeout: timedelta | None = None, # noqa: ASYNC109
|
|
62
|
+
) -> Any:
|
|
63
|
+
"""Run a workflow and wait for its result."""
|
|
64
|
+
wf_handle = await self.start_workflow(
|
|
65
|
+
type=type,
|
|
66
|
+
id=id,
|
|
67
|
+
input=input,
|
|
68
|
+
timeout=timeout,
|
|
69
|
+
)
|
|
70
|
+
wait_timeout = timeout.total_seconds() if timeout else None
|
|
71
|
+
try:
|
|
72
|
+
return await asyncio.wait_for(wf_handle.future, timeout=wait_timeout)
|
|
73
|
+
finally:
|
|
74
|
+
await wf_handle.future.stop()
|
|
75
|
+
|
|
76
|
+
async def get_workflow_handle(self, wfid: str) -> WorkflowHandle:
|
|
77
|
+
"""Get a handle for an already-running workflow."""
|
|
78
|
+
run_info = await self.describe(wfid)
|
|
74
79
|
|
|
75
80
|
handle = WorkflowHandle(
|
|
76
81
|
run_info=run_info,
|
|
@@ -81,27 +86,40 @@ class Client:
|
|
|
81
86
|
await handle.attach()
|
|
82
87
|
return handle
|
|
83
88
|
|
|
89
|
+
async def get_history(self, wf_id: str, run_id: str | None = None) -> list[HistoryEvent]:
|
|
90
|
+
"""Return the ordered history events for a workflow run."""
|
|
91
|
+
resolved_run_id = run_id
|
|
92
|
+
if resolved_run_id is None:
|
|
93
|
+
resolved_run_id = (await self.describe(wf_id)).id
|
|
94
|
+
|
|
95
|
+
return await fetch_run_history(
|
|
96
|
+
js=self._connection.js,
|
|
97
|
+
manifest=self._connection.manifest,
|
|
98
|
+
wf_id=wf_id,
|
|
99
|
+
run_id=resolved_run_id,
|
|
100
|
+
)
|
|
101
|
+
|
|
84
102
|
async def start_workflow(
|
|
85
103
|
self,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
104
|
+
type: str, # noqa: A002
|
|
105
|
+
id: str, # noqa: A002
|
|
106
|
+
input: Any | None = None, # noqa: A002
|
|
107
|
+
timeout: timedelta | None = None, # noqa: ASYNC109
|
|
90
108
|
) -> WorkflowHandle:
|
|
91
109
|
"""Start a workflow and return a handle to track and interact with it."""
|
|
92
110
|
workflow_run_id = str(ULID())
|
|
93
111
|
|
|
94
112
|
run_info = RunInfo(
|
|
95
113
|
id=workflow_run_id,
|
|
96
|
-
wf_type=
|
|
97
|
-
wf_id=
|
|
98
|
-
timeout=int(
|
|
114
|
+
wf_type=type,
|
|
115
|
+
wf_id=id,
|
|
116
|
+
timeout=int(timeout.total_seconds()) if timeout else None,
|
|
99
117
|
created_at=datetime.now(UTC),
|
|
100
118
|
)
|
|
101
119
|
|
|
102
120
|
handle = WorkflowHandle(
|
|
103
121
|
run_info=run_info,
|
|
104
|
-
payload=
|
|
122
|
+
payload=input,
|
|
105
123
|
connection=self._connection,
|
|
106
124
|
codec=self._codec,
|
|
107
125
|
)
|
|
@@ -158,11 +158,11 @@ class DirectiveWire(msgspec.Struct, omit_defaults=True):
|
|
|
158
158
|
kv: dict[str, Any] | None = None # kv_revs
|
|
159
159
|
|
|
160
160
|
|
|
161
|
-
def directive_encoder(directive: Directive) -> bytes:
|
|
161
|
+
def directive_encoder(directive: Directive, enc_hook: Any = None) -> bytes:
|
|
162
162
|
if directive.msg is None:
|
|
163
163
|
raise ValueError("Directive message cannot be None")
|
|
164
164
|
|
|
165
|
-
msg_bytes = msgspec.msgpack.encode(directive.msg)
|
|
165
|
+
msg_bytes = msgspec.msgpack.encode(directive.msg, enc_hook=enc_hook)
|
|
166
166
|
|
|
167
167
|
wire = DirectiveWire(
|
|
168
168
|
id=directive.id,
|
|
@@ -135,7 +135,7 @@ class TaskCompleted(msgspec.Struct):
|
|
|
135
135
|
|
|
136
136
|
task_id: str
|
|
137
137
|
task_name: str
|
|
138
|
-
output: Any #
|
|
138
|
+
output: dict[str, Any] # Always {"result": <primitive>}
|
|
139
139
|
step_name: str
|
|
140
140
|
duration_ms: int
|
|
141
141
|
|
|
@@ -202,6 +202,7 @@ class ChildWorkflowStarted(msgspec.Struct):
|
|
|
202
202
|
run_id: str
|
|
203
203
|
wf_type: str
|
|
204
204
|
wf_id: str
|
|
205
|
+
input: Any | None = None
|
|
205
206
|
|
|
206
207
|
|
|
207
208
|
class ParentEventSent(msgspec.Struct):
|
|
@@ -209,6 +210,8 @@ class ParentEventSent(msgspec.Struct):
|
|
|
209
210
|
|
|
210
211
|
event_name: str
|
|
211
212
|
payload: Any
|
|
213
|
+
parent_wf_type: str
|
|
214
|
+
parent_wf_id: str
|
|
212
215
|
|
|
213
216
|
|
|
214
217
|
RunEvents = RunCancelScheduled | RunCancelled | RunCompleted | RunFailed | RunScheduled | RunStarted | RunTimeout
|
|
@@ -297,7 +300,7 @@ class HistoryWire(msgspec.Struct):
|
|
|
297
300
|
o: str = ""
|
|
298
301
|
|
|
299
302
|
|
|
300
|
-
def history_encoder(event: HistoryEvent) -> bytes:
|
|
303
|
+
def history_encoder(event: HistoryEvent, enc_hook: Any = None) -> bytes:
|
|
301
304
|
"""Encode history event to msgpack as array: [kind, msg, run_id, timestamp, wf_id, worker_id]."""
|
|
302
305
|
if event.msg is None:
|
|
303
306
|
raise ValueError("HistoryEvent message cannot be None")
|
|
@@ -308,7 +311,7 @@ def history_encoder(event: HistoryEvent) -> bytes:
|
|
|
308
311
|
wo=event.worker_id,
|
|
309
312
|
ts=event.timestamp,
|
|
310
313
|
k=event.kind,
|
|
311
|
-
m=msgspec.msgpack.encode(event.msg),
|
|
314
|
+
m=msgspec.msgpack.encode(event.msg, enc_hook=enc_hook),
|
|
312
315
|
o=event.operation_id,
|
|
313
316
|
)
|
|
314
317
|
|
|
@@ -9,6 +9,36 @@ _FETCH_BATCH_SIZE = 256
|
|
|
9
9
|
_FETCH_TIMEOUT_SECONDS = 0.25
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
async def fetch_run_history(
|
|
13
|
+
js: JetStreamContext,
|
|
14
|
+
manifest: NatsManifest,
|
|
15
|
+
wf_id: str,
|
|
16
|
+
run_id: str,
|
|
17
|
+
) -> list[HistoryEvent]:
|
|
18
|
+
history_subject = manifest.history_subject(wf_id=wf_id, run_id=run_id)
|
|
19
|
+
history_stream = manifest.history_stream_name()
|
|
20
|
+
subscription = await js.pull_subscribe(
|
|
21
|
+
subject=history_subject,
|
|
22
|
+
stream=history_stream,
|
|
23
|
+
config=ConsumerConfig(
|
|
24
|
+
deliver_policy=DeliverPolicy.ALL,
|
|
25
|
+
ack_policy=AckPolicy.NONE,
|
|
26
|
+
inactive_threshold=1.0,
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
events: list[HistoryEvent] = []
|
|
31
|
+
try:
|
|
32
|
+
try:
|
|
33
|
+
while True:
|
|
34
|
+
messages = await subscription.fetch(batch=_FETCH_BATCH_SIZE, timeout=_FETCH_TIMEOUT_SECONDS)
|
|
35
|
+
events.extend(history_decoder(msg.data) for msg in messages if msg.data)
|
|
36
|
+
except (TimeoutError, FetchTimeoutError):
|
|
37
|
+
return events
|
|
38
|
+
finally:
|
|
39
|
+
await subscription.unsubscribe()
|
|
40
|
+
|
|
41
|
+
|
|
12
42
|
async def fetch_step_history(
|
|
13
43
|
js: JetStreamContext,
|
|
14
44
|
manifest: NatsManifest,
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
1
4
|
from nats.aio.client import Client as NATSClient
|
|
2
5
|
from nats.js.client import JetStreamContext
|
|
3
6
|
|
|
@@ -11,22 +14,25 @@ class Publisher:
|
|
|
11
14
|
self._js = js
|
|
12
15
|
self._manifest = manifest
|
|
13
16
|
|
|
14
|
-
async def publish_history(
|
|
17
|
+
async def publish_history(
|
|
18
|
+
self, run_info: RunInfo, event: HistoryEvent, enc_hook: Callable[[Any], Any] | None = None
|
|
19
|
+
) -> None:
|
|
15
20
|
subject = self._manifest.history_subject(wf_id=run_info.wf_id, run_id=run_info.id)
|
|
16
|
-
data = history_encoder(event)
|
|
21
|
+
data = history_encoder(event, enc_hook=enc_hook)
|
|
17
22
|
await self._js.publish(subject, data)
|
|
18
23
|
|
|
19
24
|
async def publish_next_directive(
|
|
20
25
|
self,
|
|
21
26
|
run: RunInfo,
|
|
22
27
|
directive: Directive,
|
|
28
|
+
enc_hook: Callable[[Any], Any] | None = None,
|
|
23
29
|
) -> None:
|
|
24
30
|
subject = self._manifest.directive_subject(
|
|
25
31
|
wf_type=run.wf_type,
|
|
26
32
|
wf_id=run.wf_id,
|
|
27
33
|
run_id=run.id,
|
|
28
34
|
)
|
|
29
|
-
data = directive_encoder(directive)
|
|
35
|
+
data = directive_encoder(directive, enc_hook=enc_hook)
|
|
30
36
|
await self._js.publish(subject, data)
|
|
31
37
|
|
|
32
38
|
async def publish_cmd(
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import contextlib
|
|
3
3
|
import json
|
|
4
|
+
from typing import Any
|
|
4
5
|
|
|
5
6
|
import msgspec
|
|
6
7
|
from nats.aio.msg import Msg
|
|
@@ -11,7 +12,6 @@ from grctl.logging_config import get_logger
|
|
|
11
12
|
from grctl.models import directive_decoder
|
|
12
13
|
from grctl.nats.manifest import NatsManifest
|
|
13
14
|
from grctl.settings import get_settings
|
|
14
|
-
from grctl.worker.run_manager import RunManager
|
|
15
15
|
|
|
16
16
|
logger = get_logger(__name__)
|
|
17
17
|
|
|
@@ -24,7 +24,7 @@ class Subscriber:
|
|
|
24
24
|
js: JetStreamContext,
|
|
25
25
|
manifest: NatsManifest,
|
|
26
26
|
wf_types: list[str],
|
|
27
|
-
run_manager:
|
|
27
|
+
run_manager: Any,
|
|
28
28
|
) -> None:
|
|
29
29
|
self._js = js
|
|
30
30
|
self._manifest = manifest
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from collections.abc import Callable
|
|
2
2
|
from typing import Any
|
|
3
3
|
|
|
4
|
+
import msgspec
|
|
4
5
|
import msgspec.msgpack
|
|
5
6
|
from pydantic import BaseModel
|
|
6
7
|
|
|
@@ -35,6 +36,12 @@ class CodecRegistry:
|
|
|
35
36
|
return decode(tp, obj)
|
|
36
37
|
raise TypeError(f"Unsupported type: {tp}")
|
|
37
38
|
|
|
39
|
+
def to_primitive(self, value: Any) -> Any:
|
|
40
|
+
return msgspec.to_builtins(value, enc_hook=self.enc_hook)
|
|
41
|
+
|
|
42
|
+
def from_primitive(self, raw: Any, tp: type) -> Any:
|
|
43
|
+
return msgspec.convert(raw, tp, dec_hook=self.dec_hook)
|
|
44
|
+
|
|
38
45
|
def encode(self, value: Any) -> bytes:
|
|
39
46
|
return msgspec.msgpack.encode(value, enc_hook=self.enc_hook)
|
|
40
47
|
|
|
@@ -33,7 +33,7 @@ from grctl.worker.logger import ReplayAwareLogger
|
|
|
33
33
|
from grctl.worker.runtime import get_step_runtime
|
|
34
34
|
from grctl.worker.store import Store
|
|
35
35
|
from grctl.workflow import WorkflowHandle
|
|
36
|
-
from grctl.workflow.workflow import
|
|
36
|
+
from grctl.workflow.workflow import HandlerConfig
|
|
37
37
|
|
|
38
38
|
StepHandler = Callable[..., Awaitable[Directive]]
|
|
39
39
|
|
|
@@ -50,7 +50,7 @@ class NextBuilder:
|
|
|
50
50
|
worker_id: str,
|
|
51
51
|
store: Store,
|
|
52
52
|
current_directive: Directive,
|
|
53
|
-
step_configs: dict[str,
|
|
53
|
+
step_configs: dict[str, HandlerConfig] | None = None,
|
|
54
54
|
) -> None:
|
|
55
55
|
self._run = run
|
|
56
56
|
self._worker_id = worker_id
|
|
@@ -149,7 +149,7 @@ class Context:
|
|
|
149
149
|
worker_id: str,
|
|
150
150
|
directive: Directive,
|
|
151
151
|
parent_run: RunInfo | None = None,
|
|
152
|
-
step_configs: dict[str,
|
|
152
|
+
step_configs: dict[str, HandlerConfig] | None = None,
|
|
153
153
|
workflow_logger: logging.Logger | None = None,
|
|
154
154
|
) -> None:
|
|
155
155
|
self.run = run_info
|
|
@@ -198,7 +198,12 @@ class Context:
|
|
|
198
198
|
)
|
|
199
199
|
await runtime.record(
|
|
200
200
|
HistoryKind.parent_event_sent,
|
|
201
|
-
ParentEventSent(
|
|
201
|
+
ParentEventSent(
|
|
202
|
+
event_name=event_name,
|
|
203
|
+
payload=payload,
|
|
204
|
+
parent_wf_type=self._parent_run.wf_type,
|
|
205
|
+
parent_wf_id=self._parent_run.wf_id,
|
|
206
|
+
),
|
|
202
207
|
operation_id,
|
|
203
208
|
)
|
|
204
209
|
|
|
@@ -244,7 +249,7 @@ class Context:
|
|
|
244
249
|
await handle.start()
|
|
245
250
|
await runtime.record(
|
|
246
251
|
HistoryKind.child_started,
|
|
247
|
-
ChildWorkflowStarted(run_id=run_id, wf_type=workflow_type, wf_id=workflow_id),
|
|
252
|
+
ChildWorkflowStarted(run_id=run_id, wf_type=workflow_type, wf_id=workflow_id, input=workflow_input),
|
|
248
253
|
operation_id,
|
|
249
254
|
)
|
|
250
255
|
return handle
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import functools
|
|
2
2
|
import traceback
|
|
3
|
-
from collections.abc import Awaitable, Callable
|
|
4
3
|
from datetime import UTC, datetime
|
|
5
4
|
from typing import Any
|
|
6
5
|
|
|
7
|
-
import msgspec
|
|
8
|
-
|
|
9
6
|
from grctl.logging_config import get_logger
|
|
10
7
|
from grctl.models import (
|
|
11
8
|
Directive,
|
|
@@ -18,11 +15,9 @@ from grctl.models import (
|
|
|
18
15
|
)
|
|
19
16
|
from grctl.models.directive import StepResult
|
|
20
17
|
from grctl.models.run_info_helper import RunInfoManager
|
|
21
|
-
from grctl.worker.codec import CodecRegistry
|
|
22
|
-
from grctl.worker.context import Context
|
|
23
18
|
from grctl.worker.errors import NextDirectiveMissingError
|
|
24
19
|
from grctl.worker.runtime import StepRuntime, set_step_runtime
|
|
25
|
-
from grctl.workflow.workflow import
|
|
20
|
+
from grctl.workflow.workflow import HandlerConfig
|
|
26
21
|
|
|
27
22
|
logger = get_logger(__name__)
|
|
28
23
|
|
|
@@ -46,7 +41,9 @@ def workflow_error_handler(func): # noqa: ANN001, ANN201
|
|
|
46
41
|
stack_trace=stack_trace,
|
|
47
42
|
),
|
|
48
43
|
)
|
|
49
|
-
await self.runtime.publisher.publish_next_directive(
|
|
44
|
+
await self.runtime.publisher.publish_next_directive(
|
|
45
|
+
self.runtime.run_info, fail_directive, enc_hook=self.runtime.codec.enc_hook
|
|
46
|
+
)
|
|
50
47
|
raise
|
|
51
48
|
|
|
52
49
|
return wrapper
|
|
@@ -60,20 +57,13 @@ But it's okay for now as we are focusing on correctness and not efficiency.
|
|
|
60
57
|
|
|
61
58
|
|
|
62
59
|
class WorkflowRunner:
|
|
63
|
-
"""Orchestrates workflow run lifecycle.
|
|
64
|
-
|
|
65
|
-
Manages the complete workflow run lifecycle including:
|
|
66
|
-
- Validating run requests
|
|
67
|
-
- Publishing lifecycle events (started, completed/failed/timeout)
|
|
68
|
-
- Executing workflow with timeout enforcement
|
|
69
|
-
- Tracking execution duration
|
|
70
|
-
"""
|
|
60
|
+
"""Orchestrates workflow run lifecycle."""
|
|
71
61
|
|
|
72
62
|
_result = None
|
|
73
63
|
|
|
74
64
|
def __init__(self, runtime: StepRuntime) -> None:
|
|
75
65
|
self.runtime = runtime
|
|
76
|
-
set_step_runtime(runtime)
|
|
66
|
+
self._runtime_token = set_step_runtime(runtime)
|
|
77
67
|
self.workflow = runtime.workflow
|
|
78
68
|
|
|
79
69
|
async def handle_directive(self, directive: Directive) -> None:
|
|
@@ -90,102 +80,96 @@ class WorkflowRunner:
|
|
|
90
80
|
|
|
91
81
|
@workflow_error_handler
|
|
92
82
|
async def handle_start(self, payload: Any | None) -> None:
|
|
93
|
-
|
|
94
|
-
if
|
|
83
|
+
handler_config = self.workflow._start_handler # noqa: SLF001
|
|
84
|
+
if handler_config is None:
|
|
95
85
|
raise ValueError("Workflow start handler is not defined.")
|
|
96
86
|
|
|
97
87
|
self.runtime.run_info = RunInfoManager.start(self.runtime.run_info, datetime.now(UTC))
|
|
98
88
|
self.runtime.step_name = "start"
|
|
99
|
-
await _execute_step(
|
|
89
|
+
await self._execute_step(handler_config, payload)
|
|
100
90
|
|
|
101
91
|
@workflow_error_handler
|
|
102
92
|
async def handle_event(self, event_name: str, payload: Any | None) -> None:
|
|
103
|
-
"""Handle an event by executing its corresponding handler."""
|
|
104
93
|
handler_config = self.workflow._on_event_handlers.get(event_name) # noqa: SLF001
|
|
105
94
|
if handler_config is None:
|
|
106
95
|
logger.warning(f"No handler registered for event '{event_name}'")
|
|
107
96
|
return
|
|
108
97
|
|
|
109
98
|
self.runtime.step_name = event_name
|
|
110
|
-
await _execute_step(
|
|
99
|
+
await self._execute_step(handler_config, payload)
|
|
111
100
|
|
|
112
101
|
@workflow_error_handler
|
|
113
102
|
async def handle_step(self, step: Step) -> None:
|
|
114
|
-
"""Execute workflow step."""
|
|
115
103
|
logger.debug(f"Executing step: {step.step_name} for run {self.runtime.run_info.id}")
|
|
116
104
|
|
|
117
105
|
step_config = self.workflow._step_handlers.get(step.step_name) # noqa: SLF001
|
|
118
106
|
if step_config is None:
|
|
119
107
|
raise ValueError(f"Step handler '{step.step_name}' is not defined.")
|
|
120
108
|
self.runtime.step_name = step.step_name
|
|
121
|
-
await _execute_step(
|
|
109
|
+
await self._execute_step(step_config, None)
|
|
122
110
|
|
|
123
111
|
def _get_event_name(self, handler: Any) -> str | None:
|
|
124
|
-
"""Check if handler is an @on_event handler and return its event name."""
|
|
125
112
|
for event_name, event_config in self.workflow._on_event_handlers.items(): # noqa: SLF001
|
|
126
113
|
if handler == event_config.handler:
|
|
127
114
|
return event_name
|
|
128
115
|
return None
|
|
129
116
|
|
|
117
|
+
async def _execute_step(self, handler_config: HandlerConfig, payload: Any | None) -> None:
|
|
118
|
+
ctx = self.runtime.get_step_context()
|
|
119
|
+
start_time = datetime.now(UTC)
|
|
130
120
|
|
|
131
|
-
|
|
132
|
-
codec: CodecRegistry,
|
|
133
|
-
spec: HandlerSpec,
|
|
134
|
-
ctx: Context,
|
|
135
|
-
handler: Callable[..., Awaitable[Directive]],
|
|
136
|
-
payload: Any | None,
|
|
137
|
-
) -> Directive:
|
|
138
|
-
if not spec.params:
|
|
139
|
-
return await handler(ctx)
|
|
140
|
-
if not isinstance(payload, dict):
|
|
141
|
-
raise TypeError(f"Handler expects params {list(spec.params)} but payload is not a dict: {type(payload)}")
|
|
142
|
-
typed_kwargs = {
|
|
143
|
-
name: msgspec.convert(payload[name], param_type, dec_hook=codec.dec_hook)
|
|
144
|
-
for name, param_type in spec.params.items()
|
|
145
|
-
}
|
|
146
|
-
return await handler(ctx, **typed_kwargs)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
async def _execute_step(
|
|
150
|
-
runtime: StepRuntime,
|
|
151
|
-
handler: Callable[..., Awaitable[Directive]],
|
|
152
|
-
spec: HandlerSpec,
|
|
153
|
-
payload: Any | None,
|
|
154
|
-
) -> None:
|
|
155
|
-
ctx = runtime.get_step_context()
|
|
156
|
-
start_time = datetime.now(UTC)
|
|
157
|
-
|
|
158
|
-
await _publish_step_started_event(runtime)
|
|
159
|
-
|
|
160
|
-
directive = await _dispatch_handler(runtime.codec, spec, ctx, handler, payload)
|
|
161
|
-
|
|
162
|
-
await _publish_next_directive(runtime, directive, start_time)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
async def _publish_step_started_event(runtime: StepRuntime) -> None:
|
|
166
|
-
# Step started event will be send by the worker
|
|
167
|
-
if runtime.step_history is None or len(runtime.step_history) == 0:
|
|
168
|
-
await runtime.record(
|
|
169
|
-
kind=HistoryKind.step_started,
|
|
170
|
-
payload=StepStarted(step_name=runtime.step_name),
|
|
171
|
-
operation_id="",
|
|
172
|
-
)
|
|
121
|
+
await self._publish_step_started_event()
|
|
173
122
|
|
|
123
|
+
spec = handler_config.spec
|
|
124
|
+
handler = handler_config.handler
|
|
125
|
+
if not spec.params:
|
|
126
|
+
directive = await handler(ctx)
|
|
174
127
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
)
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
raise NextDirectiveMissingError(f"Step did not return a Directive. {directive=}", runtime.step_name)
|
|
128
|
+
# Single param: if payload is already keyed by param name use the value,
|
|
129
|
+
# otherwise treat payload itself as the value (e.g. bare Pydantic model).
|
|
130
|
+
elif len(spec.params) == 1:
|
|
131
|
+
name, param_type = next(iter(spec.params.items()))
|
|
132
|
+
raw = payload[name] if isinstance(payload, dict) and name in payload else payload
|
|
133
|
+
typed_value = self.runtime.codec.from_primitive(raw, param_type)
|
|
134
|
+
directive = await handler(ctx, **{name: typed_value})
|
|
183
135
|
|
|
184
|
-
|
|
185
|
-
|
|
136
|
+
# Multi param: convert each param from the payload dict and pass as kwargs
|
|
137
|
+
else:
|
|
138
|
+
if not isinstance(payload, dict):
|
|
139
|
+
raise TypeError(
|
|
140
|
+
f"Handler expects params {list(spec.params)} but payload is not a dict: {type(payload)}"
|
|
141
|
+
)
|
|
142
|
+
typed_kwargs = {
|
|
143
|
+
name: self.runtime.codec.from_primitive(payload[name], param_type)
|
|
144
|
+
for name, param_type in spec.params.items()
|
|
145
|
+
}
|
|
146
|
+
directive = await handler(ctx, **typed_kwargs)
|
|
147
|
+
|
|
148
|
+
await self._publish_next_directive(directive, start_time)
|
|
149
|
+
|
|
150
|
+
async def _publish_step_started_event(self) -> None:
|
|
151
|
+
if self.runtime.step_history is None or len(self.runtime.step_history) == 0:
|
|
152
|
+
await self.runtime.record(
|
|
153
|
+
kind=HistoryKind.step_started,
|
|
154
|
+
payload=StepStarted(step_name=self.runtime.step_name),
|
|
155
|
+
operation_id="",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
async def _publish_next_directive(
|
|
159
|
+
self,
|
|
160
|
+
directive: Directive,
|
|
161
|
+
step_start_time: datetime | None = None,
|
|
162
|
+
) -> None:
|
|
163
|
+
if not isinstance(directive, Directive):
|
|
164
|
+
raise NextDirectiveMissingError(f"Step did not return a Directive. {directive=}", self.runtime.step_name)
|
|
186
165
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
directive.kv_revs = pending_updates
|
|
166
|
+
if step_start_time is not None and isinstance(directive.msg, StepResult):
|
|
167
|
+
directive.msg.duration_ms = int((datetime.now(UTC) - step_start_time).total_seconds() * 1000)
|
|
190
168
|
|
|
191
|
-
|
|
169
|
+
pending_updates = self.runtime.store.get_pending_updates()
|
|
170
|
+
if pending_updates:
|
|
171
|
+
directive.kv_revs = pending_updates
|
|
172
|
+
|
|
173
|
+
await self.runtime.publisher.publish_next_directive(
|
|
174
|
+
self.runtime.run_info, directive, enc_hook=self.runtime.codec.enc_hook
|
|
175
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import hashlib
|
|
3
|
-
from contextvars import ContextVar
|
|
3
|
+
from contextvars import ContextVar, Token
|
|
4
4
|
from datetime import UTC, datetime
|
|
5
5
|
from typing import TYPE_CHECKING, Any
|
|
6
6
|
|
|
@@ -29,10 +29,10 @@ def _generate_operation_id(fn_name: str, args: dict[str, Any], seq: int) -> str:
|
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
class NonDeterminismError(Exception):
|
|
32
|
-
"""Raised when replay
|
|
32
|
+
"""Raised when replay history doesn't match current execution order."""
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
# Only these kinds participate in replay
|
|
35
|
+
# Only these kinds participate in replay history matching — everything else is observability-only
|
|
36
36
|
_REPLAY_KINDS = frozenset(
|
|
37
37
|
{
|
|
38
38
|
HistoryKind.task_completed,
|
|
@@ -86,6 +86,8 @@ class StepRuntime:
|
|
|
86
86
|
async def next(
|
|
87
87
|
self, acceptable_kinds: HistoryKind | frozenset[HistoryKind], operation_id: str
|
|
88
88
|
) -> asyncio.Future[HistoryEvents] | None:
|
|
89
|
+
|
|
90
|
+
# Check if we have a step history and the cursor is within bounds. If not, we are not replaying — return None.
|
|
89
91
|
if not self.step_history or self._cursor >= len(self.step_history):
|
|
90
92
|
return None
|
|
91
93
|
|
|
@@ -101,7 +103,7 @@ class StepRuntime:
|
|
|
101
103
|
if not future.done():
|
|
102
104
|
if self._cursor >= len(self.step_history):
|
|
103
105
|
self._pending.pop(operation_id, None)
|
|
104
|
-
return None #
|
|
106
|
+
return None # history exhausted — live execution
|
|
105
107
|
raise NonDeterminismError(
|
|
106
108
|
f"Unresolved operation {operation_id} ({acceptable_kinds}) after yield — "
|
|
107
109
|
f"cursor at {self._cursor}, pending: {list(self._pending.keys())}"
|
|
@@ -124,7 +126,7 @@ class StepRuntime:
|
|
|
124
126
|
if entry.kind not in acceptable_kinds:
|
|
125
127
|
future.set_exception(
|
|
126
128
|
NonDeterminismError(
|
|
127
|
-
f"Expected one of {acceptable_kinds} but
|
|
129
|
+
f"Expected one of {acceptable_kinds} but history has {entry.kind} "
|
|
128
130
|
f"at cursor {self._cursor} for {entry.operation_id}"
|
|
129
131
|
)
|
|
130
132
|
)
|
|
@@ -134,7 +136,7 @@ class StepRuntime:
|
|
|
134
136
|
|
|
135
137
|
async def record(self, kind: HistoryKind, payload: HistoryEvents, operation_id: str) -> None:
|
|
136
138
|
event = self._create_history_event(kind, payload, operation_id)
|
|
137
|
-
await self.publisher.publish_history(run_info=self.run_info, event=event)
|
|
139
|
+
await self.publisher.publish_history(run_info=self.run_info, event=event, enc_hook=self.codec.enc_hook)
|
|
138
140
|
|
|
139
141
|
def get_step_context(self) -> "Context":
|
|
140
142
|
from grctl.worker.context import Context # noqa: PLC0415
|
|
@@ -179,5 +181,5 @@ def get_step_runtime() -> StepRuntime:
|
|
|
179
181
|
return _step_run_time.get()
|
|
180
182
|
|
|
181
183
|
|
|
182
|
-
def set_step_runtime(runtime: StepRuntime) ->
|
|
183
|
-
_step_run_time.set(runtime)
|
|
184
|
+
def set_step_runtime(runtime: StepRuntime) -> Token:
|
|
185
|
+
return _step_run_time.set(runtime)
|
|
@@ -7,7 +7,7 @@ import traceback
|
|
|
7
7
|
from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
8
8
|
from dataclasses import dataclass
|
|
9
9
|
from datetime import UTC, datetime
|
|
10
|
-
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, cast, overload
|
|
10
|
+
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, cast, get_type_hints, overload
|
|
11
11
|
|
|
12
12
|
from grctl.logging_config import get_logger
|
|
13
13
|
from grctl.models.directive import RetryPolicy
|
|
@@ -43,8 +43,7 @@ def _capture_args(fn: Callable[..., Any], args: tuple[Any, ...], kwargs: dict[st
|
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
def _normalize_args(args_dict: dict[str, Any], codec: CodecRegistry) -> dict[str, Any]:
|
|
46
|
-
|
|
47
|
-
return {k: codec.decode(codec.encode(v)) for k, v in args_dict.items()}
|
|
46
|
+
return {k: codec.to_primitive(v) for k, v in args_dict.items()}
|
|
48
47
|
|
|
49
48
|
|
|
50
49
|
def _calculate_backoff_delay(policy: RetryPolicy, attempt: int) -> int:
|
|
@@ -80,20 +79,17 @@ def _is_error_retryable(error: Exception, policy: RetryPolicy) -> bool:
|
|
|
80
79
|
"""Check if an error should be retried based on the retry policy filters."""
|
|
81
80
|
error_type = type(error).__name__
|
|
82
81
|
|
|
83
|
-
|
|
84
|
-
has_non_retryable = policy.non_retryable_errors is not None
|
|
85
|
-
|
|
86
|
-
if not has_retryable and not has_non_retryable:
|
|
82
|
+
if policy.retryable_errors is None and policy.non_retryable_errors is None:
|
|
87
83
|
return True
|
|
88
84
|
|
|
89
|
-
if
|
|
90
|
-
return error_type in policy.retryable_errors
|
|
85
|
+
if policy.retryable_errors is not None and policy.non_retryable_errors is None:
|
|
86
|
+
return error_type in policy.retryable_errors
|
|
91
87
|
|
|
92
|
-
if
|
|
93
|
-
return error_type not in policy.non_retryable_errors
|
|
88
|
+
if policy.retryable_errors is None and policy.non_retryable_errors is not None:
|
|
89
|
+
return error_type not in policy.non_retryable_errors
|
|
94
90
|
|
|
95
91
|
# Both set: must be in retryable AND not in non_retryable
|
|
96
|
-
return error_type in policy.retryable_errors and error_type not in policy.non_retryable_errors #
|
|
92
|
+
return error_type in policy.retryable_errors and error_type not in policy.non_retryable_errors # ty:ignore[unsupported-operator]
|
|
97
93
|
|
|
98
94
|
|
|
99
95
|
@dataclass
|
|
@@ -139,8 +135,8 @@ class RetryRunner:
|
|
|
139
135
|
can_retry = (
|
|
140
136
|
self._policy is not None and attempt < self._max_attempts and _is_error_retryable(e, self._policy)
|
|
141
137
|
)
|
|
142
|
-
if can_retry:
|
|
143
|
-
delay_ms = _calculate_backoff_delay(self._policy, attempt)
|
|
138
|
+
if can_retry and self._policy is not None:
|
|
139
|
+
delay_ms = _calculate_backoff_delay(self._policy, attempt)
|
|
144
140
|
yield AttemptFailed(
|
|
145
141
|
attempt=attempt,
|
|
146
142
|
max_attempts=self._max_attempts,
|
|
@@ -349,6 +345,8 @@ async def _execute_task(
|
|
|
349
345
|
normalized_args = _normalize_args(args_dict, runtime.codec)
|
|
350
346
|
operation_id = runtime.generate_operation_id(task_name, normalized_args)
|
|
351
347
|
|
|
348
|
+
# If it's a replaying run, wait for the task outcome from the step history (in-memory).
|
|
349
|
+
# Otherwise, execute the task live.
|
|
352
350
|
future = await runtime.next(
|
|
353
351
|
frozenset({HistoryKind.task_completed, HistoryKind.task_failed, HistoryKind.task_cancelled}),
|
|
354
352
|
operation_id,
|
|
@@ -357,7 +355,14 @@ async def _execute_task(
|
|
|
357
355
|
logger.info(f"Replaying outcome for task {task_name} ({operation_id}) in step {step_name}")
|
|
358
356
|
event = await future
|
|
359
357
|
if isinstance(event, TaskCompleted):
|
|
360
|
-
|
|
358
|
+
raw = event.output["result"]
|
|
359
|
+
try:
|
|
360
|
+
return_type = get_type_hints(fn).get("return")
|
|
361
|
+
except Exception:
|
|
362
|
+
return_type = None
|
|
363
|
+
if return_type is not None:
|
|
364
|
+
return runtime.codec.from_primitive(raw, return_type)
|
|
365
|
+
return raw
|
|
361
366
|
if isinstance(event, TaskFailed):
|
|
362
367
|
raise _reconstruct_error(event.error)
|
|
363
368
|
# TaskCancelled — task didn't finish; fall through to execute it live
|
|
@@ -391,7 +396,7 @@ async def _execute_task(
|
|
|
391
396
|
task_id=operation_id,
|
|
392
397
|
task_name=task_name,
|
|
393
398
|
step_name=step_name,
|
|
394
|
-
output=result,
|
|
399
|
+
output={"result": runtime.codec.to_primitive(result)},
|
|
395
400
|
duration_ms=duration_ms,
|
|
396
401
|
),
|
|
397
402
|
operation_id,
|
|
@@ -19,6 +19,7 @@ from grctl.workflow.workflow import Workflow
|
|
|
19
19
|
|
|
20
20
|
logger = get_logger(__name__)
|
|
21
21
|
|
|
22
|
+
|
|
22
23
|
# Constants
|
|
23
24
|
DEFAULT_WORKFLOW_TIMEOUT_SECONDS: float = 30.0
|
|
24
25
|
WORKER_HEARTBEAT_INTERVAL_SECONDS: int = 1
|
|
@@ -54,8 +55,10 @@ class Worker:
|
|
|
54
55
|
self._connection = connection
|
|
55
56
|
self._workflow_logger = workflow_logger
|
|
56
57
|
self._stop_event = asyncio.Event()
|
|
58
|
+
self._startup_event = asyncio.Event()
|
|
57
59
|
self._subscriber: Subscriber | None = None
|
|
58
60
|
self._run_manager: RunManager | None = None
|
|
61
|
+
self._startup_error: Exception | None = None
|
|
59
62
|
|
|
60
63
|
@cached_property
|
|
61
64
|
def worker_name(self) -> str:
|
|
@@ -81,31 +84,48 @@ class Worker:
|
|
|
81
84
|
|
|
82
85
|
Creates RunManager for workflow execution and subscribes to workflow subjects.
|
|
83
86
|
"""
|
|
87
|
+
self._startup_event.clear()
|
|
88
|
+
self._startup_error = None
|
|
89
|
+
|
|
84
90
|
logger.info(
|
|
85
91
|
f"Starting worker with {len(self._workflows)} registered workflows",
|
|
86
92
|
)
|
|
87
93
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
94
|
+
try:
|
|
95
|
+
self._run_manager = RunManager(
|
|
96
|
+
worker_name=self.worker_name,
|
|
97
|
+
worker_id=self.worker_id,
|
|
98
|
+
workflows=self._workflows,
|
|
99
|
+
connection=self._connection,
|
|
100
|
+
workflow_logger=self._workflow_logger,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
wf_types = [wf.workflow_type for wf in self._workflows]
|
|
104
|
+
self._subscriber = Subscriber(
|
|
105
|
+
js=self._connection.js,
|
|
106
|
+
manifest=self._connection.manifest,
|
|
107
|
+
wf_types=wf_types,
|
|
108
|
+
run_manager=self._run_manager,
|
|
109
|
+
)
|
|
110
|
+
await self._subscriber.start()
|
|
111
|
+
self._startup_event.set()
|
|
112
|
+
|
|
113
|
+
logger.info(f"Worker {self.worker_name} ({self.worker_id}) started and ready to process messages")
|
|
114
|
+
|
|
115
|
+
# Keep worker alive
|
|
116
|
+
await self._process_messages()
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
self._startup_error = exc
|
|
119
|
+
self._startup_event.set()
|
|
120
|
+
raise
|
|
121
|
+
|
|
122
|
+
async def wait_until_ready(self, timeout_ms: float = 5.0) -> None:
|
|
123
|
+
"""Wait until worker startup succeeds or fails."""
|
|
124
|
+
await asyncio.wait_for(self._startup_event.wait(), timeout=timeout_ms)
|
|
125
|
+
if self._startup_error is not None:
|
|
126
|
+
raise self._startup_error
|
|
127
|
+
if self._subscriber is None:
|
|
128
|
+
raise RuntimeError("Worker startup completed without creating a subscriber")
|
|
109
129
|
|
|
110
130
|
async def _process_messages(self) -> None:
|
|
111
131
|
"""Keep worker alive to process commands."""
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
from grctl.models.directive import Directive
|
|
4
4
|
from grctl.workflow.handle import WorkflowHandle
|
|
5
|
-
from grctl.workflow.workflow import
|
|
5
|
+
from grctl.workflow.workflow import HandlerConfig, Workflow
|
|
6
6
|
|
|
7
7
|
__all__ = [
|
|
8
8
|
"Directive",
|
|
9
|
-
"
|
|
9
|
+
"HandlerConfig",
|
|
10
10
|
"Workflow",
|
|
11
11
|
"WorkflowHandle",
|
|
12
12
|
]
|
|
@@ -41,6 +41,8 @@ class WorkflowFuture(asyncio.Future[Any]):
|
|
|
41
41
|
run_id=run_info.id,
|
|
42
42
|
handler=self._handle_history_event,
|
|
43
43
|
)
|
|
44
|
+
self._subscriber_stopped = False
|
|
45
|
+
self.add_done_callback(self._schedule_subscriber_stop)
|
|
44
46
|
self._history_update_handlers: dict[HistoryKind, Callable[[HistoryEvent], None]] = {
|
|
45
47
|
HistoryKind.run_scheduled: self._on_non_terminal_event,
|
|
46
48
|
HistoryKind.run_started: self._on_non_terminal_event,
|
|
@@ -54,9 +56,18 @@ class WorkflowFuture(asyncio.Future[Any]):
|
|
|
54
56
|
"""Start listening for events and publish run command."""
|
|
55
57
|
await self._subscriber.start()
|
|
56
58
|
|
|
59
|
+
def _schedule_subscriber_stop(self, _: asyncio.Future) -> None:
|
|
60
|
+
# done_callback must be sync, so we schedule the async stop as a task.
|
|
61
|
+
if self._subscriber_stopped:
|
|
62
|
+
return
|
|
63
|
+
self._subscriber_stopped = True
|
|
64
|
+
asyncio.ensure_future(self._subscriber.stop()) # noqa: RUF006
|
|
65
|
+
|
|
57
66
|
async def stop(self) -> None:
|
|
58
67
|
"""Stop listening for events and cleanup."""
|
|
59
|
-
|
|
68
|
+
if not self._subscriber_stopped:
|
|
69
|
+
self._subscriber_stopped = True
|
|
70
|
+
await self._subscriber.stop()
|
|
60
71
|
|
|
61
72
|
if not self.done():
|
|
62
73
|
self.cancel()
|
|
@@ -44,12 +44,6 @@ def inspect_handler(fn: Callable[..., Any]) -> HandlerSpec:
|
|
|
44
44
|
class HandlerConfig:
|
|
45
45
|
handler: Callable[..., Awaitable[Directive]]
|
|
46
46
|
spec: HandlerSpec
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
@dataclasses.dataclass
|
|
50
|
-
class StepConfig:
|
|
51
|
-
handler: Callable[..., Awaitable[Directive]]
|
|
52
|
-
spec: HandlerSpec
|
|
53
47
|
timeout: timedelta | None = None
|
|
54
48
|
|
|
55
49
|
|
|
@@ -82,7 +76,7 @@ class Workflow:
|
|
|
82
76
|
self._type = workflow_type
|
|
83
77
|
self._start_handler: HandlerConfig | None = None
|
|
84
78
|
self._run_handler: Callable[..., Any] | None = None
|
|
85
|
-
self._step_handlers: dict[str,
|
|
79
|
+
self._step_handlers: dict[str, HandlerConfig] = {}
|
|
86
80
|
self._on_event_handlers: dict[str, HandlerConfig] = {}
|
|
87
81
|
self._update_handlers: dict[str, Callable[..., Any]] = {}
|
|
88
82
|
self._query_handlers: dict[str, Callable[..., Any]] = {}
|
|
@@ -170,7 +164,7 @@ class Workflow:
|
|
|
170
164
|
step_timeout = timeout if timeout is not None else timedelta(seconds=10)
|
|
171
165
|
spec = inspect_handler(func)
|
|
172
166
|
|
|
173
|
-
self._step_handlers[func.__name__] =
|
|
167
|
+
self._step_handlers[func.__name__] = HandlerConfig(
|
|
174
168
|
handler=func,
|
|
175
169
|
spec=spec,
|
|
176
170
|
timeout=step_timeout,
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "grctl-sdk-python"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.3"
|
|
4
4
|
description = "The Python SDK for the Ground Control"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
7
7
|
license = "Apache-2.0"
|
|
8
8
|
license-files = ["LICENSE"]
|
|
9
9
|
authors = [
|
|
10
|
-
{ name = "
|
|
10
|
+
{ name = "Cem Evren Ates", email = "cemevre@gmail.com" },
|
|
11
11
|
]
|
|
12
12
|
classifiers = [
|
|
13
13
|
"Development Status :: 3 - Alpha",
|
|
@@ -33,11 +33,11 @@ dependencies = [
|
|
|
33
33
|
]
|
|
34
34
|
|
|
35
35
|
[project.urls]
|
|
36
|
-
Homepage = "https://github.com/
|
|
37
|
-
Documentation = "https://
|
|
38
|
-
Repository = "https://github.com/
|
|
39
|
-
Issues = "https://github.com/
|
|
40
|
-
Changelog = "https://github.com/
|
|
36
|
+
Homepage = "https://github.com/grctl/sdk-python"
|
|
37
|
+
Documentation = "https://grctl.github.io/grctl/"
|
|
38
|
+
Repository = "https://github.com/grctl/sdk-python"
|
|
39
|
+
Issues = "https://github.com/grctl/sdk-python/issues"
|
|
40
|
+
Changelog = "https://github.com/grctl/sdk-python/blob/main/CHANGELOG.md"
|
|
41
41
|
|
|
42
42
|
[build-system]
|
|
43
43
|
requires = ["uv_build>=0.11.6,<0.12"]
|
|
@@ -60,7 +60,7 @@ module-name = "grctl"
|
|
|
60
60
|
module-root = ""
|
|
61
61
|
|
|
62
62
|
[tool.pytest.ini_options]
|
|
63
|
-
addopts = "--ignore=tests/examples --ignore=tests/e2e"
|
|
63
|
+
addopts = "--ignore=tests/examples --ignore=tests/e2e --ignore=tests/spec"
|
|
64
64
|
testpaths = ["tests"]
|
|
65
65
|
log_cli = true
|
|
66
66
|
log_cli_level = "DEBUG"
|
|
@@ -107,6 +107,7 @@ lint.ignore = [
|
|
|
107
107
|
"ANN204", # Allow missing type annotations for self and cls in tests
|
|
108
108
|
"ARG", # Unused function args for fixtures
|
|
109
109
|
"COM812",
|
|
110
|
+
"D104", # Missing docstring in public package
|
|
110
111
|
"FBT", # Don't care about booleans as positional arguments in tests for @pytest.mark.parametrize
|
|
111
112
|
"INP001", # Ignore that its not in a real python module. Doesn't matter for tests
|
|
112
113
|
# The below are debateable
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|