grctl-sdk-python 0.2.0__py3-none-any.whl → 0.2.1__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.
- grctl/client/__init__.py +16 -0
- grctl/client/client.py +161 -0
- grctl/models/__init__.py +139 -0
- grctl/models/api.py +13 -0
- grctl/models/command.py +117 -0
- grctl/models/common.py +10 -0
- grctl/models/directive.py +191 -0
- grctl/models/errors.py +10 -0
- grctl/models/history.py +358 -0
- grctl/models/run_info.py +77 -0
- grctl/models/run_info_helper.py +12 -0
- grctl/models/worker.py +76 -0
- grctl/nats/__init__.py +1 -0
- grctl/nats/connection.py +67 -0
- grctl/nats/history_fetch.py +78 -0
- grctl/nats/history_sub.py +66 -0
- grctl/nats/kv_store.py +45 -0
- grctl/nats/manifest.py +235 -0
- grctl/nats/nats_client.py +10 -0
- grctl/nats/nats_manifest.yaml +95 -0
- grctl/nats/publisher.py +46 -0
- grctl/nats/subscriber.py +124 -0
- grctl/py.typed +1 -0
- grctl/worker/__init__.py +8 -0
- grctl/worker/codec.py +49 -0
- grctl/worker/context.py +321 -0
- grctl/worker/errors.py +21 -0
- grctl/worker/logger.py +40 -0
- grctl/worker/run_manager.py +131 -0
- grctl/worker/runner.py +176 -0
- grctl/worker/runtime.py +185 -0
- grctl/worker/store.py +65 -0
- grctl/worker/task.py +406 -0
- grctl/worker/worker.py +173 -0
- grctl/workflow/__init__.py +12 -0
- grctl/workflow/future.py +158 -0
- grctl/workflow/handle.py +114 -0
- grctl/workflow/workflow.py +269 -0
- grctl_sdk_python-0.2.1.dist-info/METADATA +66 -0
- grctl_sdk_python-0.2.1.dist-info/RECORD +45 -0
- {grctl_sdk_python-0.2.0.dist-info → grctl_sdk_python-0.2.1.dist-info}/WHEEL +1 -2
- grctl_sdk_python-0.2.1.dist-info/licenses/LICENSE +201 -0
- grctl_sdk_python-0.2.0.dist-info/METADATA +0 -12
- grctl_sdk_python-0.2.0.dist-info/RECORD +0 -7
- grctl_sdk_python-0.2.0.dist-info/top_level.txt +0 -1
grctl/client/__init__.py
ADDED
|
@@ -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,161 @@
|
|
|
1
|
+
"""Workflow Engine Client.
|
|
2
|
+
|
|
3
|
+
Provides a simple interface for interacting with workflows.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from datetime import UTC, datetime, timedelta
|
|
8
|
+
from typing import Any, TypeVar, overload
|
|
9
|
+
|
|
10
|
+
import msgspec
|
|
11
|
+
from ulid import ULID
|
|
12
|
+
|
|
13
|
+
from grctl.models import DescribeCmd, GrctlAPIResponse, HistoryEvent, RunInfo
|
|
14
|
+
from grctl.models.command import CmdKind, Command
|
|
15
|
+
from grctl.models.errors import WorkflowAlreadyRunningError, WorkflowError, WorkflowNotFoundError
|
|
16
|
+
from grctl.nats.connection import Connection
|
|
17
|
+
from grctl.nats.history_fetch import fetch_run_history
|
|
18
|
+
from grctl.worker.codec import CodecRegistry
|
|
19
|
+
from grctl.workflow.handle import WorkflowHandle
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_T = TypeVar("_T")
|
|
24
|
+
|
|
25
|
+
ErrWorkflowAlreadyRunningCode = 4001
|
|
26
|
+
ErrWorkflowRunNotFoundCode = 4002
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Client:
|
|
30
|
+
"""Client for interacting with the Workflow Engine."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, connection: Connection, codec: CodecRegistry | None = None) -> None:
|
|
33
|
+
self._connection = connection
|
|
34
|
+
self._codec = codec or CodecRegistry()
|
|
35
|
+
|
|
36
|
+
async def describe(self, wf_id: str) -> RunInfo:
|
|
37
|
+
"""Describe the latest run for a workflow ID."""
|
|
38
|
+
cmd = Command(
|
|
39
|
+
id=str(ULID()),
|
|
40
|
+
kind=CmdKind.run_describe,
|
|
41
|
+
timestamp=datetime.now(UTC),
|
|
42
|
+
msg=DescribeCmd(wf_id=wf_id),
|
|
43
|
+
)
|
|
44
|
+
# Use a routing-only RunInfo — publish_cmd only needs wf_id for subject routing.
|
|
45
|
+
routing_info = RunInfo(id="", wf_type="", wf_id=wf_id)
|
|
46
|
+
response_bytes = await self._connection.publisher.publish_cmd(routing_info, cmd)
|
|
47
|
+
|
|
48
|
+
response = msgspec.msgpack.decode(response_bytes, type=GrctlAPIResponse)
|
|
49
|
+
if not response.success:
|
|
50
|
+
error_msg = response.error.message if response.error else "unknown error"
|
|
51
|
+
error_code = response.error.code if response.error else 0
|
|
52
|
+
if error_code == ErrWorkflowRunNotFoundCode:
|
|
53
|
+
raise WorkflowNotFoundError(f"workflow '{wf_id}' not found: {error_msg}")
|
|
54
|
+
raise WorkflowError(f"describe failed (code={error_code}): {error_msg}")
|
|
55
|
+
|
|
56
|
+
return msgspec.msgpack.decode(response.payload, type=RunInfo)
|
|
57
|
+
|
|
58
|
+
@overload
|
|
59
|
+
async def run_workflow(
|
|
60
|
+
self,
|
|
61
|
+
type: str,
|
|
62
|
+
id: str,
|
|
63
|
+
input: Any | None = ...,
|
|
64
|
+
timeout: timedelta | None = ..., # noqa: ASYNC109
|
|
65
|
+
return_type: type[_T] = ...,
|
|
66
|
+
) -> _T: ...
|
|
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: None = ...,
|
|
76
|
+
) -> Any: ...
|
|
77
|
+
|
|
78
|
+
async def run_workflow(
|
|
79
|
+
self,
|
|
80
|
+
type: str, # noqa: A002
|
|
81
|
+
id: str, # noqa: A002
|
|
82
|
+
input: Any | None = None, # noqa: A002
|
|
83
|
+
timeout: timedelta | None = None, # noqa: ASYNC109
|
|
84
|
+
return_type: type[_T] | None = None,
|
|
85
|
+
) -> _T | Any:
|
|
86
|
+
"""Run a workflow and wait for its result."""
|
|
87
|
+
wf_handle = await self.start_workflow(
|
|
88
|
+
type=type,
|
|
89
|
+
id=id,
|
|
90
|
+
input=input,
|
|
91
|
+
timeout=timeout,
|
|
92
|
+
return_type=return_type,
|
|
93
|
+
)
|
|
94
|
+
wait_timeout = timeout.total_seconds() if timeout else None
|
|
95
|
+
return await wf_handle.result(timeout=wait_timeout)
|
|
96
|
+
|
|
97
|
+
async def get_workflow_handle(self, wfid: str) -> WorkflowHandle:
|
|
98
|
+
"""Get a handle for an already-running workflow."""
|
|
99
|
+
run_info = await self.describe(wfid)
|
|
100
|
+
|
|
101
|
+
handle = WorkflowHandle(
|
|
102
|
+
run_info=run_info,
|
|
103
|
+
payload=None,
|
|
104
|
+
connection=self._connection,
|
|
105
|
+
codec=self._codec,
|
|
106
|
+
)
|
|
107
|
+
await handle.attach()
|
|
108
|
+
return handle
|
|
109
|
+
|
|
110
|
+
async def get_history(self, wf_id: str, run_id: str | None = None) -> list[HistoryEvent]:
|
|
111
|
+
"""Return the ordered history events for a workflow run."""
|
|
112
|
+
resolved_run_id = run_id
|
|
113
|
+
if resolved_run_id is None:
|
|
114
|
+
resolved_run_id = (await self.describe(wf_id)).id
|
|
115
|
+
|
|
116
|
+
return await fetch_run_history(
|
|
117
|
+
js=self._connection.js,
|
|
118
|
+
manifest=self._connection.manifest,
|
|
119
|
+
wf_id=wf_id,
|
|
120
|
+
run_id=resolved_run_id,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
async def start_workflow(
|
|
124
|
+
self,
|
|
125
|
+
type: str, # noqa: A002
|
|
126
|
+
id: str, # noqa: A002
|
|
127
|
+
input: Any | None = None, # noqa: A002
|
|
128
|
+
timeout: timedelta | None = None, # noqa: ASYNC109
|
|
129
|
+
return_type: type | None = None,
|
|
130
|
+
) -> WorkflowHandle:
|
|
131
|
+
"""Start a workflow and return a handle to track and interact with it."""
|
|
132
|
+
workflow_run_id = str(ULID())
|
|
133
|
+
|
|
134
|
+
run_info = RunInfo(
|
|
135
|
+
id=workflow_run_id,
|
|
136
|
+
wf_type=type,
|
|
137
|
+
wf_id=id,
|
|
138
|
+
timeout=int(timeout.total_seconds()) if timeout else None,
|
|
139
|
+
created_at=datetime.now(UTC),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
handle = WorkflowHandle(
|
|
143
|
+
run_info=run_info,
|
|
144
|
+
payload=input,
|
|
145
|
+
connection=self._connection,
|
|
146
|
+
codec=self._codec,
|
|
147
|
+
return_type=return_type,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Start the workflow future (subscribe to events and publish run command)
|
|
151
|
+
response_bytes = await handle.start()
|
|
152
|
+
response = msgspec.msgpack.decode(response_bytes, type=GrctlAPIResponse)
|
|
153
|
+
if not response.success:
|
|
154
|
+
await handle.future.stop()
|
|
155
|
+
error_msg = response.error.message if response.error else "unknown error"
|
|
156
|
+
error_code = response.error.code if response.error else 0
|
|
157
|
+
if error_code == ErrWorkflowAlreadyRunningCode:
|
|
158
|
+
raise WorkflowAlreadyRunningError(f"workflow '{id}' already has an active run: {error_msg}")
|
|
159
|
+
raise WorkflowError(f"start_workflow failed (code={error_code}): {error_msg}")
|
|
160
|
+
|
|
161
|
+
return handle
|
grctl/models/__init__.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
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
|
+
FailStep,
|
|
27
|
+
Start,
|
|
28
|
+
Step,
|
|
29
|
+
StepResult,
|
|
30
|
+
Wait,
|
|
31
|
+
directive_decoder,
|
|
32
|
+
directive_encoder,
|
|
33
|
+
)
|
|
34
|
+
from grctl.models.history import (
|
|
35
|
+
ChildWorkflowStarted,
|
|
36
|
+
DeterministicEvents,
|
|
37
|
+
ErrorDetails,
|
|
38
|
+
HistoryEvent,
|
|
39
|
+
HistoryKind,
|
|
40
|
+
ParentEventSent,
|
|
41
|
+
RandomRecorded,
|
|
42
|
+
RunCancelled,
|
|
43
|
+
RunCompleted,
|
|
44
|
+
RunEvents,
|
|
45
|
+
RunFailed,
|
|
46
|
+
RunScheduled,
|
|
47
|
+
RunStarted,
|
|
48
|
+
RunTimeout,
|
|
49
|
+
SleepRecorded,
|
|
50
|
+
StepCancelled,
|
|
51
|
+
StepCompleted,
|
|
52
|
+
StepEvents,
|
|
53
|
+
StepFailed,
|
|
54
|
+
StepStarted,
|
|
55
|
+
StepTimeout,
|
|
56
|
+
TaskAttemptFailed,
|
|
57
|
+
TaskCancelled,
|
|
58
|
+
TaskCompleted,
|
|
59
|
+
TaskEvents,
|
|
60
|
+
TaskFailed,
|
|
61
|
+
TaskStarted,
|
|
62
|
+
TimestampRecorded,
|
|
63
|
+
UuidRecorded,
|
|
64
|
+
WaitStarted,
|
|
65
|
+
WaitTimedOut,
|
|
66
|
+
history_decoder,
|
|
67
|
+
history_encoder,
|
|
68
|
+
)
|
|
69
|
+
from grctl.models.run_info import RunInfo, RunStateKind, RunStatus
|
|
70
|
+
from grctl.models.run_info_helper import RunInfoManager
|
|
71
|
+
|
|
72
|
+
__all__ = [ # noqa: RUF022
|
|
73
|
+
# API response types
|
|
74
|
+
"GrctlAPIError",
|
|
75
|
+
"GrctlAPIResponse",
|
|
76
|
+
# Command types
|
|
77
|
+
"Command",
|
|
78
|
+
"StartCmd",
|
|
79
|
+
"CancelCmd",
|
|
80
|
+
"DescribeCmd",
|
|
81
|
+
"EventCmd",
|
|
82
|
+
"CmdKind",
|
|
83
|
+
"command_decoder",
|
|
84
|
+
"command_encoder",
|
|
85
|
+
# Directive types
|
|
86
|
+
"Directive",
|
|
87
|
+
"DirectiveMessage",
|
|
88
|
+
"DirectiveKind",
|
|
89
|
+
"Start",
|
|
90
|
+
"Cancel",
|
|
91
|
+
"Event",
|
|
92
|
+
"Complete",
|
|
93
|
+
"Fail",
|
|
94
|
+
"FailStep",
|
|
95
|
+
"Step",
|
|
96
|
+
"StepResult",
|
|
97
|
+
"Wait",
|
|
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
|
+
"WaitStarted",
|
|
128
|
+
"WaitTimedOut",
|
|
129
|
+
"ChildWorkflowStarted",
|
|
130
|
+
"ParentEventSent",
|
|
131
|
+
"history_decoder",
|
|
132
|
+
"history_encoder",
|
|
133
|
+
# Common types
|
|
134
|
+
"RunInfo",
|
|
135
|
+
"RunStateKind",
|
|
136
|
+
"RunStatus",
|
|
137
|
+
"ErrorDetails",
|
|
138
|
+
"RunInfoManager",
|
|
139
|
+
]
|
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
|
grctl/models/command.py
ADDED
|
@@ -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,191 @@
|
|
|
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 Wait(msgspec.Struct):
|
|
58
|
+
"""Worker directive to park the run; optionally times out to a named step."""
|
|
59
|
+
|
|
60
|
+
timeout_ms: int = 0
|
|
61
|
+
timeout_step_name: str = ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Complete(msgspec.Struct):
|
|
65
|
+
"""Worker directive to mark workflow complete."""
|
|
66
|
+
|
|
67
|
+
result: Any
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Fail(msgspec.Struct):
|
|
71
|
+
"""Worker directive to mark workflow failed."""
|
|
72
|
+
|
|
73
|
+
error: ErrorDetails
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class FailStep(msgspec.Struct):
|
|
77
|
+
"""Worker directive to mark a step as failed and fail the run."""
|
|
78
|
+
|
|
79
|
+
step_name: str
|
|
80
|
+
error: ErrorDetails
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class DirectiveKind(StrEnum):
|
|
84
|
+
start = "start"
|
|
85
|
+
cancel = "cancel"
|
|
86
|
+
terminate = "terminate"
|
|
87
|
+
complete = "complete"
|
|
88
|
+
fail = "fail"
|
|
89
|
+
step = "step"
|
|
90
|
+
event = "event"
|
|
91
|
+
wait = "wait"
|
|
92
|
+
wait_timeout = "wait_timeout"
|
|
93
|
+
step_result = "step_result"
|
|
94
|
+
fail_step = "fail_step"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class StepResult(msgspec.Struct):
|
|
98
|
+
"""Worker directive to mark step complete."""
|
|
99
|
+
|
|
100
|
+
processed_msg_kind: DirectiveKind
|
|
101
|
+
# Any because processed_msg and next_msg are type-erased at wire level;
|
|
102
|
+
# the _kind fields carry the type info needed for deserialization.
|
|
103
|
+
processed_msg: Any
|
|
104
|
+
worker_id: str
|
|
105
|
+
kv_updates: dict[str, Any]
|
|
106
|
+
next_msg_kind: DirectiveKind
|
|
107
|
+
next_msg: Any
|
|
108
|
+
duration_ms: int = 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
DirectiveMessage = Start | Cancel | Event | Complete | Fail | Step | Wait | StepResult
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# Factory map for kind-based deserialization
|
|
115
|
+
directive_factories: dict[str, type[DirectiveMessage]] = {
|
|
116
|
+
"start": Start,
|
|
117
|
+
"cancel": Cancel,
|
|
118
|
+
"event": Event,
|
|
119
|
+
"complete": Complete,
|
|
120
|
+
"fail": Fail,
|
|
121
|
+
"step": Step,
|
|
122
|
+
"wait": Wait,
|
|
123
|
+
"step_result": StepResult,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Directive(msgspec.Struct):
|
|
128
|
+
id: str
|
|
129
|
+
timestamp: datetime
|
|
130
|
+
kind: DirectiveKind
|
|
131
|
+
run_info: RunInfo
|
|
132
|
+
msg: DirectiveMessage
|
|
133
|
+
attempt: int = 0
|
|
134
|
+
kv_revs: dict[str, Any] | None = None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class DirectiveWire(msgspec.Struct, omit_defaults=True):
|
|
138
|
+
"""Wire format for Directive with compact field names matching Go server.
|
|
139
|
+
|
|
140
|
+
Encoded as a dict/map (not array) to match Go's msgpack tag expectations.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
id: str
|
|
144
|
+
k: str # kind
|
|
145
|
+
m: bytes # message
|
|
146
|
+
r: RunInfo # run_info
|
|
147
|
+
t: datetime # timestamp
|
|
148
|
+
a: int = 0 # attempt
|
|
149
|
+
kv: dict[str, Any] | None = None # kv_revs
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def directive_encoder(directive: Directive, enc_hook: Any = None) -> bytes:
|
|
153
|
+
if directive.msg is None:
|
|
154
|
+
raise ValueError("Directive message cannot be None")
|
|
155
|
+
|
|
156
|
+
msg_bytes = msgspec.msgpack.encode(directive.msg, enc_hook=enc_hook)
|
|
157
|
+
|
|
158
|
+
wire = DirectiveWire(
|
|
159
|
+
id=directive.id,
|
|
160
|
+
k=directive.kind,
|
|
161
|
+
m=msg_bytes,
|
|
162
|
+
r=directive.run_info,
|
|
163
|
+
t=directive.timestamp,
|
|
164
|
+
a=directive.attempt,
|
|
165
|
+
kv=directive.kv_revs,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return msgspec.msgpack.encode(wire)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def directive_decoder(data: bytes) -> Directive:
|
|
172
|
+
wire = msgspec.msgpack.decode(data, type=DirectiveWire)
|
|
173
|
+
|
|
174
|
+
factory = directive_factories.get(wire.k)
|
|
175
|
+
if factory is None:
|
|
176
|
+
raise ValueError(f"Unknown directive kind: {wire.k}")
|
|
177
|
+
|
|
178
|
+
msg = msgspec.msgpack.decode(wire.m, type=factory)
|
|
179
|
+
|
|
180
|
+
# Convert kind string to DirectiveKind enum
|
|
181
|
+
kind_enum = DirectiveKind(wire.k)
|
|
182
|
+
|
|
183
|
+
return Directive(
|
|
184
|
+
id=wire.id,
|
|
185
|
+
kind=kind_enum,
|
|
186
|
+
attempt=wire.a,
|
|
187
|
+
kv_revs=wire.kv or {},
|
|
188
|
+
msg=msg,
|
|
189
|
+
run_info=wire.r,
|
|
190
|
+
timestamp=wire.t,
|
|
191
|
+
)
|
grctl/models/errors.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
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."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WorkflowAlreadyRunningError(WorkflowError):
|
|
10
|
+
"""Raised when a workflow ID already has an active run."""
|