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.
Files changed (45) hide show
  1. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/PKG-INFO +13 -13
  2. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/README.md +5 -5
  3. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/client/client.py +53 -35
  4. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/models/directive.py +2 -2
  5. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/models/history.py +6 -3
  6. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/nats/history_fetch.py +30 -0
  7. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/nats/publisher.py +9 -3
  8. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/nats/subscriber.py +2 -2
  9. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/worker/codec.py +7 -0
  10. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/worker/context.py +10 -5
  11. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/worker/runner.py +64 -80
  12. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/worker/runtime.py +10 -8
  13. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/worker/task.py +21 -16
  14. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/worker/worker.py +41 -21
  15. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/workflow/__init__.py +2 -2
  16. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/workflow/future.py +12 -1
  17. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/workflow/workflow.py +2 -8
  18. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/pyproject.toml +9 -8
  19. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/LICENSE +0 -0
  20. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/__init__.py +0 -0
  21. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/client/__init__.py +0 -0
  22. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/logging_config.py +0 -0
  23. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/models/__init__.py +0 -0
  24. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/models/api.py +0 -0
  25. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/models/command.py +0 -0
  26. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/models/common.py +0 -0
  27. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/models/errors.py +0 -0
  28. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/models/run_info.py +0 -0
  29. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/models/run_info_helper.py +0 -0
  30. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/models/worker.py +0 -0
  31. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/nats/__init__.py +0 -0
  32. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/nats/connection.py +0 -0
  33. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/nats/history_sub.py +0 -0
  34. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/nats/kv_store.py +0 -0
  35. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/nats/manifest.py +0 -0
  36. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/nats/nats_client.py +0 -0
  37. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/nats/nats_manifest.yaml +0 -0
  38. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/py.typed +0 -0
  39. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/settings.py +0 -0
  40. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/worker/__init__.py +0 -0
  41. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/worker/errors.py +0 -0
  42. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/worker/logger.py +0 -0
  43. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/worker/run_manager.py +0 -0
  44. {grctl_sdk_python-0.1.1 → grctl_sdk_python-0.1.3}/grctl/worker/store.py +0 -0
  45. {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.1
3
+ Version: 0.1.3
4
4
  Summary: The Python SDK for the Ground Control
5
- Author: cemevren
6
- Author-email: cemevren <cemevren@gmail.com>
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/cemevren/grctl
29
- Project-URL: Documentation, https://cemevren.github.io/grctl/
30
- Project-URL: Repository, https://github.com/cemevren/grctl
31
- Project-URL: Issues, https://github.com/cemevren/grctl/issues
32
- Project-URL: Changelog, https://github.com/cemevren/grctl/blob/main/sdk_python/CHANGELOG.md
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/cemevren/grctl) — a lightweight workflow orchestration engine built for fail-safe execution.
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://cemevren.github.io/grctl/)**
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/cemevren/grctl/blob/main/CONTRIBUTING.md) for more information.
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/cemevren/grctl/blob/main/SECURITY.md) for reporting vulnerabilities.
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/cemevren/grctl/blob/main/LICENSE) file for details.
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/cemevren/grctl) — a lightweight workflow orchestration engine built for fail-safe execution.
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://cemevren.github.io/grctl/)**
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/cemevren/grctl/blob/main/CONTRIBUTING.md) for more information.
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/cemevren/grctl/blob/main/SECURITY.md) for reporting vulnerabilities.
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/cemevren/grctl/blob/main/LICENSE) file for details.
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 run_workflow(
34
- self,
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=workflow_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=workflow_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 '{workflow_id}' not found: {error_msg}")
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
- run_info = msgspec.msgpack.decode(response.payload, type=RunInfo)
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
- workflow_type: str,
87
- workflow_id: str,
88
- workflow_input: Any | None = None,
89
- workflow_timeout: timedelta | None = None,
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=workflow_type,
97
- wf_id=workflow_id,
98
- timeout=int(workflow_timeout.total_seconds()) if workflow_timeout else None,
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=workflow_input,
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 # Task return value
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(self, run_info: RunInfo, event: HistoryEvent) -> None:
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: RunManager,
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 StepConfig
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, StepConfig] | None = None,
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, StepConfig] | None = None,
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(event_name=event_name, payload=payload),
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 HandlerSpec
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(self.runtime.run_info, fail_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
- start_config = self.workflow._start_handler # noqa: SLF001
94
- if start_config is None:
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(self.runtime, start_config.handler, start_config.spec, payload)
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(self.runtime, handler_config.handler, handler_config.spec, payload)
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(self.runtime, step_config.handler, step_config.spec, None)
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
- async def _dispatch_handler(
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
- async def _publish_next_directive(
176
- runtime: StepRuntime,
177
- directive: Directive,
178
- step_start_time: datetime | None = None,
179
- ) -> None:
180
- """Process Directive returned by a handler."""
181
- if not isinstance(directive, Directive):
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
- if step_start_time is not None and isinstance(directive.msg, StepResult):
185
- directive.msg.duration_ms = int((datetime.now(UTC) - step_start_time).total_seconds() * 1000)
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
- pending_updates = runtime.store.get_pending_updates()
188
- if pending_updates:
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
- await runtime.publisher.publish_next_directive(runtime.run_info, directive)
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 journal doesn't match current execution order."""
32
+ """Raised when replay history doesn't match current execution order."""
33
33
 
34
34
 
35
- # Only these kinds participate in replay journal matching — everything else is observability-only
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 # journal exhausted — live execution
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 journal has {entry.kind} "
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) -> None:
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
- """Normalize task args to msgspec-encodable primitives via codec round-trip."""
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
- has_retryable = policy.retryable_errors is not None
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 has_retryable and not has_non_retryable:
90
- return error_type in policy.retryable_errors # type: ignore[operator] # ty:ignore[unsupported-operator]
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 not has_retryable and has_non_retryable:
93
- return error_type not in policy.non_retryable_errors # type: ignore[operator] # ty:ignore[unsupported-operator]
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 # type: ignore[operator] # ty:ignore[unsupported-operator]
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) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
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
- return event.output
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
- self._run_manager = RunManager(
89
- worker_name=self.worker_name,
90
- worker_id=self.worker_id,
91
- workflows=self._workflows,
92
- connection=self._connection,
93
- workflow_logger=self._workflow_logger,
94
- )
95
-
96
- wf_types = [wf.workflow_type for wf in self._workflows]
97
- self._subscriber = Subscriber(
98
- js=self._connection.js,
99
- manifest=self._connection.manifest,
100
- wf_types=wf_types,
101
- run_manager=self._run_manager,
102
- )
103
- await self._subscriber.start()
104
-
105
- logger.info(f"Worker {self.worker_name} ({self.worker_id}) started and ready to process messages")
106
-
107
- # Keep worker alive
108
- await self._process_messages()
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 StepConfig, Workflow
5
+ from grctl.workflow.workflow import HandlerConfig, Workflow
6
6
 
7
7
  __all__ = [
8
8
  "Directive",
9
- "StepConfig",
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
- await self._subscriber.stop()
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, StepConfig] = {}
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__] = StepConfig(
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.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 = "cemevren", email = "cemevren@gmail.com" },
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/cemevren/grctl"
37
- Documentation = "https://cemevren.github.io/grctl/"
38
- Repository = "https://github.com/cemevren/grctl"
39
- Issues = "https://github.com/cemevren/grctl/issues"
40
- Changelog = "https://github.com/cemevren/grctl/blob/main/sdk_python/CHANGELOG.md"
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