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.
- grctl/client/__init__.py +7 -0
- grctl/client/client.py +111 -0
- grctl/models/__init__.py +137 -0
- grctl/models/api.py +13 -0
- grctl/models/command.py +117 -0
- grctl/models/common.py +10 -0
- grctl/models/directive.py +200 -0
- grctl/models/errors.py +6 -0
- grctl/models/history.py +341 -0
- grctl/models/run_info.py +79 -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 +44 -0
- grctl/nats/history_sub.py +66 -0
- grctl/nats/kv_store.py +44 -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 +290 -0
- grctl/worker/errors.py +28 -0
- grctl/worker/logger.py +40 -0
- grctl/worker/run_manager.py +131 -0
- grctl/worker/runner.py +175 -0
- grctl/worker/runtime.py +185 -0
- grctl/worker/store.py +65 -0
- grctl/worker/task.py +405 -0
- grctl/worker/worker.py +153 -0
- grctl/workflow/__init__.py +12 -0
- grctl/workflow/future.py +147 -0
- grctl/workflow/handle.py +72 -0
- grctl/workflow/workflow.py +262 -0
- {grctl_sdk_python-0.1.0.dist-info → grctl_sdk_python-0.1.2.dist-info}/METADATA +25 -4
- grctl_sdk_python-0.1.2.dist-info/RECORD +45 -0
- {grctl_sdk_python-0.1.0.dist-info → grctl_sdk_python-0.1.2.dist-info}/WHEEL +1 -2
- grctl_sdk_python-0.1.2.dist-info/licenses/LICENSE +201 -0
- grctl_sdk_python-0.1.0.dist-info/RECORD +0 -7
- grctl_sdk_python-0.1.0.dist-info/top_level.txt +0 -1
grctl/client/__init__.py
ADDED
|
@@ -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
|
grctl/models/__init__.py
ADDED
|
@@ -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
|
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,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
|
+
)
|