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.
- grctl/client/__init__.py +16 -0
- grctl/client/client.py +175 -0
- grctl/models/__init__.py +153 -0
- grctl/models/api.py +13 -0
- grctl/models/command.py +161 -0
- grctl/models/common.py +10 -0
- grctl/models/directive.py +201 -0
- grctl/models/errors.py +14 -0
- grctl/models/history.py +370 -0
- grctl/models/run_info.py +80 -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 +242 -0
- grctl/nats/nats_client.py +10 -0
- grctl/nats/nats_manifest.yaml +103 -0
- grctl/nats/publisher.py +46 -0
- grctl/nats/subscriber.py +125 -0
- grctl/py.typed +1 -0
- grctl/worker/__init__.py +9 -0
- grctl/worker/child.py +40 -0
- grctl/worker/codec.py +49 -0
- grctl/worker/context.py +374 -0
- grctl/worker/errors.py +29 -0
- grctl/worker/logger.py +40 -0
- grctl/worker/registration.py +117 -0
- grctl/worker/run_manager.py +141 -0
- grctl/worker/runner.py +203 -0
- grctl/worker/runtime.py +185 -0
- grctl/worker/store.py +65 -0
- grctl/worker/task.py +406 -0
- grctl/worker/worker.py +194 -0
- grctl/worker/worker_cmd_subscriber.py +62 -0
- grctl/workflow/__init__.py +12 -0
- grctl/workflow/future.py +185 -0
- grctl/workflow/handle.py +132 -0
- grctl/workflow/workflow.py +315 -0
- grctl_sdk_python-0.3.0.dist-info/METADATA +66 -0
- grctl_sdk_python-0.3.0.dist-info/RECORD +48 -0
- {grctl_sdk_python-0.2.0.dist-info → grctl_sdk_python-0.3.0.dist-info}/WHEEL +1 -2
- grctl_sdk_python-0.3.0.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,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
|
grctl/models/__init__.py
ADDED
|
@@ -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
|
grctl/models/command.py
ADDED
|
@@ -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
|
+
)
|