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.
Files changed (43) hide show
  1. velophex/__init__.py +38 -0
  2. velophex/_json.py +297 -0
  3. velophex/agents/__init__.py +130 -0
  4. velophex/agents/_errors.py +75 -0
  5. velophex/agents/_loop.py +436 -0
  6. velophex/agents/_tools.py +168 -0
  7. velophex/agents/_tracing.py +87 -0
  8. velophex/agents/testing.py +249 -0
  9. velophex/cli/__init__.py +5 -0
  10. velophex/cli/main.py +548 -0
  11. velophex/cli/publish.py +152 -0
  12. velophex/cli/sync.py +90 -0
  13. velophex/cli/templates.py +126 -0
  14. velophex/contracts/__init__.py +103 -0
  15. velophex/contracts/_model.py +26 -0
  16. velophex/contracts/agents.py +110 -0
  17. velophex/contracts/diagnostics.py +157 -0
  18. velophex/contracts/executor.py +1921 -0
  19. velophex/contracts/package.py +391 -0
  20. velophex/contracts/project.py +1008 -0
  21. velophex/contracts/runtimes.py +226 -0
  22. velophex/contracts/signin.py +53 -0
  23. velophex/contracts/validation.py +158 -0
  24. velophex/entrypoints.py +329 -0
  25. velophex/errors.py +44 -0
  26. velophex/packaging.py +465 -0
  27. velophex/py.typed +0 -0
  28. velophex/runtime/__init__.py +471 -0
  29. velophex/runtime/_session.py +192 -0
  30. velophex/runtime/client.py +957 -0
  31. velophex/runtime/models.py +326 -0
  32. velophex/signing.py +425 -0
  33. velophex/worker/__init__.py +27 -0
  34. velophex/worker/__main__.py +118 -0
  35. velophex/worker/framing.py +248 -0
  36. velophex/worker/loader.py +105 -0
  37. velophex/worker/outbox.py +147 -0
  38. velophex/worker/session.py +519 -0
  39. velophex-0.2.0.dist-info/METADATA +313 -0
  40. velophex-0.2.0.dist-info/RECORD +43 -0
  41. velophex-0.2.0.dist-info/WHEEL +4 -0
  42. velophex-0.2.0.dist-info/entry_points.txt +2 -0
  43. 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