grctl-sdk-python 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. grctl/client/__init__.py +16 -0
  2. grctl/client/client.py +175 -0
  3. grctl/models/__init__.py +153 -0
  4. grctl/models/api.py +13 -0
  5. grctl/models/command.py +161 -0
  6. grctl/models/common.py +10 -0
  7. grctl/models/directive.py +201 -0
  8. grctl/models/errors.py +14 -0
  9. grctl/models/history.py +370 -0
  10. grctl/models/run_info.py +80 -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 +78 -0
  16. grctl/nats/history_sub.py +66 -0
  17. grctl/nats/kv_store.py +45 -0
  18. grctl/nats/manifest.py +242 -0
  19. grctl/nats/nats_client.py +10 -0
  20. grctl/nats/nats_manifest.yaml +103 -0
  21. grctl/nats/publisher.py +46 -0
  22. grctl/nats/subscriber.py +125 -0
  23. grctl/py.typed +1 -0
  24. grctl/worker/__init__.py +9 -0
  25. grctl/worker/child.py +40 -0
  26. grctl/worker/codec.py +49 -0
  27. grctl/worker/context.py +374 -0
  28. grctl/worker/errors.py +29 -0
  29. grctl/worker/logger.py +40 -0
  30. grctl/worker/registration.py +117 -0
  31. grctl/worker/run_manager.py +141 -0
  32. grctl/worker/runner.py +203 -0
  33. grctl/worker/runtime.py +185 -0
  34. grctl/worker/store.py +65 -0
  35. grctl/worker/task.py +406 -0
  36. grctl/worker/worker.py +194 -0
  37. grctl/worker/worker_cmd_subscriber.py +62 -0
  38. grctl/workflow/__init__.py +12 -0
  39. grctl/workflow/future.py +185 -0
  40. grctl/workflow/handle.py +132 -0
  41. grctl/workflow/workflow.py +315 -0
  42. grctl_sdk_python-0.3.0.dist-info/METADATA +66 -0
  43. grctl_sdk_python-0.3.0.dist-info/RECORD +48 -0
  44. {grctl_sdk_python-0.2.0.dist-info → grctl_sdk_python-0.3.0.dist-info}/WHEEL +1 -2
  45. grctl_sdk_python-0.3.0.dist-info/licenses/LICENSE +201 -0
  46. grctl_sdk_python-0.2.0.dist-info/METADATA +0 -12
  47. grctl_sdk_python-0.2.0.dist-info/RECORD +0 -7
  48. grctl_sdk_python-0.2.0.dist-info/top_level.txt +0 -1
@@ -0,0 +1,16 @@
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.models.errors import WorkflowAlreadyRunningError, WorkflowError, WorkflowNotFoundError
6
+ from grctl.nats.connection import Connection
7
+
8
+ __all__ = [
9
+ "Client",
10
+ "Connection",
11
+ "WorkflowAlreadyRunningError",
12
+ "WorkflowError",
13
+ "WorkflowNotFoundError",
14
+ "get_logger",
15
+ "setup_logging",
16
+ ]
grctl/client/client.py ADDED
@@ -0,0 +1,175 @@
1
+ """Workflow Engine Client.
2
+
3
+ Provides a simple interface for interacting with workflows.
4
+ """
5
+
6
+ import logging
7
+ import secrets
8
+ import socket
9
+ from datetime import UTC, datetime, timedelta
10
+ from typing import Any, TypeVar, overload
11
+
12
+ import msgspec
13
+ from ulid import ULID
14
+
15
+ from grctl.models import DescribeCmd, GrctlAPIResponse, HistoryEvent, RunInfo
16
+ from grctl.models.command import CmdKind, Command
17
+ from grctl.models.errors import (
18
+ WorkflowAlreadyRunningError,
19
+ WorkflowError,
20
+ WorkflowNotFoundError,
21
+ WorkflowTypeNotRegisteredError,
22
+ )
23
+ from grctl.nats.connection import Connection
24
+ from grctl.nats.history_fetch import fetch_run_history
25
+ from grctl.worker.codec import CodecRegistry
26
+ from grctl.workflow.handle import WorkflowHandle
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ _T = TypeVar("_T")
31
+
32
+ ErrWorkflowAlreadyRunningCode = 4001
33
+ ErrWorkflowRunNotFoundCode = 4002
34
+ ErrWorkflowTypeNotRegisteredCode = 4004
35
+
36
+
37
+ class Client:
38
+ """Client for interacting with the Workflow Engine."""
39
+
40
+ def __init__(self, connection: Connection, codec: CodecRegistry | None = None) -> None:
41
+ self._connection = connection
42
+ self._codec = codec or CodecRegistry()
43
+ self.id = f"c_{secrets.token_hex(4)}@{socket.gethostname()}"
44
+
45
+ async def describe(self, wf_id: str) -> RunInfo:
46
+ """Describe the latest run for a workflow ID."""
47
+ cmd = Command(
48
+ id=str(ULID()),
49
+ kind=CmdKind.run_describe,
50
+ timestamp=datetime.now(UTC),
51
+ msg=DescribeCmd(wf_id=wf_id),
52
+ sender_id=self.id,
53
+ )
54
+ # Use a routing-only RunInfo — publish_cmd only needs wf_id for subject routing.
55
+ routing_info = RunInfo(id="", wf_type="", wf_id=wf_id)
56
+ response_bytes = await self._connection.publisher.publish_cmd(routing_info, cmd)
57
+
58
+ response = msgspec.msgpack.decode(response_bytes, type=GrctlAPIResponse)
59
+ if not response.success:
60
+ error_msg = response.error.message if response.error else "unknown error"
61
+ error_code = response.error.code if response.error else 0
62
+ if error_code == ErrWorkflowRunNotFoundCode:
63
+ raise WorkflowNotFoundError(f"workflow '{wf_id}' not found: {error_msg}")
64
+ raise WorkflowError(f"describe failed (code={error_code}): {error_msg}")
65
+
66
+ return msgspec.msgpack.decode(response.payload, type=RunInfo)
67
+
68
+ @overload
69
+ async def run_workflow(
70
+ self,
71
+ type: str,
72
+ id: str,
73
+ input: Any | None = ...,
74
+ timeout: timedelta | None = ..., # noqa: ASYNC109
75
+ return_type: type[_T] = ...,
76
+ ) -> _T: ...
77
+
78
+ @overload
79
+ async def run_workflow(
80
+ self,
81
+ type: str,
82
+ id: str,
83
+ input: Any | None = ...,
84
+ timeout: timedelta | None = ..., # noqa: ASYNC109
85
+ return_type: None = ...,
86
+ ) -> Any: ...
87
+
88
+ async def run_workflow(
89
+ self,
90
+ type: str, # noqa: A002
91
+ id: str, # noqa: A002
92
+ input: Any | None = None, # noqa: A002
93
+ timeout: timedelta | None = None, # noqa: ASYNC109
94
+ return_type: type[_T] | None = None,
95
+ ) -> _T | Any:
96
+ """Run a workflow and wait for its result."""
97
+ wf_handle = await self.start_workflow(
98
+ type=type,
99
+ id=id,
100
+ input=input,
101
+ timeout=timeout,
102
+ return_type=return_type,
103
+ )
104
+ wait_timeout = timeout.total_seconds() if timeout else None
105
+ return await wf_handle.result(timeout=wait_timeout)
106
+
107
+ async def get_workflow_handle(self, wfid: str) -> WorkflowHandle:
108
+ """Get a handle for an already-running workflow."""
109
+ run_info = await self.describe(wfid)
110
+
111
+ handle = WorkflowHandle(
112
+ run_info=run_info,
113
+ payload=None,
114
+ connection=self._connection,
115
+ codec=self._codec,
116
+ sender_id=self.id,
117
+ )
118
+ await handle.attach()
119
+ return handle
120
+
121
+ async def get_history(self, wf_id: str, run_id: str | None = None) -> list[HistoryEvent]:
122
+ """Return the ordered history events for a workflow run."""
123
+ resolved_run_id = run_id
124
+ if resolved_run_id is None:
125
+ resolved_run_id = (await self.describe(wf_id)).id
126
+
127
+ return await fetch_run_history(
128
+ js=self._connection.js,
129
+ manifest=self._connection.manifest,
130
+ wf_id=wf_id,
131
+ run_id=resolved_run_id,
132
+ )
133
+
134
+ async def start_workflow(
135
+ self,
136
+ type: str, # noqa: A002
137
+ id: str, # noqa: A002
138
+ input: Any | None = None, # noqa: A002
139
+ timeout: timedelta | None = None, # noqa: ASYNC109
140
+ return_type: type | None = None,
141
+ ) -> WorkflowHandle:
142
+ """Start a workflow and return a handle to track and interact with it."""
143
+ workflow_run_id = str(ULID())
144
+
145
+ run_info = RunInfo(
146
+ id=workflow_run_id,
147
+ wf_type=type,
148
+ wf_id=id,
149
+ timeout=int(timeout.total_seconds()) if timeout else None,
150
+ created_at=datetime.now(UTC),
151
+ )
152
+
153
+ handle = WorkflowHandle(
154
+ run_info=run_info,
155
+ payload=input,
156
+ connection=self._connection,
157
+ codec=self._codec,
158
+ return_type=return_type,
159
+ sender_id=self.id,
160
+ )
161
+
162
+ # Start the workflow future (subscribe to events and publish run command)
163
+ response_bytes = await handle.start()
164
+ response = msgspec.msgpack.decode(response_bytes, type=GrctlAPIResponse)
165
+ if not response.success:
166
+ await handle.future.stop()
167
+ error_msg = response.error.message if response.error else "unknown error"
168
+ error_code = response.error.code if response.error else 0
169
+ if error_code == ErrWorkflowAlreadyRunningCode:
170
+ raise WorkflowAlreadyRunningError(f"workflow '{id}' already has an active run: {error_msg}")
171
+ if error_code == ErrWorkflowTypeNotRegisteredCode:
172
+ raise WorkflowTypeNotRegisteredError(f"no worker registered for workflow type '{type}': {error_msg}")
173
+ raise WorkflowError(f"start_workflow failed (code={error_code}): {error_msg}")
174
+
175
+ return handle
@@ -0,0 +1,153 @@
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
+ EventDef,
15
+ RegisterCmd,
16
+ StartCmd,
17
+ TerminateCmd,
18
+ WorkflowTypeDef,
19
+ command_decoder,
20
+ command_encoder,
21
+ )
22
+ from grctl.models.directive import (
23
+ Cancel,
24
+ Complete,
25
+ Directive,
26
+ DirectiveKind,
27
+ DirectiveMessage,
28
+ Event,
29
+ Fail,
30
+ FailStep,
31
+ Start,
32
+ Step,
33
+ StepPickedUp,
34
+ StepResult,
35
+ Wait,
36
+ directive_decoder,
37
+ directive_encoder,
38
+ )
39
+ from grctl.models.history import (
40
+ ChildWorkflowStarted,
41
+ DeterministicEvents,
42
+ ErrorDetails,
43
+ HistoryEvent,
44
+ HistoryKind,
45
+ ParentEventSent,
46
+ RandomRecorded,
47
+ RunCancelled,
48
+ RunCancelReceived,
49
+ RunCompleted,
50
+ RunEvents,
51
+ RunFailed,
52
+ RunScheduled,
53
+ RunStarted,
54
+ RunTerminated,
55
+ RunTimeout,
56
+ SleepRecorded,
57
+ StepCancelled,
58
+ StepCompleted,
59
+ StepEvents,
60
+ StepFailed,
61
+ StepStarted,
62
+ StepTimeout,
63
+ TaskAttemptFailed,
64
+ TaskCancelled,
65
+ TaskCompleted,
66
+ TaskEvents,
67
+ TaskFailed,
68
+ TaskStarted,
69
+ TimestampRecorded,
70
+ UuidRecorded,
71
+ WaitStarted,
72
+ WaitTimedOut,
73
+ history_decoder,
74
+ history_encoder,
75
+ )
76
+ from grctl.models.run_info import RunInfo, RunStateKind, RunStatus
77
+ from grctl.models.run_info_helper import RunInfoManager
78
+
79
+ __all__ = [ # noqa: RUF022
80
+ # API response types
81
+ "GrctlAPIError",
82
+ "GrctlAPIResponse",
83
+ # Command types
84
+ "Command",
85
+ "StartCmd",
86
+ "CancelCmd",
87
+ "DescribeCmd",
88
+ "EventCmd",
89
+ "RegisterCmd",
90
+ "TerminateCmd",
91
+ "WorkflowTypeDef",
92
+ "EventDef",
93
+ "CmdKind",
94
+ "command_decoder",
95
+ "command_encoder",
96
+ # Directive types
97
+ "Directive",
98
+ "DirectiveMessage",
99
+ "DirectiveKind",
100
+ "Start",
101
+ "Cancel",
102
+ "Event",
103
+ "Complete",
104
+ "Fail",
105
+ "FailStep",
106
+ "Step",
107
+ "StepPickedUp",
108
+ "StepResult",
109
+ "Wait",
110
+ "directive_decoder",
111
+ "directive_encoder",
112
+ # History event types
113
+ "HistoryEvent",
114
+ "HistoryKind",
115
+ "RunScheduled",
116
+ "RunStarted",
117
+ "RunCompleted",
118
+ "RunEvents",
119
+ "RunFailed",
120
+ "RunCancelReceived",
121
+ "RunCancelled",
122
+ "RunTerminated",
123
+ "RunTimeout",
124
+ "StepEvents",
125
+ "StepStarted",
126
+ "StepCompleted",
127
+ "StepFailed",
128
+ "StepCancelled",
129
+ "StepTimeout",
130
+ "TaskEvents",
131
+ "TaskStarted",
132
+ "TaskCompleted",
133
+ "TaskFailed",
134
+ "TaskAttemptFailed",
135
+ "TaskCancelled",
136
+ "DeterministicEvents",
137
+ "TimestampRecorded",
138
+ "RandomRecorded",
139
+ "UuidRecorded",
140
+ "SleepRecorded",
141
+ "WaitStarted",
142
+ "WaitTimedOut",
143
+ "ChildWorkflowStarted",
144
+ "ParentEventSent",
145
+ "history_decoder",
146
+ "history_encoder",
147
+ # Common types
148
+ "RunInfo",
149
+ "RunStateKind",
150
+ "RunStatus",
151
+ "ErrorDetails",
152
+ "RunInfoManager",
153
+ ]
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,161 @@
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
+ worker_register = "worker.register"
17
+ worker_terminate_run = "worker.terminate_run"
18
+
19
+
20
+ class StartCmd(msgspec.Struct):
21
+ """Request to start workflow execution."""
22
+
23
+ run_info: RunInfo
24
+ input: Any | None
25
+
26
+
27
+ class CancelCmd(msgspec.Struct):
28
+ """Request to cancel a running workflow."""
29
+
30
+ wf_id: str
31
+ reason: str | None
32
+
33
+
34
+ class DescribeCmd(msgspec.Struct):
35
+ """Request to describe a workflow run."""
36
+
37
+ wf_id: str
38
+
39
+
40
+ class EventCmd(msgspec.Struct):
41
+ """Request to emit an event to a running workflow."""
42
+
43
+ wf_id: str
44
+ event_name: str
45
+ payload: Any | None
46
+
47
+
48
+ class TerminateCmd(msgspec.Struct):
49
+ """Request to terminate a running workflow."""
50
+
51
+ wf_id: str
52
+ reason: str | None
53
+
54
+
55
+ class WorkerTerminateRunCmd(msgspec.Struct):
56
+ """Server→worker signal to cancel a specific in-flight run."""
57
+
58
+ run_id: str
59
+
60
+
61
+ class EventDef(msgspec.Struct, kw_only=True):
62
+ """Per-event timeout config carried through registration."""
63
+
64
+ name: str
65
+ timeout_ms: int = 0
66
+
67
+
68
+ class WorkflowTypeDef(msgspec.Struct):
69
+ """Structural definition of one workflow type reported at registration.
70
+
71
+ Field names and order mirror the Go WorkflowTypeDef msgpack tags.
72
+ """
73
+
74
+ type: str
75
+ start_step: str
76
+ steps: list[str]
77
+ events: list[EventDef]
78
+ queries: list[str]
79
+ start_step_timeout_ms: int = 0
80
+
81
+
82
+ class RegisterCmd(msgspec.Struct):
83
+ """Worker startup sync of its workflow type catalog to the server."""
84
+
85
+ worker_id: str
86
+ types: list[WorkflowTypeDef]
87
+
88
+
89
+ type CommandMessage = StartCmd | EventCmd | CancelCmd | DescribeCmd | TerminateCmd | RegisterCmd | WorkerTerminateRunCmd
90
+
91
+
92
+ class Command(msgspec.Struct):
93
+ id: str
94
+ kind: CmdKind
95
+ timestamp: datetime
96
+ msg: CommandMessage
97
+ sender_id: str = ""
98
+
99
+
100
+ # Factory map for kind-based deserialization
101
+ command_factories: dict[str, type] = {
102
+ "run.start": StartCmd,
103
+ "run.cancel": CancelCmd,
104
+ "run.describe": DescribeCmd,
105
+ "run.terminate": TerminateCmd,
106
+ "run.event": EventCmd,
107
+ "worker.register": RegisterCmd,
108
+ "worker.terminate_run": WorkerTerminateRunCmd,
109
+ }
110
+
111
+
112
+ class CommandWire(msgspec.Struct):
113
+ """Wire format for Command with compact field names matching Go server.
114
+
115
+ Encoded as a dict/map (not array) to match Go's msgpack tag expectations.
116
+ """
117
+
118
+ id: str
119
+ k: CmdKind
120
+ m: bytes
121
+ t: datetime
122
+ s: str = ""
123
+
124
+
125
+ def command_encoder(cmd: Command) -> bytes:
126
+ """Encode command to msgpack with compact wire format."""
127
+ if cmd.msg is None:
128
+ raise ValueError("Command message cannot be None")
129
+ if cmd.sender_id == "":
130
+ raise ValueError("Command sender ID cannot be empty")
131
+
132
+ msg_bytes = msgspec.msgpack.encode(cmd.msg)
133
+
134
+ wire = CommandWire(
135
+ id=cmd.id,
136
+ k=cmd.kind,
137
+ m=msg_bytes,
138
+ t=cmd.timestamp,
139
+ s=cmd.sender_id,
140
+ )
141
+
142
+ return msgspec.msgpack.encode(wire)
143
+
144
+
145
+ def command_decoder(data: bytes) -> Command:
146
+ """Decode msgpack to command."""
147
+ wire = msgspec.msgpack.decode(data, type=CommandWire)
148
+
149
+ factory = command_factories.get(wire.k)
150
+ if factory is None:
151
+ raise ValueError(f"Unknown command kind: {wire.k}")
152
+
153
+ msg = msgspec.msgpack.decode(wire.m, type=factory)
154
+
155
+ return Command(
156
+ id=wire.id,
157
+ kind=wire.k,
158
+ msg=msg, # ty:ignore[invalid-argument-type]
159
+ timestamp=wire.t,
160
+ sender_id=wire.s,
161
+ )
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 = ""