waymark 0.30.0.dev2__py3-none-macosx_26_0_arm64.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.
- waymark/__init__.py +62 -0
- waymark/actions.py +108 -0
- waymark/bin/waymark-boot-singleton +0 -0
- waymark/bin/waymark-bridge +0 -0
- waymark/bin/waymark-start-workers +0 -0
- waymark/bridge.py +432 -0
- waymark/dependencies.py +36 -0
- waymark/exceptions.py +18 -0
- waymark/formatter.py +110 -0
- waymark/grpc_config.py +6 -0
- waymark/ir_builder.py +4432 -0
- waymark/logger.py +39 -0
- waymark/proto/__init__.py +1 -0
- waymark/proto/ast_pb2.py +125 -0
- waymark/proto/ast_pb2.pyi +1700 -0
- waymark/proto/ast_pb2_grpc.py +24 -0
- waymark/proto/ast_pb2_grpc.pyi +22 -0
- waymark/proto/messages_pb2.py +116 -0
- waymark/proto/messages_pb2.pyi +1532 -0
- waymark/proto/messages_pb2_grpc.py +492 -0
- waymark/proto/messages_pb2_grpc.pyi +460 -0
- waymark/registry.py +111 -0
- waymark/schedule.py +385 -0
- waymark/serialization.py +278 -0
- waymark/type_coercion.py +211 -0
- waymark/worker.py +228 -0
- waymark/workflow.py +345 -0
- waymark/workflow_runtime.py +108 -0
- waymark-0.30.0.dev2.data/scripts/waymark-boot-singleton +0 -0
- waymark-0.30.0.dev2.data/scripts/waymark-bridge +0 -0
- waymark-0.30.0.dev2.data/scripts/waymark-start-workers +0 -0
- waymark-0.30.0.dev2.dist-info/METADATA +290 -0
- waymark-0.30.0.dev2.dist-info/RECORD +35 -0
- waymark-0.30.0.dev2.dist-info/WHEEL +4 -0
- waymark-0.30.0.dev2.dist-info/entry_points.txt +2 -0
waymark/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Public API for user-defined waymark actions."""
|
|
2
|
+
|
|
3
|
+
from . import bridge # noqa: F401
|
|
4
|
+
from . import workflow_runtime as _workflow_runtime # noqa: F401
|
|
5
|
+
from .actions import (
|
|
6
|
+
ActionResultPayload,
|
|
7
|
+
action,
|
|
8
|
+
deserialize_result_payload,
|
|
9
|
+
serialize_error_payload,
|
|
10
|
+
serialize_result_payload,
|
|
11
|
+
)
|
|
12
|
+
from .dependencies import Depend, Depends, provide_dependencies
|
|
13
|
+
from .exceptions import (
|
|
14
|
+
ExhaustedRetries,
|
|
15
|
+
ExhaustedRetriesError,
|
|
16
|
+
ScheduleAlreadyExistsError,
|
|
17
|
+
)
|
|
18
|
+
from .ir_builder import UnsupportedPatternError, build_workflow_ir
|
|
19
|
+
from .registry import registry
|
|
20
|
+
from .schedule import (
|
|
21
|
+
ScheduleInfo,
|
|
22
|
+
delete_schedule,
|
|
23
|
+
list_schedules,
|
|
24
|
+
pause_schedule,
|
|
25
|
+
resume_schedule,
|
|
26
|
+
schedule_workflow,
|
|
27
|
+
)
|
|
28
|
+
from .workflow import (
|
|
29
|
+
RetryPolicy,
|
|
30
|
+
Workflow,
|
|
31
|
+
workflow,
|
|
32
|
+
workflow_registry,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"action",
|
|
37
|
+
"registry",
|
|
38
|
+
"ActionResultPayload",
|
|
39
|
+
"Workflow",
|
|
40
|
+
"workflow",
|
|
41
|
+
"workflow_registry",
|
|
42
|
+
"RetryPolicy",
|
|
43
|
+
"build_workflow_ir",
|
|
44
|
+
"serialize_result_payload",
|
|
45
|
+
"deserialize_result_payload",
|
|
46
|
+
"serialize_error_payload",
|
|
47
|
+
"Depend",
|
|
48
|
+
"Depends",
|
|
49
|
+
"provide_dependencies",
|
|
50
|
+
"bridge",
|
|
51
|
+
"ExhaustedRetries",
|
|
52
|
+
"ExhaustedRetriesError",
|
|
53
|
+
"ScheduleAlreadyExistsError",
|
|
54
|
+
"UnsupportedPatternError",
|
|
55
|
+
# Schedule functions
|
|
56
|
+
"schedule_workflow",
|
|
57
|
+
"pause_schedule",
|
|
58
|
+
"resume_schedule",
|
|
59
|
+
"delete_schedule",
|
|
60
|
+
"list_schedules",
|
|
61
|
+
"ScheduleInfo",
|
|
62
|
+
]
|
waymark/actions.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from typing import Any, Callable, Optional, TypeVar, overload
|
|
5
|
+
|
|
6
|
+
from waymark.proto import messages_pb2 as pb2
|
|
7
|
+
|
|
8
|
+
from .dependencies import provide_dependencies
|
|
9
|
+
from .registry import AsyncAction, registry
|
|
10
|
+
from .serialization import dumps, loads
|
|
11
|
+
|
|
12
|
+
TAsync = TypeVar("TAsync", bound=AsyncAction)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ActionResultPayload:
|
|
17
|
+
result: Any | None
|
|
18
|
+
error: dict[str, Any] | None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def serialize_result_payload(value: Any) -> pb2.WorkflowArguments:
|
|
22
|
+
"""Serialize a successful action result."""
|
|
23
|
+
arguments = pb2.WorkflowArguments()
|
|
24
|
+
entry = arguments.arguments.add()
|
|
25
|
+
entry.key = "result"
|
|
26
|
+
entry.value.CopyFrom(dumps(value))
|
|
27
|
+
return arguments
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def serialize_error_payload(_action: str, exc: BaseException) -> pb2.WorkflowArguments:
|
|
31
|
+
"""Serialize an error raised during action execution."""
|
|
32
|
+
arguments = pb2.WorkflowArguments()
|
|
33
|
+
entry = arguments.arguments.add()
|
|
34
|
+
entry.key = "error"
|
|
35
|
+
entry.value.CopyFrom(dumps(exc))
|
|
36
|
+
return arguments
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def deserialize_result_payload(payload: pb2.WorkflowArguments | None) -> ActionResultPayload:
|
|
40
|
+
"""Deserialize WorkflowArguments produced by serialize_result_payload/error."""
|
|
41
|
+
if payload is None:
|
|
42
|
+
return ActionResultPayload(result=None, error=None)
|
|
43
|
+
values = {entry.key: entry.value for entry in payload.arguments}
|
|
44
|
+
if "error" in values:
|
|
45
|
+
error_value = values["error"]
|
|
46
|
+
data = loads(error_value)
|
|
47
|
+
if not isinstance(data, dict):
|
|
48
|
+
raise ValueError("error payload must deserialize to a mapping")
|
|
49
|
+
return ActionResultPayload(result=None, error=data)
|
|
50
|
+
result_value = values.get("result")
|
|
51
|
+
if result_value is None:
|
|
52
|
+
raise ValueError("result payload missing 'result' field")
|
|
53
|
+
return ActionResultPayload(result=loads(result_value), error=None)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@overload
|
|
57
|
+
def action(func: TAsync, /) -> TAsync: ...
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@overload
|
|
61
|
+
def action(*, name: Optional[str] = None) -> Callable[[TAsync], TAsync]: ...
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def action(
|
|
65
|
+
func: Optional[TAsync] = None,
|
|
66
|
+
*,
|
|
67
|
+
name: Optional[str] = None,
|
|
68
|
+
) -> Callable[[TAsync], TAsync] | TAsync:
|
|
69
|
+
"""Decorator for registering async actions.
|
|
70
|
+
|
|
71
|
+
Actions decorated with @action will automatically resolve dependency markers
|
|
72
|
+
when called directly (e.g., during pytest runs where workflows bypass the
|
|
73
|
+
gRPC bridge).
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def decorator(target: TAsync) -> TAsync:
|
|
77
|
+
if not inspect.iscoroutinefunction(target):
|
|
78
|
+
raise TypeError(f"action '{target.__name__}' must be defined with 'async def'")
|
|
79
|
+
action_name = name or target.__name__
|
|
80
|
+
action_module = target.__module__
|
|
81
|
+
|
|
82
|
+
@wraps(target)
|
|
83
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
84
|
+
# Convert positional args to kwargs based on the signature
|
|
85
|
+
sig = inspect.signature(target)
|
|
86
|
+
params = list(sig.parameters.keys())
|
|
87
|
+
for i, arg in enumerate(args):
|
|
88
|
+
if i < len(params):
|
|
89
|
+
kwargs[params[i]] = arg
|
|
90
|
+
|
|
91
|
+
# Resolve dependencies using the same mechanism as execute_action
|
|
92
|
+
async with provide_dependencies(target, kwargs) as call_kwargs:
|
|
93
|
+
return await target(**call_kwargs)
|
|
94
|
+
|
|
95
|
+
# Copy over the original function's attributes for introspection
|
|
96
|
+
wrapper.__wrapped__ = target # type: ignore[attr-defined]
|
|
97
|
+
wrapper.__waymark_action_name__ = action_name # type: ignore[attr-defined]
|
|
98
|
+
wrapper.__waymark_action_module__ = action_module # type: ignore[attr-defined]
|
|
99
|
+
|
|
100
|
+
# Register the original function (not the wrapper) so execute_action
|
|
101
|
+
# doesn't double-resolve dependencies
|
|
102
|
+
registry.register(action_module, action_name, target)
|
|
103
|
+
|
|
104
|
+
return wrapper # type: ignore[return-value]
|
|
105
|
+
|
|
106
|
+
if func is not None:
|
|
107
|
+
return decorator(func)
|
|
108
|
+
return decorator
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
waymark/bridge.py
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import shlex
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
import time
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from threading import Lock, RLock
|
|
12
|
+
from typing import AsyncIterator, NoReturn, Optional
|
|
13
|
+
|
|
14
|
+
import grpc
|
|
15
|
+
from grpc import aio # type: ignore[attr-defined]
|
|
16
|
+
|
|
17
|
+
from waymark.logger import configure as configure_logger
|
|
18
|
+
from waymark.proto import messages_pb2 as pb2
|
|
19
|
+
from waymark.proto import messages_pb2_grpc as pb2_grpc
|
|
20
|
+
|
|
21
|
+
from .actions import serialize_error_payload, serialize_result_payload
|
|
22
|
+
from .grpc_config import GRPC_CHANNEL_OPTIONS
|
|
23
|
+
from .workflow_runtime import execute_action
|
|
24
|
+
|
|
25
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
26
|
+
LOGGER = configure_logger("waymark.bridge")
|
|
27
|
+
|
|
28
|
+
_PORT_LOCK = RLock()
|
|
29
|
+
_CACHED_GRPC_PORT: Optional[int] = None
|
|
30
|
+
_GRPC_TARGET: Optional[str] = None
|
|
31
|
+
_GRPC_CHANNEL: Optional[aio.Channel] = None
|
|
32
|
+
_GRPC_STUB: Optional[pb2_grpc.WorkflowServiceStub] = None
|
|
33
|
+
_GRPC_LOOP: Optional[asyncio.AbstractEventLoop] = None
|
|
34
|
+
_BOOT_MUTEX = Lock()
|
|
35
|
+
_ASYNC_BOOT_LOCK: asyncio.Lock = asyncio.Lock()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class RunInstanceResult:
|
|
40
|
+
workflow_version_id: str
|
|
41
|
+
workflow_instance_id: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class RunBatchResult:
|
|
46
|
+
workflow_version_id: str
|
|
47
|
+
workflow_instance_ids: list[str]
|
|
48
|
+
queued: int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _boot_command() -> list[str]:
|
|
52
|
+
override = os.environ.get("WAYMARK_BOOT_COMMAND")
|
|
53
|
+
if override:
|
|
54
|
+
LOGGER.debug("Using WAYMARK_BOOT_COMMAND=%s", override)
|
|
55
|
+
return shlex.split(override)
|
|
56
|
+
binary = os.environ.get("WAYMARK_BOOT_BINARY", "waymark-boot-singleton")
|
|
57
|
+
LOGGER.debug("Using WAYMARK_BOOT_BINARY=%s", binary)
|
|
58
|
+
return [binary]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _repo_root() -> Path:
|
|
62
|
+
return Path(__file__).resolve().parents[3]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _resolve_boot_binary(binary: str) -> str:
|
|
66
|
+
if Path(binary).is_absolute():
|
|
67
|
+
return binary
|
|
68
|
+
resolved = shutil.which(binary)
|
|
69
|
+
if resolved:
|
|
70
|
+
return resolved
|
|
71
|
+
repo_root = _repo_root()
|
|
72
|
+
# Check multiple target directories:
|
|
73
|
+
# - CARGO_TARGET_DIR (set by cargo llvm-cov and other tools)
|
|
74
|
+
# - target/llvm-cov-target (used by cargo llvm-cov by default)
|
|
75
|
+
# - target (standard cargo target)
|
|
76
|
+
target_dirs = []
|
|
77
|
+
if cargo_target_dir := os.environ.get("CARGO_TARGET_DIR"):
|
|
78
|
+
target_dirs.append(Path(cargo_target_dir))
|
|
79
|
+
target_dirs.extend(
|
|
80
|
+
[
|
|
81
|
+
repo_root / "target" / "llvm-cov-target",
|
|
82
|
+
repo_root / "target",
|
|
83
|
+
]
|
|
84
|
+
)
|
|
85
|
+
for target_dir in target_dirs:
|
|
86
|
+
for profile in ("debug", "release"):
|
|
87
|
+
candidate = target_dir / profile / binary
|
|
88
|
+
if candidate.exists():
|
|
89
|
+
return str(candidate)
|
|
90
|
+
return binary
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _ensure_boot_binary(binary: str) -> str:
|
|
94
|
+
resolved = _resolve_boot_binary(binary)
|
|
95
|
+
if Path(resolved).exists():
|
|
96
|
+
return resolved
|
|
97
|
+
repo_root = _repo_root()
|
|
98
|
+
cargo_toml = repo_root / "Cargo.toml"
|
|
99
|
+
if cargo_toml.exists():
|
|
100
|
+
LOGGER.info("boot binary %s not found; building via cargo", binary)
|
|
101
|
+
subprocess.run(
|
|
102
|
+
[
|
|
103
|
+
"cargo",
|
|
104
|
+
"build",
|
|
105
|
+
"--bin",
|
|
106
|
+
"waymark-boot-singleton",
|
|
107
|
+
"--bin",
|
|
108
|
+
"waymark-bridge",
|
|
109
|
+
],
|
|
110
|
+
cwd=repo_root,
|
|
111
|
+
check=True,
|
|
112
|
+
)
|
|
113
|
+
resolved = _resolve_boot_binary(binary)
|
|
114
|
+
return resolved
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _remember_grpc_port(port: int) -> int:
|
|
118
|
+
global _CACHED_GRPC_PORT
|
|
119
|
+
with _PORT_LOCK:
|
|
120
|
+
_CACHED_GRPC_PORT = port
|
|
121
|
+
return port
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _cached_grpc_port() -> Optional[int]:
|
|
125
|
+
with _PORT_LOCK:
|
|
126
|
+
return _CACHED_GRPC_PORT
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _env_grpc_port_override() -> Optional[int]:
|
|
130
|
+
"""Check for explicit gRPC port override via environment."""
|
|
131
|
+
override = os.environ.get("WAYMARK_BRIDGE_GRPC_PORT")
|
|
132
|
+
if not override:
|
|
133
|
+
return None
|
|
134
|
+
try:
|
|
135
|
+
return int(override)
|
|
136
|
+
except ValueError as exc: # pragma: no cover
|
|
137
|
+
raise RuntimeError(f"invalid WAYMARK_BRIDGE_GRPC_PORT value: {override}") from exc
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _boot_singleton_blocking() -> int:
|
|
141
|
+
"""Boot the singleton and return the gRPC port."""
|
|
142
|
+
command = _boot_command()
|
|
143
|
+
if os.environ.get("WAYMARK_BOOT_COMMAND") is None:
|
|
144
|
+
command[0] = _ensure_boot_binary(command[0])
|
|
145
|
+
with tempfile.NamedTemporaryFile(mode="w+", suffix=".txt") as f:
|
|
146
|
+
output_file = Path(f.name)
|
|
147
|
+
|
|
148
|
+
command.extend(["--output-file", str(output_file)])
|
|
149
|
+
LOGGER.info("Booting waymark singleton via: %s", " ".join(command))
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
subprocess.run(
|
|
153
|
+
command,
|
|
154
|
+
check=True,
|
|
155
|
+
timeout=10,
|
|
156
|
+
)
|
|
157
|
+
except subprocess.TimeoutExpired as exc: # pragma: no cover
|
|
158
|
+
LOGGER.error("boot command timed out after %s seconds", exc.timeout)
|
|
159
|
+
raise RuntimeError("unable to boot waymark server") from exc
|
|
160
|
+
except subprocess.CalledProcessError as exc: # pragma: no cover
|
|
161
|
+
LOGGER.error("boot command failed: %s", exc)
|
|
162
|
+
raise RuntimeError("unable to boot waymark server") from exc
|
|
163
|
+
except OSError as exc: # pragma: no cover
|
|
164
|
+
LOGGER.error("unable to spawn boot command: %s", exc)
|
|
165
|
+
raise RuntimeError("unable to boot waymark server") from exc
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
# We use a file as a message passer because passing a PIPE to the singleton launcher
|
|
169
|
+
# will block our code indefinitely
|
|
170
|
+
# The singleton launches the webserver subprocess to inherit the stdin/stdout that the
|
|
171
|
+
# singleton launcher receives; which means that in the case of a PIPE it would pass that
|
|
172
|
+
# pipe to the subprocess and therefore never correctly close the file descriptor and signal
|
|
173
|
+
# exit process status to Python.
|
|
174
|
+
port_str = output_file.read_text().strip()
|
|
175
|
+
grpc_port = int(port_str)
|
|
176
|
+
LOGGER.info("boot command reported singleton gRPC port %s", grpc_port)
|
|
177
|
+
return grpc_port
|
|
178
|
+
except (ValueError, FileNotFoundError) as exc: # pragma: no cover
|
|
179
|
+
raise RuntimeError(f"unable to read port from output file: {exc}") from exc
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _resolve_grpc_port() -> int:
|
|
183
|
+
"""Resolve the gRPC port, booting singleton if necessary."""
|
|
184
|
+
cached = _cached_grpc_port()
|
|
185
|
+
if cached is not None:
|
|
186
|
+
return cached
|
|
187
|
+
env_port = _env_grpc_port_override()
|
|
188
|
+
if env_port is not None:
|
|
189
|
+
return _remember_grpc_port(env_port)
|
|
190
|
+
with _BOOT_MUTEX:
|
|
191
|
+
cached = _cached_grpc_port()
|
|
192
|
+
if cached is not None:
|
|
193
|
+
return cached
|
|
194
|
+
port = _boot_singleton_blocking()
|
|
195
|
+
return _remember_grpc_port(port)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
async def _ensure_grpc_port_async() -> int:
|
|
199
|
+
"""Ensure we have a gRPC port, booting singleton if necessary."""
|
|
200
|
+
cached = _cached_grpc_port()
|
|
201
|
+
if cached is not None:
|
|
202
|
+
return cached
|
|
203
|
+
env_port = _env_grpc_port_override()
|
|
204
|
+
if env_port is not None:
|
|
205
|
+
return _remember_grpc_port(env_port)
|
|
206
|
+
async with _ASYNC_BOOT_LOCK:
|
|
207
|
+
cached = _cached_grpc_port()
|
|
208
|
+
if cached is not None:
|
|
209
|
+
return cached
|
|
210
|
+
loop = asyncio.get_running_loop()
|
|
211
|
+
LOGGER.info("No cached singleton found, booting new instance")
|
|
212
|
+
port = await loop.run_in_executor(None, _boot_singleton_blocking)
|
|
213
|
+
LOGGER.info("Singleton ready on gRPC port %s", port)
|
|
214
|
+
return _remember_grpc_port(port)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@asynccontextmanager
|
|
218
|
+
async def ensure_singleton() -> AsyncIterator[int]:
|
|
219
|
+
"""Yield the gRPC port for the singleton server, booting it exactly once."""
|
|
220
|
+
port = await _ensure_grpc_port_async()
|
|
221
|
+
yield port
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _grpc_target() -> str:
|
|
225
|
+
"""Get the gRPC target address for the bridge server."""
|
|
226
|
+
# Check for explicit full address override
|
|
227
|
+
explicit = os.environ.get("WAYMARK_BRIDGE_GRPC_ADDR")
|
|
228
|
+
if explicit:
|
|
229
|
+
return explicit
|
|
230
|
+
|
|
231
|
+
# Otherwise, use host + port
|
|
232
|
+
host = os.environ.get("WAYMARK_BRIDGE_GRPC_HOST", DEFAULT_HOST)
|
|
233
|
+
port = _resolve_grpc_port()
|
|
234
|
+
return f"{host}:{port}"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def assert_never(value: object) -> NoReturn:
|
|
238
|
+
raise AssertionError(f"Unhandled value: {value!r}")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
async def _workflow_stub() -> pb2_grpc.WorkflowServiceStub:
|
|
242
|
+
global _GRPC_TARGET, _GRPC_CHANNEL, _GRPC_STUB, _GRPC_LOOP
|
|
243
|
+
target = _grpc_target()
|
|
244
|
+
loop = asyncio.get_running_loop()
|
|
245
|
+
channel_to_wait: Optional[aio.Channel] = None
|
|
246
|
+
with _PORT_LOCK:
|
|
247
|
+
if (
|
|
248
|
+
_GRPC_STUB is not None
|
|
249
|
+
and _GRPC_TARGET == target
|
|
250
|
+
and _GRPC_LOOP is loop
|
|
251
|
+
and not loop.is_closed()
|
|
252
|
+
):
|
|
253
|
+
return _GRPC_STUB
|
|
254
|
+
channel = aio.insecure_channel(target, options=GRPC_CHANNEL_OPTIONS)
|
|
255
|
+
stub = pb2_grpc.WorkflowServiceStub(channel)
|
|
256
|
+
_GRPC_CHANNEL = channel
|
|
257
|
+
_GRPC_STUB = stub
|
|
258
|
+
_GRPC_TARGET = target
|
|
259
|
+
_GRPC_LOOP = loop
|
|
260
|
+
channel_to_wait = channel
|
|
261
|
+
if channel_to_wait is not None:
|
|
262
|
+
await channel_to_wait.channel_ready()
|
|
263
|
+
return _GRPC_STUB # type: ignore[return-value]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
async def run_instance(payload: bytes) -> RunInstanceResult:
|
|
267
|
+
"""Register a workflow definition and start an instance over the gRPC bridge."""
|
|
268
|
+
async with ensure_singleton():
|
|
269
|
+
stub = await _workflow_stub()
|
|
270
|
+
registration = pb2.WorkflowRegistration()
|
|
271
|
+
registration.ParseFromString(payload)
|
|
272
|
+
request = pb2.RegisterWorkflowRequest(
|
|
273
|
+
registration=registration,
|
|
274
|
+
)
|
|
275
|
+
try:
|
|
276
|
+
response = await stub.RegisterWorkflow(request, timeout=30.0)
|
|
277
|
+
except aio.AioRpcError as exc: # pragma: no cover
|
|
278
|
+
raise RuntimeError(f"register_workflow failed: {exc}") from exc
|
|
279
|
+
return RunInstanceResult(
|
|
280
|
+
workflow_version_id=response.workflow_version_id,
|
|
281
|
+
workflow_instance_id=response.workflow_instance_id,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
async def run_instances_batch(
|
|
286
|
+
payload: bytes,
|
|
287
|
+
*,
|
|
288
|
+
count: int = 1,
|
|
289
|
+
inputs: Optional[pb2.WorkflowArguments] = None,
|
|
290
|
+
inputs_list: Optional[list[pb2.WorkflowArguments]] = None,
|
|
291
|
+
batch_size: int = 500,
|
|
292
|
+
include_instance_ids: bool = False,
|
|
293
|
+
) -> RunBatchResult:
|
|
294
|
+
"""Register a workflow definition and start multiple instances over the gRPC bridge."""
|
|
295
|
+
if count < 1 and not inputs_list:
|
|
296
|
+
raise ValueError("count must be >= 1 when inputs_list is empty")
|
|
297
|
+
if batch_size < 1:
|
|
298
|
+
raise ValueError("batch_size must be >= 1")
|
|
299
|
+
|
|
300
|
+
async with ensure_singleton():
|
|
301
|
+
stub = await _workflow_stub()
|
|
302
|
+
registration = pb2.WorkflowRegistration()
|
|
303
|
+
registration.ParseFromString(payload)
|
|
304
|
+
request = pb2.RegisterWorkflowBatchRequest(
|
|
305
|
+
registration=registration,
|
|
306
|
+
count=count,
|
|
307
|
+
batch_size=batch_size,
|
|
308
|
+
include_instance_ids=include_instance_ids,
|
|
309
|
+
)
|
|
310
|
+
if inputs is not None:
|
|
311
|
+
request.inputs.CopyFrom(inputs)
|
|
312
|
+
if inputs_list:
|
|
313
|
+
request.inputs_list.extend(inputs_list)
|
|
314
|
+
try:
|
|
315
|
+
response = await stub.RegisterWorkflowBatch(request, timeout=30.0)
|
|
316
|
+
except aio.AioRpcError as exc: # pragma: no cover
|
|
317
|
+
raise RuntimeError(f"register_workflow_batch failed: {exc}") from exc
|
|
318
|
+
return RunBatchResult(
|
|
319
|
+
workflow_version_id=response.workflow_version_id,
|
|
320
|
+
workflow_instance_ids=list(response.workflow_instance_ids),
|
|
321
|
+
queued=response.queued,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
async def execute_workflow(payload: bytes) -> bytes:
|
|
326
|
+
"""Execute a workflow via the in-memory workflow streaming API."""
|
|
327
|
+
os.environ.setdefault("WAYMARK_BRIDGE_IN_MEMORY", "1")
|
|
328
|
+
async with ensure_singleton():
|
|
329
|
+
stub = await _workflow_stub()
|
|
330
|
+
|
|
331
|
+
registration = pb2.WorkflowRegistration()
|
|
332
|
+
registration.ParseFromString(payload)
|
|
333
|
+
LOGGER.debug(
|
|
334
|
+
"pytest stream start: workflow=%s ir_hash=%s",
|
|
335
|
+
registration.workflow_name,
|
|
336
|
+
registration.ir_hash,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
queue: asyncio.Queue[Optional[pb2.WorkflowStreamRequest]] = asyncio.Queue()
|
|
340
|
+
skip_sleep = bool(os.environ.get("PYTEST_CURRENT_TEST"))
|
|
341
|
+
await queue.put(pb2.WorkflowStreamRequest(registration=registration, skip_sleep=skip_sleep))
|
|
342
|
+
|
|
343
|
+
async def request_stream() -> AsyncIterator[pb2.WorkflowStreamRequest]:
|
|
344
|
+
while True:
|
|
345
|
+
item = await queue.get()
|
|
346
|
+
if item is None:
|
|
347
|
+
return
|
|
348
|
+
yield item
|
|
349
|
+
|
|
350
|
+
call = stub.ExecuteWorkflow(request_stream(), timeout=300.0)
|
|
351
|
+
result_payload: Optional[bytes] = None
|
|
352
|
+
|
|
353
|
+
async for response in call:
|
|
354
|
+
kind = response.WhichOneof("kind")
|
|
355
|
+
match kind:
|
|
356
|
+
case "action_dispatch":
|
|
357
|
+
dispatch = response.action_dispatch
|
|
358
|
+
LOGGER.debug(
|
|
359
|
+
"pytest stream dispatch: action_id=%s module=%s action=%s",
|
|
360
|
+
dispatch.action_id,
|
|
361
|
+
dispatch.module_name,
|
|
362
|
+
dispatch.action_name,
|
|
363
|
+
)
|
|
364
|
+
start_ns = time.monotonic_ns()
|
|
365
|
+
execution = await execute_action(dispatch)
|
|
366
|
+
end_ns = time.monotonic_ns()
|
|
367
|
+
action_result = pb2.ActionResult(
|
|
368
|
+
action_id=dispatch.action_id,
|
|
369
|
+
success=execution.exception is None,
|
|
370
|
+
payload=(
|
|
371
|
+
serialize_result_payload(execution.result)
|
|
372
|
+
if execution.exception is None
|
|
373
|
+
else serialize_error_payload(dispatch.action_name, execution.exception)
|
|
374
|
+
),
|
|
375
|
+
worker_start_ns=start_ns,
|
|
376
|
+
worker_end_ns=end_ns,
|
|
377
|
+
error_type=(
|
|
378
|
+
type(execution.exception).__name__
|
|
379
|
+
if execution.exception is not None
|
|
380
|
+
else ""
|
|
381
|
+
),
|
|
382
|
+
error_message=str(execution.exception)
|
|
383
|
+
if execution.exception is not None
|
|
384
|
+
else "",
|
|
385
|
+
)
|
|
386
|
+
# Echo the opaque server correlation metadata untouched.
|
|
387
|
+
if dispatch.metadata:
|
|
388
|
+
action_result.metadata = dispatch.metadata
|
|
389
|
+
LOGGER.debug(
|
|
390
|
+
"pytest stream result: action_id=%s success=%s",
|
|
391
|
+
dispatch.action_id,
|
|
392
|
+
execution.exception is None,
|
|
393
|
+
)
|
|
394
|
+
await queue.put(pb2.WorkflowStreamRequest(action_result=action_result))
|
|
395
|
+
case "workflow_result":
|
|
396
|
+
result_payload = response.workflow_result.payload
|
|
397
|
+
LOGGER.debug(
|
|
398
|
+
"pytest stream complete: workflow=%s payload_bytes=%s",
|
|
399
|
+
registration.workflow_name,
|
|
400
|
+
len(result_payload),
|
|
401
|
+
)
|
|
402
|
+
await queue.put(None)
|
|
403
|
+
break
|
|
404
|
+
case None:
|
|
405
|
+
continue
|
|
406
|
+
case _:
|
|
407
|
+
assert_never(kind)
|
|
408
|
+
|
|
409
|
+
if result_payload is None:
|
|
410
|
+
raise RuntimeError("workflow stream ended without a result")
|
|
411
|
+
return result_payload
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
async def wait_for_instance(
|
|
415
|
+
instance_id: str,
|
|
416
|
+
poll_interval_secs: float = 1.0,
|
|
417
|
+
) -> Optional[bytes]:
|
|
418
|
+
"""Block until the workflow daemon produces the requested instance payload."""
|
|
419
|
+
async with ensure_singleton():
|
|
420
|
+
stub = await _workflow_stub()
|
|
421
|
+
request = pb2.WaitForInstanceRequest(
|
|
422
|
+
instance_id=instance_id,
|
|
423
|
+
poll_interval_secs=poll_interval_secs,
|
|
424
|
+
)
|
|
425
|
+
try:
|
|
426
|
+
response = await stub.WaitForInstance(request, timeout=None)
|
|
427
|
+
except aio.AioRpcError as exc: # pragma: no cover
|
|
428
|
+
status_fn = exc.code
|
|
429
|
+
if callable(status_fn) and status_fn() == grpc.StatusCode.NOT_FOUND:
|
|
430
|
+
return None
|
|
431
|
+
raise RuntimeError(f"wait_for_instance failed: {exc}") from exc
|
|
432
|
+
return bytes(response.payload)
|
waymark/dependencies.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from typing import Any, Callable
|
|
2
|
+
|
|
3
|
+
from mountaineer_di import (
|
|
4
|
+
DependencyResolver,
|
|
5
|
+
Depends,
|
|
6
|
+
get_function_dependencies,
|
|
7
|
+
isolate_dependency_only_function,
|
|
8
|
+
provide_dependencies,
|
|
9
|
+
strip_depends_from_signature,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
DependMarker = type(Depends())
|
|
13
|
+
DependsMarker = DependMarker
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def Depend( # noqa: N802
|
|
17
|
+
dependency: Callable[..., Any] | None = None,
|
|
18
|
+
*,
|
|
19
|
+
use_cache: bool = True,
|
|
20
|
+
) -> Any:
|
|
21
|
+
"""Compatibility alias for ``mountaineer_di.Depends``."""
|
|
22
|
+
|
|
23
|
+
return Depends(dependency, use_cache=use_cache)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"DependencyResolver",
|
|
28
|
+
"Depend",
|
|
29
|
+
"DependMarker",
|
|
30
|
+
"Depends",
|
|
31
|
+
"DependsMarker",
|
|
32
|
+
"get_function_dependencies",
|
|
33
|
+
"isolate_dependency_only_function",
|
|
34
|
+
"provide_dependencies",
|
|
35
|
+
"strip_depends_from_signature",
|
|
36
|
+
]
|
waymark/exceptions.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Custom exception types raised by waymark workflows."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ExhaustedRetriesError(Exception):
|
|
5
|
+
"""Raised when an action exhausts its allotted retry attempts."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str | None = None) -> None:
|
|
8
|
+
super().__init__(message or "action exhausted retries")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
ExhaustedRetries = ExhaustedRetriesError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ScheduleAlreadyExistsError(Exception):
|
|
15
|
+
"""Raised when a schedule name is already registered."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str | None = None) -> None:
|
|
18
|
+
super().__init__(message or "schedule already exists")
|