grctl-sdk-python 0.1.0__py3-none-any.whl → 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. grctl/client/__init__.py +7 -0
  2. grctl/client/client.py +111 -0
  3. grctl/models/__init__.py +137 -0
  4. grctl/models/api.py +13 -0
  5. grctl/models/command.py +117 -0
  6. grctl/models/common.py +10 -0
  7. grctl/models/directive.py +200 -0
  8. grctl/models/errors.py +6 -0
  9. grctl/models/history.py +341 -0
  10. grctl/models/run_info.py +79 -0
  11. grctl/models/run_info_helper.py +12 -0
  12. grctl/models/worker.py +76 -0
  13. grctl/nats/__init__.py +1 -0
  14. grctl/nats/connection.py +67 -0
  15. grctl/nats/history_fetch.py +44 -0
  16. grctl/nats/history_sub.py +66 -0
  17. grctl/nats/kv_store.py +44 -0
  18. grctl/nats/manifest.py +235 -0
  19. grctl/nats/nats_client.py +10 -0
  20. grctl/nats/nats_manifest.yaml +95 -0
  21. grctl/nats/publisher.py +46 -0
  22. grctl/nats/subscriber.py +124 -0
  23. grctl/py.typed +1 -0
  24. grctl/worker/__init__.py +8 -0
  25. grctl/worker/codec.py +49 -0
  26. grctl/worker/context.py +290 -0
  27. grctl/worker/errors.py +28 -0
  28. grctl/worker/logger.py +40 -0
  29. grctl/worker/run_manager.py +131 -0
  30. grctl/worker/runner.py +175 -0
  31. grctl/worker/runtime.py +185 -0
  32. grctl/worker/store.py +65 -0
  33. grctl/worker/task.py +405 -0
  34. grctl/worker/worker.py +153 -0
  35. grctl/workflow/__init__.py +12 -0
  36. grctl/workflow/future.py +147 -0
  37. grctl/workflow/handle.py +72 -0
  38. grctl/workflow/workflow.py +262 -0
  39. {grctl_sdk_python-0.1.0.dist-info → grctl_sdk_python-0.1.2.dist-info}/METADATA +25 -4
  40. grctl_sdk_python-0.1.2.dist-info/RECORD +45 -0
  41. {grctl_sdk_python-0.1.0.dist-info → grctl_sdk_python-0.1.2.dist-info}/WHEEL +1 -2
  42. grctl_sdk_python-0.1.2.dist-info/licenses/LICENSE +201 -0
  43. grctl_sdk_python-0.1.0.dist-info/RECORD +0 -7
  44. grctl_sdk_python-0.1.0.dist-info/top_level.txt +0 -1
@@ -0,0 +1,7 @@
1
+ """Ground Control Python SDK client package."""
2
+
3
+ from grctl.client.client import Client
4
+ from grctl.logging_config import get_logger, setup_logging
5
+ from grctl.nats.connection import Connection
6
+
7
+ __all__ = ["Client", "Connection", "get_logger", "setup_logging"]
grctl/client/client.py ADDED
@@ -0,0 +1,111 @@
1
+ """Workflow Engine Client.
2
+
3
+ Provides a simple interface for interacting with workflows.
4
+ """
5
+
6
+ import asyncio
7
+ import logging
8
+ from datetime import UTC, datetime, timedelta
9
+ from typing import Any
10
+
11
+ import msgspec
12
+ from ulid import ULID
13
+
14
+ from grctl.models import DescribeCmd, GrctlAPIResponse, RunInfo
15
+ from grctl.models.command import CmdKind, Command
16
+ from grctl.models.errors import WorkflowError, WorkflowNotFoundError
17
+ from grctl.nats.connection import Connection
18
+ from grctl.worker.codec import CodecRegistry
19
+ from grctl.workflow.handle import WorkflowHandle
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ ErrWorkflowRunNotFoundCode = 4002
24
+
25
+
26
+ class Client:
27
+ """Client for interacting with the Workflow Engine."""
28
+
29
+ def __init__(self, connection: Connection, codec: CodecRegistry | None = None) -> None:
30
+ self._connection = connection
31
+ self._codec = codec or CodecRegistry()
32
+
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."""
55
+ cmd = Command(
56
+ id=str(ULID()),
57
+ kind=CmdKind.run_describe,
58
+ timestamp=datetime.now(UTC),
59
+ msg=DescribeCmd(wf_id=workflow_id),
60
+ )
61
+ # 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)
63
+ response_bytes = await self._connection.publisher.publish_cmd(routing_info, cmd)
64
+
65
+ response = msgspec.msgpack.decode(response_bytes, type=GrctlAPIResponse)
66
+ if not response.success:
67
+ error_msg = response.error.message if response.error else "unknown error"
68
+ error_code = response.error.code if response.error else 0
69
+ if error_code == ErrWorkflowRunNotFoundCode:
70
+ raise WorkflowNotFoundError(f"workflow '{workflow_id}' not found: {error_msg}")
71
+ raise WorkflowError(f"describe failed (code={error_code}): {error_msg}")
72
+
73
+ run_info = msgspec.msgpack.decode(response.payload, type=RunInfo)
74
+
75
+ handle = WorkflowHandle(
76
+ run_info=run_info,
77
+ payload=None,
78
+ connection=self._connection,
79
+ codec=self._codec,
80
+ )
81
+ await handle.attach()
82
+ return handle
83
+
84
+ async def start_workflow(
85
+ self,
86
+ workflow_type: str,
87
+ workflow_id: str,
88
+ workflow_input: Any | None = None,
89
+ workflow_timeout: timedelta | None = None,
90
+ ) -> WorkflowHandle:
91
+ """Start a workflow and return a handle to track and interact with it."""
92
+ workflow_run_id = str(ULID())
93
+
94
+ run_info = RunInfo(
95
+ 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,
99
+ created_at=datetime.now(UTC),
100
+ )
101
+
102
+ handle = WorkflowHandle(
103
+ run_info=run_info,
104
+ payload=workflow_input,
105
+ connection=self._connection,
106
+ codec=self._codec,
107
+ )
108
+
109
+ # Start the workflow future (subscribe to events and publish run command)
110
+ await handle.start()
111
+ return handle
@@ -0,0 +1,137 @@
1
+ """Messaging infrastructure for workflow run operations.
2
+
3
+ This module provides NATS-based messaging for workflow lifecycle events,
4
+ enabling clients to start workflows and track execution through observable events.
5
+ """
6
+
7
+ from grctl.models.api import GrctlAPIError, GrctlAPIResponse
8
+ from grctl.models.command import (
9
+ CancelCmd,
10
+ CmdKind,
11
+ Command,
12
+ DescribeCmd,
13
+ EventCmd,
14
+ StartCmd,
15
+ command_decoder,
16
+ command_encoder,
17
+ )
18
+ from grctl.models.directive import (
19
+ Cancel,
20
+ Complete,
21
+ Directive,
22
+ DirectiveKind,
23
+ DirectiveMessage,
24
+ Event,
25
+ Fail,
26
+ Sleep,
27
+ SleepUntil,
28
+ Start,
29
+ Step,
30
+ StepResult,
31
+ WaitEvent,
32
+ directive_decoder,
33
+ directive_encoder,
34
+ )
35
+ from grctl.models.history import (
36
+ ChildWorkflowStarted,
37
+ DeterministicEvents,
38
+ ErrorDetails,
39
+ HistoryEvent,
40
+ HistoryKind,
41
+ ParentEventSent,
42
+ RandomRecorded,
43
+ RunCancelled,
44
+ RunCompleted,
45
+ RunEvents,
46
+ RunFailed,
47
+ RunScheduled,
48
+ RunStarted,
49
+ RunTimeout,
50
+ SleepRecorded,
51
+ StepCancelled,
52
+ StepCompleted,
53
+ StepEvents,
54
+ StepFailed,
55
+ StepStarted,
56
+ StepTimeout,
57
+ TaskAttemptFailed,
58
+ TaskCancelled,
59
+ TaskCompleted,
60
+ TaskEvents,
61
+ TaskFailed,
62
+ TaskStarted,
63
+ TimestampRecorded,
64
+ UuidRecorded,
65
+ history_decoder,
66
+ history_encoder,
67
+ )
68
+ from grctl.models.run_info import RunInfo, RunStateKind, RunStatus
69
+ from grctl.models.run_info_helper import RunInfoManager
70
+
71
+ __all__ = [ # noqa: RUF022
72
+ # API response types
73
+ "GrctlAPIError",
74
+ "GrctlAPIResponse",
75
+ # Command types
76
+ "Command",
77
+ "StartCmd",
78
+ "CancelCmd",
79
+ "DescribeCmd",
80
+ "EventCmd",
81
+ "CmdKind",
82
+ "command_decoder",
83
+ "command_encoder",
84
+ # Directive types
85
+ "Directive",
86
+ "DirectiveMessage",
87
+ "DirectiveKind",
88
+ "Start",
89
+ "Cancel",
90
+ "Event",
91
+ "Complete",
92
+ "Fail",
93
+ "Step",
94
+ "StepResult",
95
+ "WaitEvent",
96
+ "Sleep",
97
+ "SleepUntil",
98
+ "directive_decoder",
99
+ "directive_encoder",
100
+ # History event types
101
+ "HistoryEvent",
102
+ "HistoryKind",
103
+ "RunScheduled",
104
+ "RunStarted",
105
+ "RunCompleted",
106
+ "RunEvents",
107
+ "RunFailed",
108
+ "RunCancelled",
109
+ "RunTimeout",
110
+ "StepEvents",
111
+ "StepStarted",
112
+ "StepCompleted",
113
+ "StepFailed",
114
+ "StepCancelled",
115
+ "StepTimeout",
116
+ "TaskEvents",
117
+ "TaskStarted",
118
+ "TaskCompleted",
119
+ "TaskFailed",
120
+ "TaskAttemptFailed",
121
+ "TaskCancelled",
122
+ "DeterministicEvents",
123
+ "TimestampRecorded",
124
+ "RandomRecorded",
125
+ "UuidRecorded",
126
+ "SleepRecorded",
127
+ "ChildWorkflowStarted",
128
+ "ParentEventSent",
129
+ "history_decoder",
130
+ "history_encoder",
131
+ # Common types
132
+ "RunInfo",
133
+ "RunStateKind",
134
+ "RunStatus",
135
+ "ErrorDetails",
136
+ "RunInfoManager",
137
+ ]
grctl/models/api.py ADDED
@@ -0,0 +1,13 @@
1
+ import msgspec
2
+
3
+
4
+ class GrctlAPIError(msgspec.Struct):
5
+ code: int
6
+ message: str
7
+ detail: str = ""
8
+
9
+
10
+ class GrctlAPIResponse(msgspec.Struct):
11
+ success: bool
12
+ payload: msgspec.Raw = msgspec.field(default_factory=lambda: msgspec.Raw(b""))
13
+ error: GrctlAPIError | None = None
@@ -0,0 +1,117 @@
1
+ from datetime import datetime
2
+ from enum import StrEnum
3
+ from typing import Any
4
+
5
+ import msgspec
6
+
7
+ from grctl.models.run_info import RunInfo
8
+
9
+
10
+ class CmdKind(StrEnum):
11
+ run_start = "run.start"
12
+ run_cancel = "run.cancel"
13
+ run_describe = "run.describe"
14
+ run_terminate = "run.terminate"
15
+ run_event = "run.event"
16
+
17
+
18
+ class StartCmd(msgspec.Struct):
19
+ """Request to start workflow execution."""
20
+
21
+ run_info: RunInfo
22
+ input: Any | None
23
+
24
+
25
+ class CancelCmd(msgspec.Struct):
26
+ """Request to cancel a running workflow."""
27
+
28
+ wf_id: str
29
+ reason: str | None
30
+
31
+
32
+ class DescribeCmd(msgspec.Struct):
33
+ """Request to describe a workflow run."""
34
+
35
+ wf_id: str
36
+
37
+
38
+ class EventCmd(msgspec.Struct):
39
+ """Request to emit an event to a running workflow."""
40
+
41
+ wf_id: str
42
+ event_name: str
43
+ payload: Any | None
44
+
45
+
46
+ class TerminateCmd(msgspec.Struct):
47
+ """Request to terminate a running workflow."""
48
+
49
+ wf_id: str
50
+ reason: str | None
51
+
52
+
53
+ type CommandMessage = StartCmd | EventCmd | CancelCmd | DescribeCmd | TerminateCmd
54
+
55
+
56
+ class Command(msgspec.Struct):
57
+ id: str
58
+ kind: CmdKind
59
+ timestamp: datetime
60
+ msg: CommandMessage
61
+
62
+
63
+ # Factory map for kind-based deserialization
64
+ command_factories: dict[str, type] = {
65
+ "run.start": StartCmd,
66
+ "run.cancel": CancelCmd,
67
+ "run.describe": DescribeCmd,
68
+ "run.terminate": TerminateCmd,
69
+ "run.event": EventCmd,
70
+ }
71
+
72
+
73
+ class CommandWire(msgspec.Struct):
74
+ """Wire format for Command with compact field names matching Go server.
75
+
76
+ Encoded as a dict/map (not array) to match Go's msgpack tag expectations.
77
+ """
78
+
79
+ id: str
80
+ k: CmdKind
81
+ m: bytes
82
+ t: datetime
83
+
84
+
85
+ def command_encoder(cmd: Command) -> bytes:
86
+ """Encode command to msgpack with compact wire format."""
87
+ if cmd.msg is None:
88
+ raise ValueError("Command message cannot be None")
89
+
90
+ msg_bytes = msgspec.msgpack.encode(cmd.msg)
91
+
92
+ wire = CommandWire(
93
+ id=cmd.id,
94
+ k=cmd.kind,
95
+ m=msg_bytes,
96
+ t=cmd.timestamp,
97
+ )
98
+
99
+ return msgspec.msgpack.encode(wire)
100
+
101
+
102
+ def command_decoder(data: bytes) -> Command:
103
+ """Decode msgpack to command."""
104
+ wire = msgspec.msgpack.decode(data, type=CommandWire)
105
+
106
+ factory = command_factories.get(wire.k)
107
+ if factory is None:
108
+ raise ValueError(f"Unknown command kind: {wire.k}")
109
+
110
+ msg = msgspec.msgpack.decode(wire.m, type=factory)
111
+
112
+ return Command(
113
+ id=wire.id,
114
+ kind=wire.k,
115
+ msg=msg, # ty:ignore[invalid-argument-type]
116
+ timestamp=wire.t,
117
+ )
grctl/models/common.py ADDED
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from msgspec import Struct
4
+
5
+
6
+ class ErrorDetails(Struct):
7
+ type: str
8
+ message: str
9
+ stack_trace: str
10
+ qualified_type: str = ""
@@ -0,0 +1,200 @@
1
+ """Custom msgpack encoding/decoding for Directive types.
2
+
3
+ This module provides msgpack serialization for Directive messages with a compact wire format
4
+ that matches the Go server implementation.
5
+ """
6
+
7
+ from datetime import datetime
8
+ from enum import StrEnum
9
+ from typing import Any
10
+
11
+ import msgspec
12
+
13
+ from grctl.logging_config import get_logger
14
+ from grctl.models.common import ErrorDetails
15
+ from grctl.models.run_info import RunInfo
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ class RetryPolicy(msgspec.Struct, omit_defaults=True):
21
+ max_attempts: int | None = None
22
+ initial_delay_ms: int | None = None
23
+ backoff_multiplier: float | None = None
24
+ max_delay_ms: int | None = None
25
+ jitter: float | None = None
26
+ retryable_errors: list[str] | None = None
27
+ non_retryable_errors: list[str] | None = None
28
+
29
+
30
+ class Start(msgspec.Struct):
31
+ """Request to start workflow execution."""
32
+
33
+ input: Any | None = None
34
+ timeout_ms: int | None = 3_000 # 3 seconds in nanoseconds (Go time.Duration)
35
+
36
+
37
+ class Cancel(msgspec.Struct):
38
+ """Request to cancel a running workflow."""
39
+
40
+ reason: str | None = None
41
+
42
+
43
+ class Event(msgspec.Struct):
44
+ """Request to emit an event to a running workflow."""
45
+
46
+ event_name: str
47
+ payload: Any | None = None
48
+
49
+
50
+ class Step(msgspec.Struct):
51
+ """Request to execute a specific step in a workflow."""
52
+
53
+ step_name: str
54
+ timeout_ms: int | None = 3_000 # 3 seconds in nanoseconds (Go time.Duration)
55
+
56
+
57
+ class WaitEvent(msgspec.Struct):
58
+ """Worker directive to wait for events."""
59
+
60
+ timeout_ms: int = 3000
61
+ timeout_step_name: str | None = None
62
+
63
+
64
+ class Sleep(msgspec.Struct):
65
+ """Worker directive to sleep for duration."""
66
+
67
+ next_step_name: str
68
+ duration_ms: int = 3000
69
+
70
+
71
+ class SleepUntil(msgspec.Struct):
72
+ """Worker directive to sleep until timestamp."""
73
+
74
+ until: datetime
75
+ next_step_name: str
76
+
77
+
78
+ class Complete(msgspec.Struct):
79
+ """Worker directive to mark workflow complete."""
80
+
81
+ result: Any
82
+
83
+
84
+ class Fail(msgspec.Struct):
85
+ """Worker directive to mark workflow failed."""
86
+
87
+ error: ErrorDetails
88
+
89
+
90
+ class DirectiveKind(StrEnum):
91
+ start = "start"
92
+ cancel = "cancel"
93
+ terminate = "terminate"
94
+ complete = "complete"
95
+ fail = "fail"
96
+ step = "step"
97
+ event = "event"
98
+ wait_event = "wait_event"
99
+ sleep = "sleep"
100
+ sleep_until = "sleep_until"
101
+ step_result = "step_result"
102
+
103
+
104
+ class StepResult(msgspec.Struct):
105
+ """Worker directive to mark step complete."""
106
+
107
+ processed_msg_kind: DirectiveKind
108
+ # Any because processed_msg and next_msg are type-erased at wire level;
109
+ # the _kind fields carry the type info needed for deserialization.
110
+ processed_msg: Any
111
+ worker_id: str
112
+ kv_updates: dict[str, Any]
113
+ next_msg_kind: DirectiveKind
114
+ next_msg: Any
115
+ duration_ms: int = 0
116
+
117
+
118
+ DirectiveMessage = Start | Cancel | Event | Complete | Fail | Step | WaitEvent | Sleep | SleepUntil | StepResult
119
+
120
+
121
+ # Factory map for kind-based deserialization
122
+ directive_factories: dict[str, type[DirectiveMessage]] = {
123
+ "start": Start,
124
+ "cancel": Cancel,
125
+ "event": Event,
126
+ "complete": Complete,
127
+ "fail": Fail,
128
+ "step": Step,
129
+ "wait_event": WaitEvent,
130
+ "sleep": Sleep,
131
+ "sleep_until": SleepUntil,
132
+ "step_result": StepResult,
133
+ }
134
+
135
+
136
+ class Directive(msgspec.Struct):
137
+ id: str
138
+ timestamp: datetime
139
+ kind: DirectiveKind
140
+ run_info: RunInfo
141
+ msg: DirectiveMessage
142
+ attempt: int = 0
143
+ kv_revs: dict[str, Any] | None = None
144
+
145
+
146
+ class DirectiveWire(msgspec.Struct, omit_defaults=True):
147
+ """Wire format for Directive with compact field names matching Go server.
148
+
149
+ Encoded as a dict/map (not array) to match Go's msgpack tag expectations.
150
+ """
151
+
152
+ id: str
153
+ k: str # kind
154
+ m: bytes # message
155
+ r: RunInfo # run_info
156
+ t: datetime # timestamp
157
+ a: int = 0 # attempt
158
+ kv: dict[str, Any] | None = None # kv_revs
159
+
160
+
161
+ def directive_encoder(directive: Directive, enc_hook: Any = None) -> bytes:
162
+ if directive.msg is None:
163
+ raise ValueError("Directive message cannot be None")
164
+
165
+ msg_bytes = msgspec.msgpack.encode(directive.msg, enc_hook=enc_hook)
166
+
167
+ wire = DirectiveWire(
168
+ id=directive.id,
169
+ k=directive.kind,
170
+ m=msg_bytes,
171
+ r=directive.run_info,
172
+ t=directive.timestamp,
173
+ a=directive.attempt,
174
+ kv=directive.kv_revs,
175
+ )
176
+
177
+ return msgspec.msgpack.encode(wire)
178
+
179
+
180
+ def directive_decoder(data: bytes) -> Directive:
181
+ wire = msgspec.msgpack.decode(data, type=DirectiveWire)
182
+
183
+ factory = directive_factories.get(wire.k)
184
+ if factory is None:
185
+ raise ValueError(f"Unknown directive kind: {wire.k}")
186
+
187
+ msg = msgspec.msgpack.decode(wire.m, type=factory)
188
+
189
+ # Convert kind string to DirectiveKind enum
190
+ kind_enum = DirectiveKind(wire.k)
191
+
192
+ return Directive(
193
+ id=wire.id,
194
+ kind=kind_enum,
195
+ attempt=wire.a,
196
+ kv_revs=wire.kv or {},
197
+ msg=msg,
198
+ run_info=wire.r,
199
+ timestamp=wire.t,
200
+ )
grctl/models/errors.py ADDED
@@ -0,0 +1,6 @@
1
+ class WorkflowError(Exception):
2
+ """Workflow error."""
3
+
4
+
5
+ class WorkflowNotFoundError(WorkflowError):
6
+ """Raised when a workflow ID does not correspond to any active run."""