velophex 0.2.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.
- velophex/__init__.py +38 -0
- velophex/_json.py +297 -0
- velophex/agents/__init__.py +130 -0
- velophex/agents/_errors.py +75 -0
- velophex/agents/_loop.py +436 -0
- velophex/agents/_tools.py +168 -0
- velophex/agents/_tracing.py +87 -0
- velophex/agents/testing.py +249 -0
- velophex/cli/__init__.py +5 -0
- velophex/cli/main.py +548 -0
- velophex/cli/publish.py +152 -0
- velophex/cli/sync.py +90 -0
- velophex/cli/templates.py +126 -0
- velophex/contracts/__init__.py +103 -0
- velophex/contracts/_model.py +26 -0
- velophex/contracts/agents.py +110 -0
- velophex/contracts/diagnostics.py +157 -0
- velophex/contracts/executor.py +1921 -0
- velophex/contracts/package.py +391 -0
- velophex/contracts/project.py +1008 -0
- velophex/contracts/runtimes.py +226 -0
- velophex/contracts/signin.py +53 -0
- velophex/contracts/validation.py +158 -0
- velophex/entrypoints.py +329 -0
- velophex/errors.py +44 -0
- velophex/packaging.py +465 -0
- velophex/py.typed +0 -0
- velophex/runtime/__init__.py +471 -0
- velophex/runtime/_session.py +192 -0
- velophex/runtime/client.py +957 -0
- velophex/runtime/models.py +326 -0
- velophex/signing.py +425 -0
- velophex/worker/__init__.py +27 -0
- velophex/worker/__main__.py +118 -0
- velophex/worker/framing.py +248 -0
- velophex/worker/loader.py +105 -0
- velophex/worker/outbox.py +147 -0
- velophex/worker/session.py +519 -0
- velophex-0.2.0.dist-info/METADATA +313 -0
- velophex-0.2.0.dist-info/RECORD +43 -0
- velophex-0.2.0.dist-info/WHEEL +4 -0
- velophex-0.2.0.dist-info/entry_points.txt +2 -0
- velophex-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1921 @@
|
|
|
1
|
+
"""Executor protocol messages (ADR-P005, ``docs/EXECUTOR-PROTOCOL.md``, protocol version 1, revision 1.1).
|
|
2
|
+
|
|
3
|
+
Every message is an envelope ``{"type": name, "body": {...}}``. The bodies here mirror the
|
|
4
|
+
records in ``VeloPhex.Platform.Executor.Protocol.ExecutorMessages`` *member for member and in
|
|
5
|
+
their declared order*, because the conformance vectors in
|
|
6
|
+
``schemas/test-vectors/executor-frames.json`` compare payload bytes, not parsed objects.
|
|
7
|
+
|
|
8
|
+
Bodies are camelCase and omit null members. A member that is non-nullable in .NET (a ``bool``
|
|
9
|
+
or an ``int``) is always written, even at its default — that is why ``retryable: false``
|
|
10
|
+
appears in the fault vector.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import UTC, datetime, timedelta
|
|
18
|
+
from typing import Any, ClassVar, Final
|
|
19
|
+
from uuid import UUID
|
|
20
|
+
|
|
21
|
+
from velophex import _json
|
|
22
|
+
from velophex._json import omit_none
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"ABSOLUTE_MAXIMUM_FRAME_BYTES",
|
|
26
|
+
"CURRENT_VERSION",
|
|
27
|
+
"DEFAULT_MAXIMUM_FRAME_BYTES",
|
|
28
|
+
"FAULT_ENVELOPE_VERSION",
|
|
29
|
+
"FRAME_HEADER_BYTES",
|
|
30
|
+
"FRAME_LENGTH_BYTES",
|
|
31
|
+
"FRAME_TAG_BYTES",
|
|
32
|
+
"MINIMUM_VERSION",
|
|
33
|
+
"REVISION",
|
|
34
|
+
"SESSION_KEY_BYTES",
|
|
35
|
+
"SESSION_KEY_ENVIRONMENT_VARIABLE",
|
|
36
|
+
"TAG_LABEL",
|
|
37
|
+
"BookmarkInfo",
|
|
38
|
+
"BookmarkKinds",
|
|
39
|
+
"BreakpointInfo",
|
|
40
|
+
"CanceledMessage",
|
|
41
|
+
"CheckpointInfo",
|
|
42
|
+
"CheckpointMessage",
|
|
43
|
+
"CheckpointedMessage",
|
|
44
|
+
"DebugCommandMessage",
|
|
45
|
+
"DebugCommands",
|
|
46
|
+
"DebugExceptionInfo",
|
|
47
|
+
"DebugFrameInfo",
|
|
48
|
+
"DebugLocation",
|
|
49
|
+
"DebugOptionKeys",
|
|
50
|
+
"DebugOptions",
|
|
51
|
+
"DebugPauseReasons",
|
|
52
|
+
"DebugPausedMessage",
|
|
53
|
+
"DebugRestartedMessage",
|
|
54
|
+
"DebugResumedMessage",
|
|
55
|
+
"DebugSettings",
|
|
56
|
+
"DebugStepOutputs",
|
|
57
|
+
"DebugTraceMessage",
|
|
58
|
+
"DebugTraceStates",
|
|
59
|
+
"DebugVariableInfo",
|
|
60
|
+
"DebugVariablesMessage",
|
|
61
|
+
"EvaluateResultMessage",
|
|
62
|
+
"ExecutionContextInfo",
|
|
63
|
+
"ExecutorCapabilities",
|
|
64
|
+
"ExecutorFrameDirection",
|
|
65
|
+
"ExecutorMessage",
|
|
66
|
+
"ExecutorProtocolError",
|
|
67
|
+
"ExecutorProtocolException",
|
|
68
|
+
"FaultInfo",
|
|
69
|
+
"FaultMessage",
|
|
70
|
+
"FaultRetryScopes",
|
|
71
|
+
"HeartbeatMessage",
|
|
72
|
+
"HelloAckMessage",
|
|
73
|
+
"HelloMessage",
|
|
74
|
+
"LogMessage",
|
|
75
|
+
"PrepareMessage",
|
|
76
|
+
"PreparedMessage",
|
|
77
|
+
"ProgressMessage",
|
|
78
|
+
"RestoreMessage",
|
|
79
|
+
"ResultMessage",
|
|
80
|
+
"ResumeBookmarkMessage",
|
|
81
|
+
"RuntimeHealthMessage",
|
|
82
|
+
"RuntimeHealthReportMessage",
|
|
83
|
+
"RuntimeHealthStatuses",
|
|
84
|
+
"StartMessage",
|
|
85
|
+
"StopMessage",
|
|
86
|
+
"SubInvokeMessage",
|
|
87
|
+
"SubResultMessage",
|
|
88
|
+
"SuspendedMessage",
|
|
89
|
+
"TestAssertionInfo",
|
|
90
|
+
"TestOutcomes",
|
|
91
|
+
"TestResultMessage",
|
|
92
|
+
"decode_message",
|
|
93
|
+
"encode_message",
|
|
94
|
+
"format_timestamp",
|
|
95
|
+
"message_type_name",
|
|
96
|
+
"parse_timestamp",
|
|
97
|
+
"select_version",
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
CURRENT_VERSION: Final = 1
|
|
101
|
+
MINIMUM_VERSION: Final = 1
|
|
102
|
+
|
|
103
|
+
#: The specification revision these models are built against. Additive revisions keep the wire
|
|
104
|
+
#: version: every addition sits behind a capability, so this string says which contract the SDK
|
|
105
|
+
#: was built from, not which optional features the worker implements. The worker offers none of
|
|
106
|
+
#: the ``debug-*`` capabilities and none of the revision 2 ones: a Python automation is a function
|
|
107
|
+
#: call and nothing in it suspends, so it answers ``checkpoint``, ``restore``, ``resume-bookmark``
|
|
108
|
+
#: and ``runtime-health`` as unsupported rather than misparsing them.
|
|
109
|
+
#:
|
|
110
|
+
#: This pinned ``1.1`` while .NET was at ``1.2`` and nothing compared the two, so the SDK claimed a
|
|
111
|
+
#: revision it was not built against and no test could say so. ``test_vocabulary_mirror`` now reads
|
|
112
|
+
#: ``ExecutorProtocol.Revision`` from the C# source and fails on a mismatch.
|
|
113
|
+
REVISION: Final = "2.0"
|
|
114
|
+
|
|
115
|
+
SESSION_KEY_ENVIRONMENT_VARIABLE: Final = "VELOPHEX_EXECUTOR_SESSION_KEY"
|
|
116
|
+
SESSION_KEY_BYTES: Final = 32
|
|
117
|
+
|
|
118
|
+
FRAME_LENGTH_BYTES: Final = 4
|
|
119
|
+
FRAME_TAG_BYTES: Final = 32
|
|
120
|
+
FRAME_HEADER_BYTES: Final = FRAME_LENGTH_BYTES + FRAME_TAG_BYTES
|
|
121
|
+
|
|
122
|
+
DEFAULT_MAXIMUM_FRAME_BYTES: Final = 16 * 1024 * 1024
|
|
123
|
+
ABSOLUTE_MAXIMUM_FRAME_BYTES: Final = 256 * 1024 * 1024
|
|
124
|
+
|
|
125
|
+
#: Domain separator bound into every tag; the trailing NUL is part of it.
|
|
126
|
+
TAG_LABEL: Final = b"VPXEX/1\x00"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ExecutorFrameDirection:
|
|
130
|
+
"""Which side wrote a frame. Bound into the tag, so a frame is valid one way only."""
|
|
131
|
+
|
|
132
|
+
PARENT_TO_EXECUTOR: Final = 1
|
|
133
|
+
EXECUTOR_TO_PARENT: Final = 2
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class ExecutorCapabilities:
|
|
137
|
+
"""Optional features an executor or parent announces in the hello exchange."""
|
|
138
|
+
|
|
139
|
+
PREPARE: Final = "prepare"
|
|
140
|
+
DEBUG: Final = "debug"
|
|
141
|
+
PROGRESS: Final = "progress"
|
|
142
|
+
SUB_INVOKE: Final = "sub-invoke"
|
|
143
|
+
|
|
144
|
+
# Revision 1.1 debugger features (offered by an executor).
|
|
145
|
+
DEBUG_EXCEPTIONS: Final = "debug-exceptions"
|
|
146
|
+
DEBUG_SLOW_STEP: Final = "debug-slow-step"
|
|
147
|
+
DEBUG_LOG_ACTIVITIES: Final = "debug-log-activities"
|
|
148
|
+
DEBUG_TRAIL: Final = "debug-trail"
|
|
149
|
+
DEBUG_ACTIVITY_OPTIONS: Final = "debug-options"
|
|
150
|
+
DEBUG_STEP_INTO_INVOKE: Final = "debug-step-into-invoke"
|
|
151
|
+
DEBUG_RUN_TO: Final = "debug-run-to"
|
|
152
|
+
DEBUG_RUN_FROM: Final = "debug-run-from"
|
|
153
|
+
DEBUG_VARIABLES: Final = "debug-variables"
|
|
154
|
+
DEBUG_BRANCHES: Final = "debug-branches"
|
|
155
|
+
|
|
156
|
+
# Contracts 0.8.16 (engine ADR 0037 section 6): handles ``request-stop``. The worker offers it.
|
|
157
|
+
SHOULD_STOP: Final = "should-stop"
|
|
158
|
+
|
|
159
|
+
# Contracts 0.8.25: a parent opts in to test mode and ``test-result`` messages. The worker does
|
|
160
|
+
# not offer it; a Python test case runs as a plain entry point.
|
|
161
|
+
TEST_RESULTS: Final = "test-results"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class DebugCommands:
|
|
165
|
+
"""Commands a debugging parent sends."""
|
|
166
|
+
|
|
167
|
+
CONTINUE: Final = "Continue"
|
|
168
|
+
STEP_INTO: Final = "StepInto"
|
|
169
|
+
STEP_OVER: Final = "StepOver"
|
|
170
|
+
STEP_OUT: Final = "StepOut"
|
|
171
|
+
PAUSE: Final = "Pause"
|
|
172
|
+
SET_BREAKPOINTS: Final = "SetBreakpoints"
|
|
173
|
+
EVALUATE: Final = "Evaluate"
|
|
174
|
+
RETRY: Final = "Retry"
|
|
175
|
+
IGNORE: Final = "Ignore"
|
|
176
|
+
RESTART: Final = "Restart"
|
|
177
|
+
RUN_TO: Final = "RunTo"
|
|
178
|
+
GET_CHILDREN: Final = "GetChildren"
|
|
179
|
+
SET_VARIABLE: Final = "SetVariable"
|
|
180
|
+
SET_SETTINGS: Final = "SetSettings"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class DebugPauseReasons:
|
|
184
|
+
"""The ``reason`` of ``debug-paused``."""
|
|
185
|
+
|
|
186
|
+
ENTRY: Final = "Entry"
|
|
187
|
+
BREAKPOINT: Final = "Breakpoint"
|
|
188
|
+
STEP: Final = "Step"
|
|
189
|
+
PAUSE: Final = "Pause"
|
|
190
|
+
EXCEPTION: Final = "Exception"
|
|
191
|
+
RUN_TO: Final = "RunTo"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class DebugTraceStates:
|
|
195
|
+
"""The ``state`` of ``debug-trace``."""
|
|
196
|
+
|
|
197
|
+
STARTED: Final = "Started"
|
|
198
|
+
COMPLETED: Final = "Completed"
|
|
199
|
+
FAULTED: Final = "Faulted"
|
|
200
|
+
SKIPPED: Final = "Skipped"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class DebugOptionKeys:
|
|
204
|
+
"""Well-known keys of ``DebugSettings.options``."""
|
|
205
|
+
|
|
206
|
+
HIGHLIGHT_ELEMENTS: Final = "highlightElements"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class ExecutorProtocolError:
|
|
210
|
+
"""Why a frame or message was rejected. Mirrors the .NET ``ExecutorProtocolError`` enum."""
|
|
211
|
+
|
|
212
|
+
INVALID_LENGTH: Final = "InvalidLength"
|
|
213
|
+
PAYLOAD_TOO_LARGE: Final = "PayloadTooLarge"
|
|
214
|
+
TRUNCATED_FRAME: Final = "TruncatedFrame"
|
|
215
|
+
AUTHENTICATION_FAILED: Final = "AuthenticationFailed"
|
|
216
|
+
MALFORMED_MESSAGE: Final = "MalformedMessage"
|
|
217
|
+
UNKNOWN_MESSAGE_TYPE: Final = "UnknownMessageType"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class ExecutorProtocolException(Exception):
|
|
221
|
+
"""A protocol violation. The channel is closed and the parent kills the executor."""
|
|
222
|
+
|
|
223
|
+
def __init__(self, error: str, message: str) -> None:
|
|
224
|
+
super().__init__(message)
|
|
225
|
+
self.error = error
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# --------------------------------------------------------------------------- timestamps
|
|
229
|
+
|
|
230
|
+
_FRACTION = re.compile(r"\.(\d+)")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def format_timestamp(value: datetime) -> _json.VerbatimString:
|
|
234
|
+
r"""Format *value* the way ``System.Text.Json`` writes a ``DateTimeOffset``.
|
|
235
|
+
|
|
236
|
+
``yyyy-MM-ddTHH:mm:ss[.fffffff]+HH:MM``: the fraction is dropped when it is zero and
|
|
237
|
+
otherwise carries no trailing zeros, and UTC is written as ``+00:00`` rather than ``Z``.
|
|
238
|
+
A naive value is read as UTC, because the protocol has no local time.
|
|
239
|
+
|
|
240
|
+
The result is a :class:`velophex._json.VerbatimString`, because .NET writes a
|
|
241
|
+
``DateTimeOffset`` through a typed fast path that never reaches the escaping encoder: the
|
|
242
|
+
``+`` of the offset stays a ``+`` where the same text in a plain string property would
|
|
243
|
+
become ``+``. The frame vectors depend on it.
|
|
244
|
+
"""
|
|
245
|
+
moment = value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
|
246
|
+
|
|
247
|
+
text = (
|
|
248
|
+
f"{moment.year:04d}-{moment.month:02d}-{moment.day:02d}"
|
|
249
|
+
f"T{moment.hour:02d}:{moment.minute:02d}:{moment.second:02d}"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if moment.microsecond:
|
|
253
|
+
text += f".{moment.microsecond:06d}".rstrip("0")
|
|
254
|
+
|
|
255
|
+
offset = moment.utcoffset() or timedelta(0)
|
|
256
|
+
total_minutes = int(offset.total_seconds() // 60)
|
|
257
|
+
sign = "-" if total_minutes < 0 else "+"
|
|
258
|
+
total_minutes = abs(total_minutes)
|
|
259
|
+
|
|
260
|
+
return _json.VerbatimString(f"{text}{sign}{total_minutes // 60:02d}:{total_minutes % 60:02d}")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def parse_timestamp(value: str) -> datetime:
|
|
264
|
+
"""Parse an ISO 8601 timestamp, tolerating ``Z`` and the 7 fractional digits .NET writes."""
|
|
265
|
+
text = value.strip()
|
|
266
|
+
|
|
267
|
+
if text.endswith(("Z", "z")):
|
|
268
|
+
text = text[:-1] + "+00:00"
|
|
269
|
+
|
|
270
|
+
# datetime.fromisoformat accepts 3 or 6 fractional digits only.
|
|
271
|
+
def _trim(match: re.Match[str]) -> str:
|
|
272
|
+
digits = match.group(1)
|
|
273
|
+
return "." + (digits[:6].ljust(6, "0"))
|
|
274
|
+
|
|
275
|
+
text = _FRACTION.sub(_trim, text, count=1)
|
|
276
|
+
|
|
277
|
+
parsed = datetime.fromisoformat(text)
|
|
278
|
+
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _timestamp(body: dict[str, Any], name: str) -> datetime | None:
|
|
282
|
+
raw = body.get(name)
|
|
283
|
+
return parse_timestamp(raw) if isinstance(raw, str) else None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _uuid(body: dict[str, Any], name: str) -> UUID | None:
|
|
287
|
+
raw = body.get(name)
|
|
288
|
+
return UUID(raw) if isinstance(raw, str) else None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# --------------------------------------------------------------------------- payload types
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
#: ``FaultInfo.version`` of a fault that carries the envelope members (contracts 0.8.37).
|
|
295
|
+
FAULT_ENVELOPE_VERSION: Final = 2
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class FaultRetryScopes:
|
|
299
|
+
"""What a retry of a failed run would repeat (``FaultInfo.retry_scope``)."""
|
|
300
|
+
|
|
301
|
+
#: Nothing safely: an effect was committed or the failure is deterministic.
|
|
302
|
+
NONE: Final = "none"
|
|
303
|
+
|
|
304
|
+
#: Only a model call failed and nothing was changed.
|
|
305
|
+
MODEL_CALL: Final = "model-call"
|
|
306
|
+
|
|
307
|
+
#: The whole run may be started again.
|
|
308
|
+
RUN: Final = "run"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@dataclass(frozen=True, slots=True)
|
|
312
|
+
class FaultInfo:
|
|
313
|
+
"""Why a run failed. ``kind`` is one of ``FaultKinds`` and decides whether it is retried.
|
|
314
|
+
|
|
315
|
+
``code`` names the condition and is distinct from ``kind``. The members after ``retryable``
|
|
316
|
+
are the fault envelope (``version`` 2, contracts 0.8.37), each absent from the wire when
|
|
317
|
+
unset, so a version-1 fault keeps its exact bytes.
|
|
318
|
+
"""
|
|
319
|
+
|
|
320
|
+
kind: str
|
|
321
|
+
code: str
|
|
322
|
+
message: str
|
|
323
|
+
detail: str | None = None
|
|
324
|
+
retryable: bool = True
|
|
325
|
+
version: int | None = None
|
|
326
|
+
cause: str | None = None
|
|
327
|
+
fix: str | None = None
|
|
328
|
+
retry_scope: str | None = None
|
|
329
|
+
request_id: str | None = None
|
|
330
|
+
docs_url: str | None = None
|
|
331
|
+
effects_committed: bool | None = None
|
|
332
|
+
|
|
333
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
334
|
+
"""Ordered body of the .NET ``FaultInfo`` record."""
|
|
335
|
+
return omit_none(
|
|
336
|
+
{
|
|
337
|
+
"kind": self.kind,
|
|
338
|
+
"code": self.code,
|
|
339
|
+
"message": self.message,
|
|
340
|
+
"detail": self.detail,
|
|
341
|
+
"retryable": self.retryable,
|
|
342
|
+
"version": self.version,
|
|
343
|
+
"cause": self.cause,
|
|
344
|
+
"fix": self.fix,
|
|
345
|
+
"retryScope": self.retry_scope,
|
|
346
|
+
"requestId": self.request_id,
|
|
347
|
+
"docsUrl": self.docs_url,
|
|
348
|
+
"effectsCommitted": self.effects_committed,
|
|
349
|
+
}
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
@classmethod
|
|
353
|
+
def from_json_value(cls, body: dict[str, Any]) -> FaultInfo:
|
|
354
|
+
"""Read a fault from its body."""
|
|
355
|
+
version = body.get("version")
|
|
356
|
+
committed = body.get("effectsCommitted")
|
|
357
|
+
|
|
358
|
+
return cls(
|
|
359
|
+
kind=str(body["kind"]),
|
|
360
|
+
code=str(body["code"]),
|
|
361
|
+
message=str(body["message"]),
|
|
362
|
+
detail=body.get("detail"),
|
|
363
|
+
retryable=bool(body.get("retryable", True)),
|
|
364
|
+
version=int(version) if version is not None else None,
|
|
365
|
+
cause=body.get("cause"),
|
|
366
|
+
fix=body.get("fix"),
|
|
367
|
+
retry_scope=body.get("retryScope"),
|
|
368
|
+
request_id=body.get("requestId"),
|
|
369
|
+
docs_url=body.get("docsUrl"),
|
|
370
|
+
effects_committed=bool(committed) if committed is not None else None,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@dataclass(frozen=True, slots=True)
|
|
375
|
+
class BreakpointInfo:
|
|
376
|
+
"""A breakpoint on a workflow element id."""
|
|
377
|
+
|
|
378
|
+
node_id: str
|
|
379
|
+
condition: str | None = None
|
|
380
|
+
hit_count: int | None = None
|
|
381
|
+
log_message: str | None = None
|
|
382
|
+
workflow_path: str | None = None
|
|
383
|
+
|
|
384
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
385
|
+
"""Ordered body of the .NET ``BreakpointInfo`` record."""
|
|
386
|
+
return omit_none(
|
|
387
|
+
{
|
|
388
|
+
"nodeId": self.node_id,
|
|
389
|
+
"condition": self.condition,
|
|
390
|
+
"hitCount": self.hit_count,
|
|
391
|
+
"logMessage": self.log_message,
|
|
392
|
+
"workflowPath": self.workflow_path,
|
|
393
|
+
}
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
@classmethod
|
|
397
|
+
def from_json_value(cls, body: dict[str, Any]) -> BreakpointInfo:
|
|
398
|
+
"""Read a breakpoint from its body."""
|
|
399
|
+
return cls(
|
|
400
|
+
node_id=str(body["nodeId"]),
|
|
401
|
+
condition=body.get("condition"),
|
|
402
|
+
hit_count=int(body["hitCount"]) if body.get("hitCount") is not None else None,
|
|
403
|
+
log_message=body.get("logMessage"),
|
|
404
|
+
workflow_path=body.get("workflowPath"),
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@dataclass(frozen=True, slots=True)
|
|
409
|
+
class DebugLocation:
|
|
410
|
+
"""An element of a workflow file (revision 1.1)."""
|
|
411
|
+
|
|
412
|
+
node_id: str
|
|
413
|
+
workflow_path: str | None = None
|
|
414
|
+
|
|
415
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
416
|
+
"""Ordered body of the .NET ``DebugLocation`` record."""
|
|
417
|
+
return omit_none({"nodeId": self.node_id, "workflowPath": self.workflow_path})
|
|
418
|
+
|
|
419
|
+
@classmethod
|
|
420
|
+
def from_json_value(cls, body: dict[str, Any]) -> DebugLocation:
|
|
421
|
+
"""Read a location from its body."""
|
|
422
|
+
return cls(node_id=str(body["nodeId"]), workflow_path=body.get("workflowPath"))
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _optional_location(body: dict[str, Any], name: str) -> DebugLocation | None:
|
|
426
|
+
value = body.get(name)
|
|
427
|
+
return DebugLocation.from_json_value(value) if isinstance(value, dict) else None
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _optional_bool(body: dict[str, Any], name: str) -> bool | None:
|
|
431
|
+
value = body.get(name)
|
|
432
|
+
return bool(value) if value is not None else None
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _optional_int(body: dict[str, Any], name: str) -> int | None:
|
|
436
|
+
value = body.get(name)
|
|
437
|
+
return int(value) if value is not None else None
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
@dataclass(frozen=True, slots=True)
|
|
441
|
+
class DebugSettings:
|
|
442
|
+
"""Debug behaviour set at start or changed by ``SetSettings`` (revision 1.1)."""
|
|
443
|
+
|
|
444
|
+
break_on_exceptions: bool | None = None
|
|
445
|
+
break_on_caught_exceptions: bool | None = None
|
|
446
|
+
step_delay_milliseconds: int | None = None
|
|
447
|
+
log_activities: bool | None = None
|
|
448
|
+
execution_trail: bool | None = None
|
|
449
|
+
options: dict[str, Any] | None = None
|
|
450
|
+
|
|
451
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
452
|
+
"""Ordered body of the .NET ``DebugSettings`` record."""
|
|
453
|
+
return omit_none(
|
|
454
|
+
{
|
|
455
|
+
"breakOnExceptions": self.break_on_exceptions,
|
|
456
|
+
"breakOnCaughtExceptions": self.break_on_caught_exceptions,
|
|
457
|
+
"stepDelayMilliseconds": self.step_delay_milliseconds,
|
|
458
|
+
"logActivities": self.log_activities,
|
|
459
|
+
"executionTrail": self.execution_trail,
|
|
460
|
+
"options": dict(self.options) if self.options is not None else None,
|
|
461
|
+
}
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
@classmethod
|
|
465
|
+
def from_json_value(cls, body: dict[str, Any]) -> DebugSettings:
|
|
466
|
+
"""Read debug settings from their body."""
|
|
467
|
+
options = body.get("options")
|
|
468
|
+
|
|
469
|
+
return cls(
|
|
470
|
+
break_on_exceptions=_optional_bool(body, "breakOnExceptions"),
|
|
471
|
+
break_on_caught_exceptions=_optional_bool(body, "breakOnCaughtExceptions"),
|
|
472
|
+
step_delay_milliseconds=_optional_int(body, "stepDelayMilliseconds"),
|
|
473
|
+
log_activities=_optional_bool(body, "logActivities"),
|
|
474
|
+
execution_trail=_optional_bool(body, "executionTrail"),
|
|
475
|
+
options=dict(options) if isinstance(options, dict) else None,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _optional_settings(body: dict[str, Any], name: str) -> DebugSettings | None:
|
|
480
|
+
value = body.get(name)
|
|
481
|
+
return DebugSettings.from_json_value(value) if isinstance(value, dict) else None
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
@dataclass(frozen=True, slots=True)
|
|
485
|
+
class DebugOptions:
|
|
486
|
+
"""Debugging asked for at start."""
|
|
487
|
+
|
|
488
|
+
breakpoints: list[BreakpointInfo] = field(default_factory=list)
|
|
489
|
+
stop_on_entry: bool = False
|
|
490
|
+
settings: DebugSettings | None = None
|
|
491
|
+
run_to: DebugLocation | None = None
|
|
492
|
+
start_at: DebugLocation | None = None
|
|
493
|
+
|
|
494
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
495
|
+
"""Ordered body of the .NET ``DebugOptions`` record."""
|
|
496
|
+
return omit_none(
|
|
497
|
+
{
|
|
498
|
+
"breakpoints": [breakpoint.to_json_value() for breakpoint in self.breakpoints],
|
|
499
|
+
"stopOnEntry": self.stop_on_entry,
|
|
500
|
+
"settings": self.settings.to_json_value() if self.settings is not None else None,
|
|
501
|
+
"runTo": self.run_to.to_json_value() if self.run_to is not None else None,
|
|
502
|
+
"startAt": self.start_at.to_json_value() if self.start_at is not None else None,
|
|
503
|
+
}
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
@classmethod
|
|
507
|
+
def from_json_value(cls, body: dict[str, Any]) -> DebugOptions:
|
|
508
|
+
"""Read debug options from their body."""
|
|
509
|
+
return cls(
|
|
510
|
+
breakpoints=[BreakpointInfo.from_json_value(item) for item in body.get("breakpoints") or []],
|
|
511
|
+
stop_on_entry=bool(body.get("stopOnEntry", False)),
|
|
512
|
+
settings=_optional_settings(body, "settings"),
|
|
513
|
+
run_to=_optional_location(body, "runTo"),
|
|
514
|
+
start_at=_optional_location(body, "startAt"),
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
@dataclass(frozen=True, slots=True)
|
|
519
|
+
class ExecutionContextInfo:
|
|
520
|
+
"""Who is running and on whose behalf.
|
|
521
|
+
|
|
522
|
+
``runtime_token`` authorises the Orchestrator Runtime API for this attempt only. The
|
|
523
|
+
worker keeps it in memory, never logs it, never puts it in a child process's environment
|
|
524
|
+
and never returns it in outputs.
|
|
525
|
+
"""
|
|
526
|
+
|
|
527
|
+
job_id: UUID | None = None
|
|
528
|
+
attempt_id: UUID | None = None
|
|
529
|
+
attempt_number: int = 0
|
|
530
|
+
orchestrator_url: str | None = None
|
|
531
|
+
runtime_token: str | None = None
|
|
532
|
+
runtime_token_expires_at: datetime | None = None
|
|
533
|
+
deadline: datetime | None = None
|
|
534
|
+
robot_name: str | None = None
|
|
535
|
+
machine_name: str | None = None
|
|
536
|
+
trace_parent: str | None = None
|
|
537
|
+
trace_state: str | None = None
|
|
538
|
+
|
|
539
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
540
|
+
"""Ordered body of the .NET ``ExecutionContextInfo`` record."""
|
|
541
|
+
return omit_none(
|
|
542
|
+
{
|
|
543
|
+
"jobId": str(self.job_id) if self.job_id else None,
|
|
544
|
+
"attemptId": str(self.attempt_id) if self.attempt_id else None,
|
|
545
|
+
"attemptNumber": self.attempt_number,
|
|
546
|
+
"orchestratorUrl": self.orchestrator_url,
|
|
547
|
+
"runtimeToken": self.runtime_token,
|
|
548
|
+
"runtimeTokenExpiresAt": _optional_timestamp(self.runtime_token_expires_at),
|
|
549
|
+
"deadline": _optional_timestamp(self.deadline),
|
|
550
|
+
"robotName": self.robot_name,
|
|
551
|
+
"machineName": self.machine_name,
|
|
552
|
+
"traceParent": self.trace_parent,
|
|
553
|
+
"traceState": self.trace_state,
|
|
554
|
+
}
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
@classmethod
|
|
558
|
+
def from_json_value(cls, body: dict[str, Any]) -> ExecutionContextInfo:
|
|
559
|
+
"""Read the execution context from its body."""
|
|
560
|
+
return cls(
|
|
561
|
+
job_id=_uuid(body, "jobId"),
|
|
562
|
+
attempt_id=_uuid(body, "attemptId"),
|
|
563
|
+
attempt_number=int(body.get("attemptNumber", 0)),
|
|
564
|
+
orchestrator_url=body.get("orchestratorUrl"),
|
|
565
|
+
runtime_token=body.get("runtimeToken"),
|
|
566
|
+
runtime_token_expires_at=_timestamp(body, "runtimeTokenExpiresAt"),
|
|
567
|
+
deadline=_timestamp(body, "deadline"),
|
|
568
|
+
robot_name=body.get("robotName"),
|
|
569
|
+
machine_name=body.get("machineName"),
|
|
570
|
+
trace_parent=body.get("traceParent"),
|
|
571
|
+
trace_state=body.get("traceState"),
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
def redacted(self) -> ExecutionContextInfo:
|
|
575
|
+
"""A copy without the runtime token, safe to log or hand to automation code."""
|
|
576
|
+
return ExecutionContextInfo(
|
|
577
|
+
job_id=self.job_id,
|
|
578
|
+
attempt_id=self.attempt_id,
|
|
579
|
+
attempt_number=self.attempt_number,
|
|
580
|
+
orchestrator_url=self.orchestrator_url,
|
|
581
|
+
runtime_token=None,
|
|
582
|
+
runtime_token_expires_at=self.runtime_token_expires_at,
|
|
583
|
+
deadline=self.deadline,
|
|
584
|
+
robot_name=self.robot_name,
|
|
585
|
+
machine_name=self.machine_name,
|
|
586
|
+
trace_parent=self.trace_parent,
|
|
587
|
+
trace_state=self.trace_state,
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
@dataclass(frozen=True, slots=True)
|
|
592
|
+
class DebugVariableInfo:
|
|
593
|
+
"""One local in a paused frame."""
|
|
594
|
+
|
|
595
|
+
name: str
|
|
596
|
+
type_name: str | None = None
|
|
597
|
+
value: str | None = None
|
|
598
|
+
is_sensitive: bool = False
|
|
599
|
+
reference: int | None = None
|
|
600
|
+
child_count: int | None = None
|
|
601
|
+
|
|
602
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
603
|
+
"""Ordered body of the .NET ``DebugVariableInfo`` record."""
|
|
604
|
+
return omit_none(
|
|
605
|
+
{
|
|
606
|
+
"name": self.name,
|
|
607
|
+
"typeName": self.type_name,
|
|
608
|
+
"value": self.value,
|
|
609
|
+
"isSensitive": self.is_sensitive,
|
|
610
|
+
"reference": self.reference,
|
|
611
|
+
"childCount": self.child_count,
|
|
612
|
+
}
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
@classmethod
|
|
616
|
+
def from_json_value(cls, body: dict[str, Any]) -> DebugVariableInfo:
|
|
617
|
+
"""Read a local from its body."""
|
|
618
|
+
return cls(
|
|
619
|
+
name=str(body["name"]),
|
|
620
|
+
type_name=body.get("typeName"),
|
|
621
|
+
value=body.get("value"),
|
|
622
|
+
is_sensitive=bool(body.get("isSensitive", False)),
|
|
623
|
+
reference=_optional_int(body, "reference"),
|
|
624
|
+
child_count=_optional_int(body, "childCount"),
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _variables(body: dict[str, Any], name: str) -> list[DebugVariableInfo]:
|
|
629
|
+
return [DebugVariableInfo.from_json_value(item) for item in body.get(name) or []]
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
@dataclass(frozen=True, slots=True)
|
|
633
|
+
class DebugFrameInfo:
|
|
634
|
+
"""One frame of a paused call stack."""
|
|
635
|
+
|
|
636
|
+
node_id: str
|
|
637
|
+
display_name: str | None = None
|
|
638
|
+
workflow_path: str | None = None
|
|
639
|
+
is_faulted: bool | None = None
|
|
640
|
+
|
|
641
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
642
|
+
"""Ordered body of the .NET ``DebugFrameInfo`` record."""
|
|
643
|
+
return omit_none(
|
|
644
|
+
{
|
|
645
|
+
"nodeId": self.node_id,
|
|
646
|
+
"displayName": self.display_name,
|
|
647
|
+
"workflowPath": self.workflow_path,
|
|
648
|
+
"isFaulted": self.is_faulted,
|
|
649
|
+
}
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
@classmethod
|
|
653
|
+
def from_json_value(cls, body: dict[str, Any]) -> DebugFrameInfo:
|
|
654
|
+
"""Read a call stack frame from its body."""
|
|
655
|
+
return cls(
|
|
656
|
+
node_id=str(body["nodeId"]),
|
|
657
|
+
display_name=body.get("displayName"),
|
|
658
|
+
workflow_path=body.get("workflowPath"),
|
|
659
|
+
is_faulted=_optional_bool(body, "isFaulted"),
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
@dataclass(frozen=True, slots=True)
|
|
664
|
+
class DebugExceptionInfo:
|
|
665
|
+
"""The exception a run paused on (revision 1.1)."""
|
|
666
|
+
|
|
667
|
+
type_name: str
|
|
668
|
+
message: str
|
|
669
|
+
is_handled: bool
|
|
670
|
+
code: str | None = None
|
|
671
|
+
category: str | None = None
|
|
672
|
+
detail: str | None = None
|
|
673
|
+
|
|
674
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
675
|
+
"""Ordered body of the .NET ``DebugExceptionInfo`` record."""
|
|
676
|
+
return omit_none(
|
|
677
|
+
{
|
|
678
|
+
"typeName": self.type_name,
|
|
679
|
+
"message": self.message,
|
|
680
|
+
"isHandled": self.is_handled,
|
|
681
|
+
"code": self.code,
|
|
682
|
+
"category": self.category,
|
|
683
|
+
"detail": self.detail,
|
|
684
|
+
}
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
@classmethod
|
|
688
|
+
def from_json_value(cls, body: dict[str, Any]) -> DebugExceptionInfo:
|
|
689
|
+
"""Read exception details from their body."""
|
|
690
|
+
return cls(
|
|
691
|
+
type_name=str(body["typeName"]),
|
|
692
|
+
message=str(body["message"]),
|
|
693
|
+
is_handled=bool(body["isHandled"]),
|
|
694
|
+
code=body.get("code"),
|
|
695
|
+
category=body.get("category"),
|
|
696
|
+
detail=body.get("detail"),
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
@dataclass(frozen=True, slots=True)
|
|
701
|
+
class DebugStepOutputs:
|
|
702
|
+
"""What the last completed element wrote (revision 1.1)."""
|
|
703
|
+
|
|
704
|
+
node_id: str
|
|
705
|
+
display_name: str | None = None
|
|
706
|
+
outputs: list[DebugVariableInfo] = field(default_factory=list)
|
|
707
|
+
workflow_path: str | None = None
|
|
708
|
+
|
|
709
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
710
|
+
"""Ordered body of the .NET ``DebugStepOutputs`` record."""
|
|
711
|
+
return omit_none(
|
|
712
|
+
{
|
|
713
|
+
"nodeId": self.node_id,
|
|
714
|
+
"displayName": self.display_name,
|
|
715
|
+
"outputs": [output.to_json_value() for output in self.outputs],
|
|
716
|
+
"workflowPath": self.workflow_path,
|
|
717
|
+
}
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
@classmethod
|
|
721
|
+
def from_json_value(cls, body: dict[str, Any]) -> DebugStepOutputs:
|
|
722
|
+
"""Read step outputs from their body."""
|
|
723
|
+
return cls(
|
|
724
|
+
node_id=str(body["nodeId"]),
|
|
725
|
+
display_name=body.get("displayName"),
|
|
726
|
+
outputs=_variables(body, "outputs"),
|
|
727
|
+
workflow_path=body.get("workflowPath"),
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _optional_timestamp(value: datetime | None) -> _json.VerbatimString | None:
|
|
732
|
+
return format_timestamp(value) if value is not None else None
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
# --------------------------------------------------------------------------- messages
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
class ExecutorMessage:
|
|
739
|
+
"""Base of every executor protocol message."""
|
|
740
|
+
|
|
741
|
+
#: The ``type`` discriminator of the envelope.
|
|
742
|
+
type_name: ClassVar[str]
|
|
743
|
+
|
|
744
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
745
|
+
"""The message body, members in the order the .NET record declares them."""
|
|
746
|
+
raise NotImplementedError
|
|
747
|
+
|
|
748
|
+
@classmethod
|
|
749
|
+
def from_json_value(cls, body: dict[str, Any]) -> ExecutorMessage:
|
|
750
|
+
"""Read the message from its body."""
|
|
751
|
+
raise NotImplementedError
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
@dataclass(frozen=True, slots=True)
|
|
755
|
+
class HelloMessage(ExecutorMessage):
|
|
756
|
+
"""Parent to executor: the first frame of a session."""
|
|
757
|
+
|
|
758
|
+
type_name: ClassVar[str] = "hello"
|
|
759
|
+
|
|
760
|
+
minimum_version: int
|
|
761
|
+
maximum_version: int
|
|
762
|
+
parent_name: str
|
|
763
|
+
parent_version: str
|
|
764
|
+
capabilities: list[str] = field(default_factory=list)
|
|
765
|
+
|
|
766
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
767
|
+
"""Ordered body of ``hello``."""
|
|
768
|
+
return {
|
|
769
|
+
"minimumVersion": self.minimum_version,
|
|
770
|
+
"maximumVersion": self.maximum_version,
|
|
771
|
+
"parentName": self.parent_name,
|
|
772
|
+
"parentVersion": self.parent_version,
|
|
773
|
+
"capabilities": list(self.capabilities),
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
@classmethod
|
|
777
|
+
def from_json_value(cls, body: dict[str, Any]) -> HelloMessage:
|
|
778
|
+
"""Read ``hello`` from its body."""
|
|
779
|
+
return cls(
|
|
780
|
+
minimum_version=int(body["minimumVersion"]),
|
|
781
|
+
maximum_version=int(body["maximumVersion"]),
|
|
782
|
+
parent_name=str(body["parentName"]),
|
|
783
|
+
parent_version=str(body["parentVersion"]),
|
|
784
|
+
capabilities=[str(item) for item in body.get("capabilities") or []],
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
@dataclass(frozen=True, slots=True)
|
|
789
|
+
class HelloAckMessage(ExecutorMessage):
|
|
790
|
+
"""Executor to parent: accepts the session with the highest common version."""
|
|
791
|
+
|
|
792
|
+
type_name: ClassVar[str] = "hello-ack"
|
|
793
|
+
|
|
794
|
+
version: int
|
|
795
|
+
runtime: str
|
|
796
|
+
runtime_version: str
|
|
797
|
+
executor_version: str
|
|
798
|
+
capabilities: list[str] = field(default_factory=list)
|
|
799
|
+
|
|
800
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
801
|
+
"""Ordered body of ``hello-ack``."""
|
|
802
|
+
return {
|
|
803
|
+
"version": self.version,
|
|
804
|
+
"runtime": self.runtime,
|
|
805
|
+
"runtimeVersion": self.runtime_version,
|
|
806
|
+
"executorVersion": self.executor_version,
|
|
807
|
+
"capabilities": list(self.capabilities),
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
@classmethod
|
|
811
|
+
def from_json_value(cls, body: dict[str, Any]) -> HelloAckMessage:
|
|
812
|
+
"""Read ``hello-ack`` from its body."""
|
|
813
|
+
return cls(
|
|
814
|
+
version=int(body["version"]),
|
|
815
|
+
runtime=str(body["runtime"]),
|
|
816
|
+
runtime_version=str(body["runtimeVersion"]),
|
|
817
|
+
executor_version=str(body["executorVersion"]),
|
|
818
|
+
capabilities=[str(item) for item in body.get("capabilities") or []],
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
@dataclass(frozen=True, slots=True)
|
|
823
|
+
class PrepareMessage(ExecutorMessage):
|
|
824
|
+
"""Parent to executor: build the environment for a package under ``environment_root``."""
|
|
825
|
+
|
|
826
|
+
type_name: ClassVar[str] = "prepare"
|
|
827
|
+
|
|
828
|
+
package_root: str
|
|
829
|
+
environment_root: str
|
|
830
|
+
settings: Any | None = None
|
|
831
|
+
|
|
832
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
833
|
+
"""Ordered body of ``prepare``."""
|
|
834
|
+
return omit_none(
|
|
835
|
+
{
|
|
836
|
+
"packageRoot": self.package_root,
|
|
837
|
+
"environmentRoot": self.environment_root,
|
|
838
|
+
"settings": self.settings,
|
|
839
|
+
}
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
@classmethod
|
|
843
|
+
def from_json_value(cls, body: dict[str, Any]) -> PrepareMessage:
|
|
844
|
+
"""Read ``prepare`` from its body."""
|
|
845
|
+
return cls(
|
|
846
|
+
package_root=str(body["packageRoot"]),
|
|
847
|
+
environment_root=str(body["environmentRoot"]),
|
|
848
|
+
settings=body.get("settings"),
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
@dataclass(frozen=True, slots=True)
|
|
853
|
+
class PreparedMessage(ExecutorMessage):
|
|
854
|
+
"""Executor to parent: the environment is ready."""
|
|
855
|
+
|
|
856
|
+
type_name: ClassVar[str] = "prepared"
|
|
857
|
+
|
|
858
|
+
environment_root: str
|
|
859
|
+
fingerprint: str | None = None
|
|
860
|
+
|
|
861
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
862
|
+
"""Ordered body of ``prepared``."""
|
|
863
|
+
return omit_none({"environmentRoot": self.environment_root, "fingerprint": self.fingerprint})
|
|
864
|
+
|
|
865
|
+
@classmethod
|
|
866
|
+
def from_json_value(cls, body: dict[str, Any]) -> PreparedMessage:
|
|
867
|
+
"""Read ``prepared`` from its body."""
|
|
868
|
+
return cls(environment_root=str(body["environmentRoot"]), fingerprint=body.get("fingerprint"))
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
@dataclass(frozen=True, slots=True)
|
|
872
|
+
class StartMessage(ExecutorMessage):
|
|
873
|
+
"""Parent to executor: run one entry point of the extracted package."""
|
|
874
|
+
|
|
875
|
+
type_name: ClassVar[str] = "start"
|
|
876
|
+
|
|
877
|
+
package_root: str
|
|
878
|
+
entry_point_id: str
|
|
879
|
+
context: ExecutionContextInfo
|
|
880
|
+
environment_root: str | None = None
|
|
881
|
+
inputs: Any | None = None
|
|
882
|
+
debug: DebugOptions | None = None
|
|
883
|
+
#: The tenant expression policy (engine ADR 0036), kept as read; Python has no expressions.
|
|
884
|
+
expression_policy: Any | None = None
|
|
885
|
+
|
|
886
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
887
|
+
"""Ordered body of ``start``."""
|
|
888
|
+
return omit_none(
|
|
889
|
+
{
|
|
890
|
+
"packageRoot": self.package_root,
|
|
891
|
+
"environmentRoot": self.environment_root,
|
|
892
|
+
"entryPointId": self.entry_point_id,
|
|
893
|
+
"inputs": self.inputs,
|
|
894
|
+
"context": self.context.to_json_value(),
|
|
895
|
+
"debug": self.debug.to_json_value() if self.debug is not None else None,
|
|
896
|
+
"expressionPolicy": self.expression_policy,
|
|
897
|
+
}
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
@classmethod
|
|
901
|
+
def from_json_value(cls, body: dict[str, Any]) -> StartMessage:
|
|
902
|
+
"""Read ``start`` from its body."""
|
|
903
|
+
debug = body.get("debug")
|
|
904
|
+
|
|
905
|
+
return cls(
|
|
906
|
+
package_root=str(body["packageRoot"]),
|
|
907
|
+
entry_point_id=str(body["entryPointId"]),
|
|
908
|
+
context=ExecutionContextInfo.from_json_value(body.get("context") or {}),
|
|
909
|
+
environment_root=body.get("environmentRoot"),
|
|
910
|
+
inputs=body.get("inputs"),
|
|
911
|
+
debug=DebugOptions.from_json_value(debug) if isinstance(debug, dict) else None,
|
|
912
|
+
expression_policy=body.get("expressionPolicy"),
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
@dataclass(frozen=True, slots=True)
|
|
917
|
+
class StopMessage(ExecutorMessage):
|
|
918
|
+
"""Parent to executor: end gracefully within the grace period, or be killed."""
|
|
919
|
+
|
|
920
|
+
type_name: ClassVar[str] = "stop"
|
|
921
|
+
|
|
922
|
+
reason: str
|
|
923
|
+
grace_period_milliseconds: int
|
|
924
|
+
|
|
925
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
926
|
+
"""Ordered body of ``stop``."""
|
|
927
|
+
return {"reason": self.reason, "gracePeriodMilliseconds": self.grace_period_milliseconds}
|
|
928
|
+
|
|
929
|
+
@classmethod
|
|
930
|
+
def from_json_value(cls, body: dict[str, Any]) -> StopMessage:
|
|
931
|
+
"""Read ``stop`` from its body."""
|
|
932
|
+
return cls(reason=str(body["reason"]), grace_period_milliseconds=int(body["gracePeriodMilliseconds"]))
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
@dataclass(frozen=True, slots=True)
|
|
936
|
+
class LogMessage(ExecutorMessage):
|
|
937
|
+
"""Executor to parent: one log line of the automation or the worker."""
|
|
938
|
+
|
|
939
|
+
type_name: ClassVar[str] = "log"
|
|
940
|
+
|
|
941
|
+
sequence: int
|
|
942
|
+
timestamp: datetime
|
|
943
|
+
level: str
|
|
944
|
+
message: str
|
|
945
|
+
phase: str | None = None
|
|
946
|
+
sub_phase: str | None = None
|
|
947
|
+
node_id: str | None = None
|
|
948
|
+
details: Any | None = None
|
|
949
|
+
|
|
950
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
951
|
+
"""Ordered body of ``log``."""
|
|
952
|
+
return omit_none(
|
|
953
|
+
{
|
|
954
|
+
"sequence": self.sequence,
|
|
955
|
+
"timestamp": format_timestamp(self.timestamp),
|
|
956
|
+
"level": self.level,
|
|
957
|
+
"message": self.message,
|
|
958
|
+
"phase": self.phase,
|
|
959
|
+
"subPhase": self.sub_phase,
|
|
960
|
+
"nodeId": self.node_id,
|
|
961
|
+
"details": self.details,
|
|
962
|
+
}
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
@classmethod
|
|
966
|
+
def from_json_value(cls, body: dict[str, Any]) -> LogMessage:
|
|
967
|
+
"""Read ``log`` from its body."""
|
|
968
|
+
return cls(
|
|
969
|
+
sequence=int(body["sequence"]),
|
|
970
|
+
timestamp=parse_timestamp(str(body["timestamp"])),
|
|
971
|
+
level=str(body["level"]),
|
|
972
|
+
message=str(body["message"]),
|
|
973
|
+
phase=body.get("phase"),
|
|
974
|
+
sub_phase=body.get("subPhase"),
|
|
975
|
+
node_id=body.get("nodeId"),
|
|
976
|
+
details=body.get("details"),
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
@dataclass(frozen=True, slots=True)
|
|
981
|
+
class ProgressMessage(ExecutorMessage):
|
|
982
|
+
"""Executor to parent: an element started, completed or faulted."""
|
|
983
|
+
|
|
984
|
+
type_name: ClassVar[str] = "progress"
|
|
985
|
+
|
|
986
|
+
node_id: str
|
|
987
|
+
state: str
|
|
988
|
+
timestamp: datetime
|
|
989
|
+
display_name: str | None = None
|
|
990
|
+
|
|
991
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
992
|
+
"""Ordered body of ``progress``."""
|
|
993
|
+
return omit_none(
|
|
994
|
+
{
|
|
995
|
+
"nodeId": self.node_id,
|
|
996
|
+
"displayName": self.display_name,
|
|
997
|
+
"state": self.state,
|
|
998
|
+
"timestamp": format_timestamp(self.timestamp),
|
|
999
|
+
}
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
@classmethod
|
|
1003
|
+
def from_json_value(cls, body: dict[str, Any]) -> ProgressMessage:
|
|
1004
|
+
"""Read ``progress`` from its body."""
|
|
1005
|
+
return cls(
|
|
1006
|
+
node_id=str(body["nodeId"]),
|
|
1007
|
+
state=str(body["state"]),
|
|
1008
|
+
timestamp=parse_timestamp(str(body["timestamp"])),
|
|
1009
|
+
display_name=body.get("displayName"),
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
@dataclass(frozen=True, slots=True)
|
|
1014
|
+
class HeartbeatMessage(ExecutorMessage):
|
|
1015
|
+
"""Executor to parent: liveness while an activity runs for a long time."""
|
|
1016
|
+
|
|
1017
|
+
type_name: ClassVar[str] = "heartbeat"
|
|
1018
|
+
|
|
1019
|
+
timestamp: datetime
|
|
1020
|
+
|
|
1021
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
1022
|
+
"""Ordered body of ``heartbeat``."""
|
|
1023
|
+
return {"timestamp": format_timestamp(self.timestamp)}
|
|
1024
|
+
|
|
1025
|
+
@classmethod
|
|
1026
|
+
def from_json_value(cls, body: dict[str, Any]) -> HeartbeatMessage:
|
|
1027
|
+
"""Read ``heartbeat`` from its body."""
|
|
1028
|
+
return cls(timestamp=parse_timestamp(str(body["timestamp"])))
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
@dataclass(frozen=True, slots=True)
|
|
1032
|
+
class SubInvokeMessage(ExecutorMessage):
|
|
1033
|
+
"""Executor to parent: run another published automation and send back its outcome."""
|
|
1034
|
+
|
|
1035
|
+
type_name: ClassVar[str] = "sub-invoke"
|
|
1036
|
+
|
|
1037
|
+
request_id: str
|
|
1038
|
+
package_id: str
|
|
1039
|
+
entry_point_id: str | None = None
|
|
1040
|
+
inputs: Any | None = None
|
|
1041
|
+
timeout_seconds: int | None = None
|
|
1042
|
+
|
|
1043
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
1044
|
+
"""Ordered body of ``sub-invoke``."""
|
|
1045
|
+
return omit_none(
|
|
1046
|
+
{
|
|
1047
|
+
"requestId": self.request_id,
|
|
1048
|
+
"packageId": self.package_id,
|
|
1049
|
+
"entryPointId": self.entry_point_id,
|
|
1050
|
+
"inputs": self.inputs,
|
|
1051
|
+
"timeoutSeconds": self.timeout_seconds,
|
|
1052
|
+
}
|
|
1053
|
+
)
|
|
1054
|
+
|
|
1055
|
+
@classmethod
|
|
1056
|
+
def from_json_value(cls, body: dict[str, Any]) -> SubInvokeMessage:
|
|
1057
|
+
"""Read ``sub-invoke`` from its body."""
|
|
1058
|
+
return cls(
|
|
1059
|
+
request_id=str(body["requestId"]),
|
|
1060
|
+
package_id=str(body["packageId"]),
|
|
1061
|
+
entry_point_id=body.get("entryPointId"),
|
|
1062
|
+
inputs=body.get("inputs"),
|
|
1063
|
+
timeout_seconds=int(body["timeoutSeconds"]) if body.get("timeoutSeconds") is not None else None,
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
@dataclass(frozen=True, slots=True)
|
|
1068
|
+
class SubResultMessage(ExecutorMessage):
|
|
1069
|
+
"""Parent to executor: the outcome of a ``sub-invoke``."""
|
|
1070
|
+
|
|
1071
|
+
type_name: ClassVar[str] = "sub-result"
|
|
1072
|
+
|
|
1073
|
+
request_id: str
|
|
1074
|
+
outcome: str
|
|
1075
|
+
outputs: Any | None = None
|
|
1076
|
+
fault: FaultInfo | None = None
|
|
1077
|
+
|
|
1078
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
1079
|
+
"""Ordered body of ``sub-result``."""
|
|
1080
|
+
return omit_none(
|
|
1081
|
+
{
|
|
1082
|
+
"requestId": self.request_id,
|
|
1083
|
+
"outcome": self.outcome,
|
|
1084
|
+
"outputs": self.outputs,
|
|
1085
|
+
"fault": self.fault.to_json_value() if self.fault is not None else None,
|
|
1086
|
+
}
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
@classmethod
|
|
1090
|
+
def from_json_value(cls, body: dict[str, Any]) -> SubResultMessage:
|
|
1091
|
+
"""Read ``sub-result`` from its body."""
|
|
1092
|
+
fault = body.get("fault")
|
|
1093
|
+
|
|
1094
|
+
return cls(
|
|
1095
|
+
request_id=str(body["requestId"]),
|
|
1096
|
+
outcome=str(body["outcome"]),
|
|
1097
|
+
outputs=body.get("outputs"),
|
|
1098
|
+
fault=FaultInfo.from_json_value(fault) if isinstance(fault, dict) else None,
|
|
1099
|
+
)
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
@dataclass(frozen=True, slots=True)
|
|
1103
|
+
class DebugCommandMessage(ExecutorMessage):
|
|
1104
|
+
"""Parent to executor: a debugger command."""
|
|
1105
|
+
|
|
1106
|
+
type_name: ClassVar[str] = "debug-command"
|
|
1107
|
+
|
|
1108
|
+
command: str
|
|
1109
|
+
breakpoints: list[BreakpointInfo] | None = None
|
|
1110
|
+
expression: str | None = None
|
|
1111
|
+
request_id: str | None = None
|
|
1112
|
+
location: DebugLocation | None = None
|
|
1113
|
+
reference: int | None = None
|
|
1114
|
+
name: str | None = None
|
|
1115
|
+
settings: DebugSettings | None = None
|
|
1116
|
+
|
|
1117
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
1118
|
+
"""Ordered body of ``debug-command``."""
|
|
1119
|
+
return omit_none(
|
|
1120
|
+
{
|
|
1121
|
+
"command": self.command,
|
|
1122
|
+
"breakpoints": (
|
|
1123
|
+
[breakpoint.to_json_value() for breakpoint in self.breakpoints]
|
|
1124
|
+
if self.breakpoints is not None
|
|
1125
|
+
else None
|
|
1126
|
+
),
|
|
1127
|
+
"expression": self.expression,
|
|
1128
|
+
"requestId": self.request_id,
|
|
1129
|
+
"location": self.location.to_json_value() if self.location is not None else None,
|
|
1130
|
+
"reference": self.reference,
|
|
1131
|
+
"name": self.name,
|
|
1132
|
+
"settings": self.settings.to_json_value() if self.settings is not None else None,
|
|
1133
|
+
}
|
|
1134
|
+
)
|
|
1135
|
+
|
|
1136
|
+
@classmethod
|
|
1137
|
+
def from_json_value(cls, body: dict[str, Any]) -> DebugCommandMessage:
|
|
1138
|
+
"""Read ``debug-command`` from its body."""
|
|
1139
|
+
breakpoints = body.get("breakpoints")
|
|
1140
|
+
|
|
1141
|
+
return cls(
|
|
1142
|
+
command=str(body["command"]),
|
|
1143
|
+
breakpoints=(
|
|
1144
|
+
[BreakpointInfo.from_json_value(item) for item in breakpoints]
|
|
1145
|
+
if isinstance(breakpoints, list)
|
|
1146
|
+
else None
|
|
1147
|
+
),
|
|
1148
|
+
expression=body.get("expression"),
|
|
1149
|
+
request_id=body.get("requestId"),
|
|
1150
|
+
location=_optional_location(body, "location"),
|
|
1151
|
+
reference=_optional_int(body, "reference"),
|
|
1152
|
+
name=body.get("name"),
|
|
1153
|
+
settings=_optional_settings(body, "settings"),
|
|
1154
|
+
)
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
@dataclass(frozen=True, slots=True)
|
|
1158
|
+
class DebugPausedMessage(ExecutorMessage):
|
|
1159
|
+
"""Executor to parent: execution is paused at ``node_id``."""
|
|
1160
|
+
|
|
1161
|
+
type_name: ClassVar[str] = "debug-paused"
|
|
1162
|
+
|
|
1163
|
+
node_id: str
|
|
1164
|
+
reason: str
|
|
1165
|
+
locals: list[DebugVariableInfo] = field(default_factory=list)
|
|
1166
|
+
call_stack: list[DebugFrameInfo] = field(default_factory=list)
|
|
1167
|
+
workflow_path: str | None = None
|
|
1168
|
+
exception: DebugExceptionInfo | None = None
|
|
1169
|
+
previous_step: DebugStepOutputs | None = None
|
|
1170
|
+
branch: str | None = None
|
|
1171
|
+
"""The parallel branch the run paused in (contracts 0.8.20, capability ``debug-branches``)."""
|
|
1172
|
+
|
|
1173
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
1174
|
+
"""Ordered body of ``debug-paused``."""
|
|
1175
|
+
return omit_none(
|
|
1176
|
+
{
|
|
1177
|
+
"nodeId": self.node_id,
|
|
1178
|
+
"reason": self.reason,
|
|
1179
|
+
"locals": [local.to_json_value() for local in self.locals],
|
|
1180
|
+
"callStack": [frame.to_json_value() for frame in self.call_stack],
|
|
1181
|
+
"workflowPath": self.workflow_path,
|
|
1182
|
+
"exception": self.exception.to_json_value() if self.exception is not None else None,
|
|
1183
|
+
"previousStep": self.previous_step.to_json_value() if self.previous_step is not None else None,
|
|
1184
|
+
"branch": self.branch,
|
|
1185
|
+
}
|
|
1186
|
+
)
|
|
1187
|
+
|
|
1188
|
+
@classmethod
|
|
1189
|
+
def from_json_value(cls, body: dict[str, Any]) -> DebugPausedMessage:
|
|
1190
|
+
"""Read ``debug-paused`` from its body."""
|
|
1191
|
+
exception = body.get("exception")
|
|
1192
|
+
previous = body.get("previousStep")
|
|
1193
|
+
|
|
1194
|
+
return cls(
|
|
1195
|
+
node_id=str(body["nodeId"]),
|
|
1196
|
+
reason=str(body["reason"]),
|
|
1197
|
+
locals=_variables(body, "locals"),
|
|
1198
|
+
call_stack=[DebugFrameInfo.from_json_value(item) for item in body.get("callStack") or []],
|
|
1199
|
+
workflow_path=body.get("workflowPath"),
|
|
1200
|
+
exception=DebugExceptionInfo.from_json_value(exception) if isinstance(exception, dict) else None,
|
|
1201
|
+
previous_step=DebugStepOutputs.from_json_value(previous) if isinstance(previous, dict) else None,
|
|
1202
|
+
branch=body.get("branch"),
|
|
1203
|
+
)
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
@dataclass(frozen=True, slots=True)
|
|
1207
|
+
class DebugResumedMessage(ExecutorMessage):
|
|
1208
|
+
"""Executor to parent: execution continued."""
|
|
1209
|
+
|
|
1210
|
+
type_name: ClassVar[str] = "debug-resumed"
|
|
1211
|
+
|
|
1212
|
+
timestamp: datetime
|
|
1213
|
+
|
|
1214
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
1215
|
+
"""Ordered body of ``debug-resumed``."""
|
|
1216
|
+
return {"timestamp": format_timestamp(self.timestamp)}
|
|
1217
|
+
|
|
1218
|
+
@classmethod
|
|
1219
|
+
def from_json_value(cls, body: dict[str, Any]) -> DebugResumedMessage:
|
|
1220
|
+
"""Read ``debug-resumed`` from its body."""
|
|
1221
|
+
return cls(timestamp=parse_timestamp(str(body["timestamp"])))
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
@dataclass(frozen=True, slots=True)
|
|
1225
|
+
class EvaluateResultMessage(ExecutorMessage):
|
|
1226
|
+
"""Executor to parent: the answer to an Evaluate command."""
|
|
1227
|
+
|
|
1228
|
+
type_name: ClassVar[str] = "evaluate-result"
|
|
1229
|
+
|
|
1230
|
+
request_id: str
|
|
1231
|
+
succeeded: bool
|
|
1232
|
+
value: str | None = None
|
|
1233
|
+
type_name_of_value: str | None = None
|
|
1234
|
+
error: str | None = None
|
|
1235
|
+
|
|
1236
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
1237
|
+
"""Ordered body of ``evaluate-result``."""
|
|
1238
|
+
return omit_none(
|
|
1239
|
+
{
|
|
1240
|
+
"requestId": self.request_id,
|
|
1241
|
+
"succeeded": self.succeeded,
|
|
1242
|
+
"value": self.value,
|
|
1243
|
+
"typeName": self.type_name_of_value,
|
|
1244
|
+
"error": self.error,
|
|
1245
|
+
}
|
|
1246
|
+
)
|
|
1247
|
+
|
|
1248
|
+
@classmethod
|
|
1249
|
+
def from_json_value(cls, body: dict[str, Any]) -> EvaluateResultMessage:
|
|
1250
|
+
"""Read ``evaluate-result`` from its body."""
|
|
1251
|
+
return cls(
|
|
1252
|
+
request_id=str(body["requestId"]),
|
|
1253
|
+
succeeded=bool(body["succeeded"]),
|
|
1254
|
+
value=body.get("value"),
|
|
1255
|
+
type_name_of_value=body.get("typeName"),
|
|
1256
|
+
error=body.get("error"),
|
|
1257
|
+
)
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
@dataclass(frozen=True, slots=True)
|
|
1261
|
+
class DebugVariablesMessage(ExecutorMessage):
|
|
1262
|
+
"""Executor to parent: the children of an expandable variable (revision 1.1)."""
|
|
1263
|
+
|
|
1264
|
+
type_name: ClassVar[str] = "debug-variables"
|
|
1265
|
+
|
|
1266
|
+
request_id: str
|
|
1267
|
+
reference: int
|
|
1268
|
+
variables: list[DebugVariableInfo] = field(default_factory=list)
|
|
1269
|
+
error: str | None = None
|
|
1270
|
+
|
|
1271
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
1272
|
+
"""Ordered body of ``debug-variables``."""
|
|
1273
|
+
return omit_none(
|
|
1274
|
+
{
|
|
1275
|
+
"requestId": self.request_id,
|
|
1276
|
+
"reference": self.reference,
|
|
1277
|
+
"variables": [variable.to_json_value() for variable in self.variables],
|
|
1278
|
+
"error": self.error,
|
|
1279
|
+
}
|
|
1280
|
+
)
|
|
1281
|
+
|
|
1282
|
+
@classmethod
|
|
1283
|
+
def from_json_value(cls, body: dict[str, Any]) -> DebugVariablesMessage:
|
|
1284
|
+
"""Read ``debug-variables`` from its body."""
|
|
1285
|
+
return cls(
|
|
1286
|
+
request_id=str(body["requestId"]),
|
|
1287
|
+
reference=int(body["reference"]),
|
|
1288
|
+
variables=_variables(body, "variables"),
|
|
1289
|
+
error=body.get("error"),
|
|
1290
|
+
)
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
@dataclass(frozen=True, slots=True)
|
|
1294
|
+
class DebugTraceMessage(ExecutorMessage):
|
|
1295
|
+
"""Executor to parent: an element started, completed, faulted or was skipped (revision 1.1)."""
|
|
1296
|
+
|
|
1297
|
+
type_name: ClassVar[str] = "debug-trace"
|
|
1298
|
+
|
|
1299
|
+
node_id: str
|
|
1300
|
+
state: str
|
|
1301
|
+
timestamp: datetime
|
|
1302
|
+
display_name: str | None = None
|
|
1303
|
+
workflow_path: str | None = None
|
|
1304
|
+
kind: str | None = None
|
|
1305
|
+
duration_milliseconds: int | None = None
|
|
1306
|
+
|
|
1307
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
1308
|
+
"""Ordered body of ``debug-trace``."""
|
|
1309
|
+
return omit_none(
|
|
1310
|
+
{
|
|
1311
|
+
"nodeId": self.node_id,
|
|
1312
|
+
"displayName": self.display_name,
|
|
1313
|
+
"state": self.state,
|
|
1314
|
+
"timestamp": format_timestamp(self.timestamp),
|
|
1315
|
+
"workflowPath": self.workflow_path,
|
|
1316
|
+
"kind": self.kind,
|
|
1317
|
+
"durationMilliseconds": self.duration_milliseconds,
|
|
1318
|
+
}
|
|
1319
|
+
)
|
|
1320
|
+
|
|
1321
|
+
@classmethod
|
|
1322
|
+
def from_json_value(cls, body: dict[str, Any]) -> DebugTraceMessage:
|
|
1323
|
+
"""Read ``debug-trace`` from its body."""
|
|
1324
|
+
return cls(
|
|
1325
|
+
node_id=str(body["nodeId"]),
|
|
1326
|
+
state=str(body["state"]),
|
|
1327
|
+
timestamp=parse_timestamp(str(body["timestamp"])),
|
|
1328
|
+
display_name=body.get("displayName"),
|
|
1329
|
+
workflow_path=body.get("workflowPath"),
|
|
1330
|
+
kind=body.get("kind"),
|
|
1331
|
+
duration_milliseconds=_optional_int(body, "durationMilliseconds"),
|
|
1332
|
+
)
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
@dataclass(frozen=True, slots=True)
|
|
1336
|
+
class DebugRestartedMessage(ExecutorMessage):
|
|
1337
|
+
"""Executor to parent: the run restarted from its first element (revision 1.1)."""
|
|
1338
|
+
|
|
1339
|
+
type_name: ClassVar[str] = "debug-restarted"
|
|
1340
|
+
|
|
1341
|
+
run: int
|
|
1342
|
+
timestamp: datetime
|
|
1343
|
+
|
|
1344
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
1345
|
+
"""Ordered body of ``debug-restarted``."""
|
|
1346
|
+
return {"run": self.run, "timestamp": format_timestamp(self.timestamp)}
|
|
1347
|
+
|
|
1348
|
+
@classmethod
|
|
1349
|
+
def from_json_value(cls, body: dict[str, Any]) -> DebugRestartedMessage:
|
|
1350
|
+
"""Read ``debug-restarted`` from its body."""
|
|
1351
|
+
return cls(run=int(body["run"]), timestamp=parse_timestamp(str(body["timestamp"])))
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
@dataclass(frozen=True, slots=True)
|
|
1355
|
+
class ResultMessage(ExecutorMessage):
|
|
1356
|
+
"""Executor to parent, terminal: the entry point completed."""
|
|
1357
|
+
|
|
1358
|
+
type_name: ClassVar[str] = "result"
|
|
1359
|
+
|
|
1360
|
+
outputs: Any | None = None
|
|
1361
|
+
metrics: Any | None = None
|
|
1362
|
+
|
|
1363
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
1364
|
+
"""Ordered body of ``result``."""
|
|
1365
|
+
return omit_none({"outputs": self.outputs, "metrics": self.metrics})
|
|
1366
|
+
|
|
1367
|
+
@classmethod
|
|
1368
|
+
def from_json_value(cls, body: dict[str, Any]) -> ResultMessage:
|
|
1369
|
+
"""Read ``result`` from its body."""
|
|
1370
|
+
return cls(outputs=body.get("outputs"), metrics=body.get("metrics"))
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
@dataclass(frozen=True, slots=True)
|
|
1374
|
+
class CanceledMessage(ExecutorMessage):
|
|
1375
|
+
"""Executor to parent, terminal: the run ended after a stop request."""
|
|
1376
|
+
|
|
1377
|
+
type_name: ClassVar[str] = "canceled"
|
|
1378
|
+
|
|
1379
|
+
reason: str
|
|
1380
|
+
|
|
1381
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
1382
|
+
"""Ordered body of ``canceled``."""
|
|
1383
|
+
return {"reason": self.reason}
|
|
1384
|
+
|
|
1385
|
+
@classmethod
|
|
1386
|
+
def from_json_value(cls, body: dict[str, Any]) -> CanceledMessage:
|
|
1387
|
+
"""Read ``canceled`` from its body."""
|
|
1388
|
+
return cls(reason=str(body["reason"]))
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
@dataclass(frozen=True, slots=True)
|
|
1392
|
+
class FaultMessage(ExecutorMessage):
|
|
1393
|
+
"""Executor to parent, terminal (or the answer to a failed prepare): the run failed."""
|
|
1394
|
+
|
|
1395
|
+
type_name: ClassVar[str] = "fault"
|
|
1396
|
+
|
|
1397
|
+
fault: FaultInfo
|
|
1398
|
+
|
|
1399
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
1400
|
+
"""Ordered body of ``fault``."""
|
|
1401
|
+
return {"fault": self.fault.to_json_value()}
|
|
1402
|
+
|
|
1403
|
+
@classmethod
|
|
1404
|
+
def from_json_value(cls, body: dict[str, Any]) -> FaultMessage:
|
|
1405
|
+
"""Read ``fault`` from its body."""
|
|
1406
|
+
return cls(fault=FaultInfo.from_json_value(body["fault"]))
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
# ------------------------------------------------------ durable execution (revision 2)
|
|
1410
|
+
#
|
|
1411
|
+
# The worker implements none of these: a Python automation is a function call and nothing in it
|
|
1412
|
+
# suspends, so it offers no revision 2 capability and a conformant parent never sends one. The
|
|
1413
|
+
# models exist anyway, because decoding is how the worker answers "unsupported" instead of
|
|
1414
|
+
# "malformed", and because the vectors are the protocol: a message the SDK cannot read is a
|
|
1415
|
+
# message the SDK cannot be checked against.
|
|
1416
|
+
|
|
1417
|
+
|
|
1418
|
+
class BookmarkKinds:
|
|
1419
|
+
"""Why a run is suspended. Mirrors the .NET ``BookmarkKinds``."""
|
|
1420
|
+
|
|
1421
|
+
TIMER: Final = "Timer"
|
|
1422
|
+
HUMAN_TASK: Final = "HumanTask"
|
|
1423
|
+
EVENT: Final = "Event"
|
|
1424
|
+
|
|
1425
|
+
|
|
1426
|
+
class RuntimeHealthStatuses:
|
|
1427
|
+
"""Values of ``RuntimeHealthReportMessage.status``."""
|
|
1428
|
+
|
|
1429
|
+
HEALTHY: Final = "Healthy"
|
|
1430
|
+
DEGRADED: Final = "Degraded"
|
|
1431
|
+
UNHEALTHY: Final = "Unhealthy"
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
@dataclass(frozen=True, slots=True)
|
|
1435
|
+
class CheckpointInfo:
|
|
1436
|
+
"""A checkpoint, handed over as a file rather than as bytes on the wire.
|
|
1437
|
+
|
|
1438
|
+
A frame is capped at 16 MiB and a workflow's state is not, so the state stays in a file the
|
|
1439
|
+
parent owns and only this descriptor crosses. See ``docs/CHECKPOINT-CONTRACT.md``.
|
|
1440
|
+
"""
|
|
1441
|
+
|
|
1442
|
+
checkpoint_id: str
|
|
1443
|
+
path: str
|
|
1444
|
+
digest: str
|
|
1445
|
+
size_bytes: int
|
|
1446
|
+
runtime_provider: str
|
|
1447
|
+
runtime_provider_version: str
|
|
1448
|
+
persistence_contract_version: int
|
|
1449
|
+
package_digest: str
|
|
1450
|
+
created_at: datetime
|
|
1451
|
+
|
|
1452
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
1453
|
+
"""Ordered body of the .NET ``CheckpointInfo`` record."""
|
|
1454
|
+
return {
|
|
1455
|
+
"checkpointId": self.checkpoint_id,
|
|
1456
|
+
"path": self.path,
|
|
1457
|
+
"digest": self.digest,
|
|
1458
|
+
"sizeBytes": self.size_bytes,
|
|
1459
|
+
"runtimeProvider": self.runtime_provider,
|
|
1460
|
+
"runtimeProviderVersion": self.runtime_provider_version,
|
|
1461
|
+
"persistenceContractVersion": self.persistence_contract_version,
|
|
1462
|
+
"packageDigest": self.package_digest,
|
|
1463
|
+
"createdAt": format_timestamp(self.created_at),
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
@classmethod
|
|
1467
|
+
def from_json_value(cls, body: dict[str, Any]) -> CheckpointInfo:
|
|
1468
|
+
"""Read a checkpoint descriptor from its body."""
|
|
1469
|
+
return cls(
|
|
1470
|
+
checkpoint_id=str(body["checkpointId"]),
|
|
1471
|
+
path=str(body["path"]),
|
|
1472
|
+
digest=str(body["digest"]),
|
|
1473
|
+
size_bytes=int(body["sizeBytes"]),
|
|
1474
|
+
runtime_provider=str(body["runtimeProvider"]),
|
|
1475
|
+
runtime_provider_version=str(body["runtimeProviderVersion"]),
|
|
1476
|
+
persistence_contract_version=int(body["persistenceContractVersion"]),
|
|
1477
|
+
package_digest=str(body["packageDigest"]),
|
|
1478
|
+
created_at=parse_timestamp(body["createdAt"]),
|
|
1479
|
+
)
|
|
1480
|
+
|
|
1481
|
+
|
|
1482
|
+
@dataclass(frozen=True, slots=True)
|
|
1483
|
+
class BookmarkInfo:
|
|
1484
|
+
"""What a suspended run is waiting for. ``kind`` is one of ``BookmarkKinds``."""
|
|
1485
|
+
|
|
1486
|
+
name: str
|
|
1487
|
+
kind: str
|
|
1488
|
+
display_name: str | None = None
|
|
1489
|
+
due_at: datetime | None = None
|
|
1490
|
+
|
|
1491
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
1492
|
+
"""Ordered body of the .NET ``BookmarkInfo`` record."""
|
|
1493
|
+
return omit_none(
|
|
1494
|
+
{
|
|
1495
|
+
"name": self.name,
|
|
1496
|
+
"kind": self.kind,
|
|
1497
|
+
"displayName": self.display_name,
|
|
1498
|
+
"dueAt": _optional_timestamp(self.due_at),
|
|
1499
|
+
}
|
|
1500
|
+
)
|
|
1501
|
+
|
|
1502
|
+
@classmethod
|
|
1503
|
+
def from_json_value(cls, body: dict[str, Any]) -> BookmarkInfo:
|
|
1504
|
+
"""Read a bookmark from its body."""
|
|
1505
|
+
due_at = body.get("dueAt")
|
|
1506
|
+
|
|
1507
|
+
return cls(
|
|
1508
|
+
name=str(body["name"]),
|
|
1509
|
+
kind=str(body["kind"]),
|
|
1510
|
+
display_name=body.get("displayName"),
|
|
1511
|
+
due_at=parse_timestamp(due_at) if due_at else None,
|
|
1512
|
+
)
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
@dataclass(frozen=True, slots=True)
|
|
1516
|
+
class CheckpointMessage(ExecutorMessage):
|
|
1517
|
+
"""Parent to executor: write a checkpoint into ``checkpoint_root`` now."""
|
|
1518
|
+
|
|
1519
|
+
type_name: ClassVar[str] = "checkpoint"
|
|
1520
|
+
|
|
1521
|
+
checkpoint_root: str
|
|
1522
|
+
reason: str | None = None
|
|
1523
|
+
|
|
1524
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
1525
|
+
"""Ordered body of ``checkpoint``."""
|
|
1526
|
+
return omit_none({"checkpointRoot": self.checkpoint_root, "reason": self.reason})
|
|
1527
|
+
|
|
1528
|
+
@classmethod
|
|
1529
|
+
def from_json_value(cls, body: dict[str, Any]) -> CheckpointMessage:
|
|
1530
|
+
"""Read ``checkpoint`` from its body."""
|
|
1531
|
+
return cls(checkpoint_root=str(body["checkpointRoot"]), reason=body.get("reason"))
|
|
1532
|
+
|
|
1533
|
+
|
|
1534
|
+
@dataclass(frozen=True, slots=True)
|
|
1535
|
+
class CheckpointedMessage(ExecutorMessage):
|
|
1536
|
+
"""Executor to parent: a checkpoint was written and its digest verified by the writer."""
|
|
1537
|
+
|
|
1538
|
+
type_name: ClassVar[str] = "checkpointed"
|
|
1539
|
+
|
|
1540
|
+
checkpoint: CheckpointInfo
|
|
1541
|
+
|
|
1542
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
1543
|
+
"""Ordered body of ``checkpointed``."""
|
|
1544
|
+
return {"checkpoint": self.checkpoint.to_json_value()}
|
|
1545
|
+
|
|
1546
|
+
@classmethod
|
|
1547
|
+
def from_json_value(cls, body: dict[str, Any]) -> CheckpointedMessage:
|
|
1548
|
+
"""Read ``checkpointed`` from its body."""
|
|
1549
|
+
return cls(checkpoint=CheckpointInfo.from_json_value(body["checkpoint"]))
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
@dataclass(frozen=True, slots=True)
|
|
1553
|
+
class RestoreMessage(ExecutorMessage):
|
|
1554
|
+
"""Parent to executor: run from a checkpoint instead of from the beginning.
|
|
1555
|
+
|
|
1556
|
+
A session carries ``start`` or ``restore``, never both.
|
|
1557
|
+
"""
|
|
1558
|
+
|
|
1559
|
+
type_name: ClassVar[str] = "restore"
|
|
1560
|
+
|
|
1561
|
+
package_root: str
|
|
1562
|
+
checkpoint: CheckpointInfo
|
|
1563
|
+
context: ExecutionContextInfo
|
|
1564
|
+
environment_root: str | None = None
|
|
1565
|
+
debug: DebugOptions | None = None
|
|
1566
|
+
#: The tenant expression policy (engine ADR 0036), kept as read; Python has no expressions.
|
|
1567
|
+
expression_policy: Any | None = None
|
|
1568
|
+
|
|
1569
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
1570
|
+
"""Ordered body of ``restore``."""
|
|
1571
|
+
return omit_none(
|
|
1572
|
+
{
|
|
1573
|
+
"packageRoot": self.package_root,
|
|
1574
|
+
"environmentRoot": self.environment_root,
|
|
1575
|
+
"checkpoint": self.checkpoint.to_json_value(),
|
|
1576
|
+
"context": self.context.to_json_value(),
|
|
1577
|
+
"debug": self.debug.to_json_value() if self.debug is not None else None,
|
|
1578
|
+
"expressionPolicy": self.expression_policy,
|
|
1579
|
+
}
|
|
1580
|
+
)
|
|
1581
|
+
|
|
1582
|
+
@classmethod
|
|
1583
|
+
def from_json_value(cls, body: dict[str, Any]) -> RestoreMessage:
|
|
1584
|
+
"""Read ``restore`` from its body."""
|
|
1585
|
+
debug = body.get("debug")
|
|
1586
|
+
|
|
1587
|
+
return cls(
|
|
1588
|
+
package_root=str(body["packageRoot"]),
|
|
1589
|
+
checkpoint=CheckpointInfo.from_json_value(body["checkpoint"]),
|
|
1590
|
+
context=ExecutionContextInfo.from_json_value(body.get("context") or {}),
|
|
1591
|
+
environment_root=body.get("environmentRoot"),
|
|
1592
|
+
debug=DebugOptions.from_json_value(debug) if isinstance(debug, dict) else None,
|
|
1593
|
+
expression_policy=body.get("expressionPolicy"),
|
|
1594
|
+
)
|
|
1595
|
+
|
|
1596
|
+
|
|
1597
|
+
@dataclass(frozen=True, slots=True)
|
|
1598
|
+
class SuspendedMessage(ExecutorMessage):
|
|
1599
|
+
"""Executor to parent, terminal: the run suspended and can be resumed from the checkpoint."""
|
|
1600
|
+
|
|
1601
|
+
type_name: ClassVar[str] = "suspended"
|
|
1602
|
+
|
|
1603
|
+
checkpoint: CheckpointInfo
|
|
1604
|
+
waiting: tuple[BookmarkInfo, ...] = ()
|
|
1605
|
+
resume_at: datetime | None = None
|
|
1606
|
+
|
|
1607
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
1608
|
+
"""Ordered body of ``suspended``."""
|
|
1609
|
+
return omit_none(
|
|
1610
|
+
{
|
|
1611
|
+
"checkpoint": self.checkpoint.to_json_value(),
|
|
1612
|
+
"waiting": [bookmark.to_json_value() for bookmark in self.waiting],
|
|
1613
|
+
"resumeAt": _optional_timestamp(self.resume_at),
|
|
1614
|
+
}
|
|
1615
|
+
)
|
|
1616
|
+
|
|
1617
|
+
@classmethod
|
|
1618
|
+
def from_json_value(cls, body: dict[str, Any]) -> SuspendedMessage:
|
|
1619
|
+
"""Read ``suspended`` from its body."""
|
|
1620
|
+
resume_at = body.get("resumeAt")
|
|
1621
|
+
|
|
1622
|
+
return cls(
|
|
1623
|
+
checkpoint=CheckpointInfo.from_json_value(body["checkpoint"]),
|
|
1624
|
+
waiting=tuple(BookmarkInfo.from_json_value(item) for item in body.get("waiting") or ()),
|
|
1625
|
+
resume_at=parse_timestamp(resume_at) if resume_at else None,
|
|
1626
|
+
)
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
@dataclass(frozen=True, slots=True)
|
|
1630
|
+
class ResumeBookmarkMessage(ExecutorMessage):
|
|
1631
|
+
"""Parent to executor: deliver a value to a waiting bookmark."""
|
|
1632
|
+
|
|
1633
|
+
type_name: ClassVar[str] = "resume-bookmark"
|
|
1634
|
+
|
|
1635
|
+
bookmark_name: str
|
|
1636
|
+
value: Any | None = None
|
|
1637
|
+
|
|
1638
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
1639
|
+
"""Ordered body of ``resume-bookmark``."""
|
|
1640
|
+
return omit_none({"bookmarkName": self.bookmark_name, "value": self.value})
|
|
1641
|
+
|
|
1642
|
+
@classmethod
|
|
1643
|
+
def from_json_value(cls, body: dict[str, Any]) -> ResumeBookmarkMessage:
|
|
1644
|
+
"""Read ``resume-bookmark`` from its body."""
|
|
1645
|
+
return cls(bookmark_name=str(body["bookmarkName"]), value=body.get("value"))
|
|
1646
|
+
|
|
1647
|
+
|
|
1648
|
+
@dataclass(frozen=True, slots=True)
|
|
1649
|
+
class RuntimeHealthMessage(ExecutorMessage):
|
|
1650
|
+
"""Parent to executor: ask the runtime how it is."""
|
|
1651
|
+
|
|
1652
|
+
type_name: ClassVar[str] = "runtime-health"
|
|
1653
|
+
|
|
1654
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
1655
|
+
"""Ordered body of ``runtime-health``. It carries nothing."""
|
|
1656
|
+
return {}
|
|
1657
|
+
|
|
1658
|
+
@classmethod
|
|
1659
|
+
def from_json_value(cls, body: dict[str, Any]) -> RuntimeHealthMessage:
|
|
1660
|
+
"""Read ``runtime-health`` from its body."""
|
|
1661
|
+
return cls()
|
|
1662
|
+
|
|
1663
|
+
|
|
1664
|
+
@dataclass(frozen=True, slots=True)
|
|
1665
|
+
class RuntimeHealthReportMessage(ExecutorMessage):
|
|
1666
|
+
"""Executor to parent: the runtime's own view of its health."""
|
|
1667
|
+
|
|
1668
|
+
type_name: ClassVar[str] = "runtime-health-report"
|
|
1669
|
+
|
|
1670
|
+
status: str
|
|
1671
|
+
detail: str | None = None
|
|
1672
|
+
metrics: Any | None = None
|
|
1673
|
+
|
|
1674
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
1675
|
+
"""Ordered body of ``runtime-health-report``."""
|
|
1676
|
+
return omit_none({"status": self.status, "detail": self.detail, "metrics": self.metrics})
|
|
1677
|
+
|
|
1678
|
+
@classmethod
|
|
1679
|
+
def from_json_value(cls, body: dict[str, Any]) -> RuntimeHealthReportMessage:
|
|
1680
|
+
"""Read ``runtime-health-report`` from its body."""
|
|
1681
|
+
return cls(
|
|
1682
|
+
status=str(body["status"]),
|
|
1683
|
+
detail=body.get("detail"),
|
|
1684
|
+
metrics=body.get("metrics"),
|
|
1685
|
+
)
|
|
1686
|
+
|
|
1687
|
+
|
|
1688
|
+
@dataclass(frozen=True, slots=True)
|
|
1689
|
+
class RequestStopMessage(ExecutorMessage):
|
|
1690
|
+
"""Parent to executor: ask the automation to end on its own (capability ``should-stop``).
|
|
1691
|
+
|
|
1692
|
+
It only raises the run's stop-requested flag (:func:`velophex.should_stop`); nothing is
|
|
1693
|
+
canceled. Engine ADR 0037 section 6, contracts 0.8.16.
|
|
1694
|
+
"""
|
|
1695
|
+
|
|
1696
|
+
type_name: ClassVar[str] = "request-stop"
|
|
1697
|
+
|
|
1698
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
1699
|
+
"""Ordered body of ``request-stop``. It carries nothing."""
|
|
1700
|
+
return {}
|
|
1701
|
+
|
|
1702
|
+
@classmethod
|
|
1703
|
+
def from_json_value(cls, body: dict[str, Any]) -> RequestStopMessage:
|
|
1704
|
+
"""Read ``request-stop`` from its body."""
|
|
1705
|
+
return cls()
|
|
1706
|
+
|
|
1707
|
+
|
|
1708
|
+
class TestOutcomes:
|
|
1709
|
+
"""Values of ``TestResultMessage.outcome`` and ``TestAssertionInfo.outcome`` (an open vocabulary)."""
|
|
1710
|
+
|
|
1711
|
+
PASSED: Final = "Passed"
|
|
1712
|
+
FAILED: Final = "Failed"
|
|
1713
|
+
ERROR: Final = "Error"
|
|
1714
|
+
SKIPPED: Final = "Skipped"
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
@dataclass(frozen=True, slots=True)
|
|
1718
|
+
class TestAssertionInfo:
|
|
1719
|
+
"""One verification a test case recorded (a Verify activity)."""
|
|
1720
|
+
|
|
1721
|
+
__test__ = False # not a pytest test class
|
|
1722
|
+
|
|
1723
|
+
name: str
|
|
1724
|
+
outcome: str
|
|
1725
|
+
message: str | None = None
|
|
1726
|
+
expected: str | None = None
|
|
1727
|
+
actual: str | None = None
|
|
1728
|
+
node_id: str | None = None
|
|
1729
|
+
workflow_path: str | None = None
|
|
1730
|
+
# Contracts 0.8.31: absolute path of the PNG a failed verification saved, on the machine that ran it.
|
|
1731
|
+
screenshot_path: str | None = None
|
|
1732
|
+
|
|
1733
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
1734
|
+
"""Ordered body of the .NET ``TestAssertionInfo`` record."""
|
|
1735
|
+
return omit_none(
|
|
1736
|
+
{
|
|
1737
|
+
"name": self.name,
|
|
1738
|
+
"outcome": self.outcome,
|
|
1739
|
+
"message": self.message,
|
|
1740
|
+
"expected": self.expected,
|
|
1741
|
+
"actual": self.actual,
|
|
1742
|
+
"nodeId": self.node_id,
|
|
1743
|
+
"workflowPath": self.workflow_path,
|
|
1744
|
+
"screenshotPath": self.screenshot_path,
|
|
1745
|
+
}
|
|
1746
|
+
)
|
|
1747
|
+
|
|
1748
|
+
@classmethod
|
|
1749
|
+
def from_json_value(cls, body: dict[str, Any]) -> TestAssertionInfo:
|
|
1750
|
+
"""Read an assertion from its body."""
|
|
1751
|
+
return cls(
|
|
1752
|
+
name=str(body["name"]),
|
|
1753
|
+
outcome=str(body["outcome"]),
|
|
1754
|
+
message=body.get("message"),
|
|
1755
|
+
expected=body.get("expected"),
|
|
1756
|
+
actual=body.get("actual"),
|
|
1757
|
+
node_id=body.get("nodeId"),
|
|
1758
|
+
workflow_path=body.get("workflowPath"),
|
|
1759
|
+
screenshot_path=body.get("screenshotPath"),
|
|
1760
|
+
)
|
|
1761
|
+
|
|
1762
|
+
|
|
1763
|
+
@dataclass(frozen=True, slots=True)
|
|
1764
|
+
class TestResultMessage(ExecutorMessage):
|
|
1765
|
+
"""Executor to parent: the outcome of one run (one data row) of a test case.
|
|
1766
|
+
|
|
1767
|
+
Capability ``test-results`` (contracts 0.8.25), which the parent opts in to. Not terminal.
|
|
1768
|
+
"""
|
|
1769
|
+
|
|
1770
|
+
__test__ = False # not a pytest test class
|
|
1771
|
+
|
|
1772
|
+
type_name: ClassVar[str] = "test-result"
|
|
1773
|
+
|
|
1774
|
+
test_case_id: str
|
|
1775
|
+
outcome: str
|
|
1776
|
+
duration_milliseconds: int
|
|
1777
|
+
assertions: tuple[TestAssertionInfo, ...] = ()
|
|
1778
|
+
workflow_path: str | None = None
|
|
1779
|
+
display_name: str | None = None
|
|
1780
|
+
data_row_index: int | None = None
|
|
1781
|
+
message: str | None = None
|
|
1782
|
+
fault: FaultInfo | None = None
|
|
1783
|
+
|
|
1784
|
+
def to_json_value(self) -> dict[str, Any]:
|
|
1785
|
+
"""Ordered body of ``test-result``."""
|
|
1786
|
+
return omit_none(
|
|
1787
|
+
{
|
|
1788
|
+
"testCaseId": self.test_case_id,
|
|
1789
|
+
"outcome": self.outcome,
|
|
1790
|
+
"durationMilliseconds": self.duration_milliseconds,
|
|
1791
|
+
"assertions": [assertion.to_json_value() for assertion in self.assertions],
|
|
1792
|
+
"workflowPath": self.workflow_path,
|
|
1793
|
+
"displayName": self.display_name,
|
|
1794
|
+
"dataRowIndex": self.data_row_index,
|
|
1795
|
+
"message": self.message,
|
|
1796
|
+
"fault": None if self.fault is None else self.fault.to_json_value(),
|
|
1797
|
+
}
|
|
1798
|
+
)
|
|
1799
|
+
|
|
1800
|
+
@classmethod
|
|
1801
|
+
def from_json_value(cls, body: dict[str, Any]) -> TestResultMessage:
|
|
1802
|
+
"""Read ``test-result`` from its body."""
|
|
1803
|
+
fault = body.get("fault")
|
|
1804
|
+
row = body.get("dataRowIndex")
|
|
1805
|
+
|
|
1806
|
+
return cls(
|
|
1807
|
+
test_case_id=str(body["testCaseId"]),
|
|
1808
|
+
outcome=str(body["outcome"]),
|
|
1809
|
+
duration_milliseconds=int(body["durationMilliseconds"]),
|
|
1810
|
+
assertions=tuple(TestAssertionInfo.from_json_value(item) for item in body.get("assertions") or ()),
|
|
1811
|
+
workflow_path=body.get("workflowPath"),
|
|
1812
|
+
display_name=body.get("displayName"),
|
|
1813
|
+
data_row_index=None if row is None else int(row),
|
|
1814
|
+
message=body.get("message"),
|
|
1815
|
+
fault=None if fault is None else FaultInfo.from_json_value(fault),
|
|
1816
|
+
)
|
|
1817
|
+
|
|
1818
|
+
|
|
1819
|
+
_MESSAGE_TYPES: Final[dict[str, type[ExecutorMessage]]] = {
|
|
1820
|
+
message.type_name: message
|
|
1821
|
+
for message in (
|
|
1822
|
+
HelloMessage,
|
|
1823
|
+
HelloAckMessage,
|
|
1824
|
+
PrepareMessage,
|
|
1825
|
+
PreparedMessage,
|
|
1826
|
+
StartMessage,
|
|
1827
|
+
StopMessage,
|
|
1828
|
+
LogMessage,
|
|
1829
|
+
ProgressMessage,
|
|
1830
|
+
HeartbeatMessage,
|
|
1831
|
+
SubInvokeMessage,
|
|
1832
|
+
SubResultMessage,
|
|
1833
|
+
DebugCommandMessage,
|
|
1834
|
+
DebugPausedMessage,
|
|
1835
|
+
DebugResumedMessage,
|
|
1836
|
+
EvaluateResultMessage,
|
|
1837
|
+
DebugVariablesMessage,
|
|
1838
|
+
DebugTraceMessage,
|
|
1839
|
+
DebugRestartedMessage,
|
|
1840
|
+
ResultMessage,
|
|
1841
|
+
CanceledMessage,
|
|
1842
|
+
FaultMessage,
|
|
1843
|
+
# Revision 2: durable execution. The worker implements none of them; it decodes them so it
|
|
1844
|
+
# can answer "unsupported" rather than killing the session as a protocol violation.
|
|
1845
|
+
CheckpointMessage,
|
|
1846
|
+
CheckpointedMessage,
|
|
1847
|
+
RestoreMessage,
|
|
1848
|
+
SuspendedMessage,
|
|
1849
|
+
ResumeBookmarkMessage,
|
|
1850
|
+
RuntimeHealthMessage,
|
|
1851
|
+
RuntimeHealthReportMessage,
|
|
1852
|
+
# Capability should-stop: the worker implements it.
|
|
1853
|
+
RequestStopMessage,
|
|
1854
|
+
# Capability test-results (contracts 0.8.25): the worker does not offer it, and decodes it
|
|
1855
|
+
# only so the vectors round-trip.
|
|
1856
|
+
TestResultMessage,
|
|
1857
|
+
)
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
|
|
1861
|
+
def message_type_name(message: ExecutorMessage) -> str:
|
|
1862
|
+
"""The ``type`` discriminator of *message*."""
|
|
1863
|
+
return message.type_name
|
|
1864
|
+
|
|
1865
|
+
|
|
1866
|
+
def encode_message(message: ExecutorMessage) -> bytes:
|
|
1867
|
+
"""Serialize *message* as the UTF-8 envelope that becomes a frame payload."""
|
|
1868
|
+
return _json.encode_wire({"type": message.type_name, "body": message.to_json_value()})
|
|
1869
|
+
|
|
1870
|
+
|
|
1871
|
+
def decode_message(payload: bytes) -> ExecutorMessage:
|
|
1872
|
+
"""Parse a frame payload. Unknown types and malformed JSON are protocol violations."""
|
|
1873
|
+
try:
|
|
1874
|
+
envelope = _json.loads(payload)
|
|
1875
|
+
except ValueError as error:
|
|
1876
|
+
raise ExecutorProtocolException(
|
|
1877
|
+
ExecutorProtocolError.MALFORMED_MESSAGE, "The frame payload is not valid JSON."
|
|
1878
|
+
) from error
|
|
1879
|
+
|
|
1880
|
+
if (
|
|
1881
|
+
not isinstance(envelope, dict)
|
|
1882
|
+
or not isinstance(envelope.get("type"), str)
|
|
1883
|
+
or not isinstance(envelope.get("body"), dict)
|
|
1884
|
+
):
|
|
1885
|
+
raise ExecutorProtocolException(
|
|
1886
|
+
ExecutorProtocolError.MALFORMED_MESSAGE, "The frame payload is not a message envelope."
|
|
1887
|
+
)
|
|
1888
|
+
|
|
1889
|
+
name = envelope["type"]
|
|
1890
|
+
message_type = _MESSAGE_TYPES.get(name)
|
|
1891
|
+
|
|
1892
|
+
if message_type is None:
|
|
1893
|
+
raise ExecutorProtocolException(
|
|
1894
|
+
ExecutorProtocolError.UNKNOWN_MESSAGE_TYPE, f"Unknown message type '{_printable(name)}'."
|
|
1895
|
+
)
|
|
1896
|
+
|
|
1897
|
+
try:
|
|
1898
|
+
return message_type.from_json_value(envelope["body"])
|
|
1899
|
+
except NotImplementedError:
|
|
1900
|
+
raise ExecutorProtocolException(
|
|
1901
|
+
ExecutorProtocolError.UNKNOWN_MESSAGE_TYPE, f"A '{name}' message is never sent to this side."
|
|
1902
|
+
) from None
|
|
1903
|
+
except (KeyError, TypeError, ValueError) as error:
|
|
1904
|
+
raise ExecutorProtocolException(
|
|
1905
|
+
ExecutorProtocolError.MALFORMED_MESSAGE, f"The '{name}' message body is malformed."
|
|
1906
|
+
) from error
|
|
1907
|
+
|
|
1908
|
+
|
|
1909
|
+
def _printable(value: str) -> str:
|
|
1910
|
+
return "".join(character if character.isprintable() else "?" for character in value[:64])
|
|
1911
|
+
|
|
1912
|
+
|
|
1913
|
+
def select_version(offered_minimum: int, offered_maximum: int) -> int | None:
|
|
1914
|
+
"""The highest version both sides speak, or ``None`` when the ranges do not overlap."""
|
|
1915
|
+
if offered_minimum < 1 or offered_maximum < offered_minimum:
|
|
1916
|
+
return None
|
|
1917
|
+
|
|
1918
|
+
highest = min(offered_maximum, CURRENT_VERSION)
|
|
1919
|
+
lowest = max(offered_minimum, MINIMUM_VERSION)
|
|
1920
|
+
|
|
1921
|
+
return highest if highest >= lowest else None
|