cloudagent-daemon 0.2.1__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.
@@ -0,0 +1,289 @@
1
+ """Outbound frame queue for the cloudagent daemon.
2
+
3
+ Serializes all outgoing daemon→server sends through a lock (concurrent
4
+ spawn supervisors may call simultaneously — must avoid interleaved JSON
5
+ on the wire). Also provides high-level helpers for each outbound frame
6
+ type. The actual wire (WebSocket vs HTTP-POST) is hidden behind a
7
+ ``Transport`` protocol — see ``transport.py``.
8
+
9
+ Spec §4.2 / §4.3.
10
+
11
+ Design note on pending_tool_calls ownership
12
+ -------------------------------------------
13
+ OutboundQueue creates and owns ``pending_tool_calls``. FrameDispatcher
14
+ accepts it via its ``tool_result_pending`` constructor parameter so that
15
+ incoming ``tool_result`` frames resolve the same futures that
16
+ ``send_tool_call`` is awaiting. Caller wires them together::
17
+
18
+ oq = OutboundQueue(transport)
19
+ dispatcher = FrameDispatcher(..., tool_result_pending=oq.pending_tool_calls)
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import logging
25
+ from datetime import UTC, datetime
26
+ from typing import TYPE_CHECKING
27
+ from uuid import uuid4
28
+
29
+ if TYPE_CHECKING:
30
+ from cloudagent_daemon.transport import Transport
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ def _new_frame_id() -> str:
36
+ return f"frm_{uuid4().hex[:16]}"
37
+
38
+
39
+ def _new_request_id() -> str:
40
+ return f"req_{uuid4().hex}"
41
+
42
+
43
+ def _now_iso() -> str:
44
+ return datetime.now(UTC).isoformat()
45
+
46
+
47
+ class OutboundQueue:
48
+ """Serialize outbound frames and provide frame-type helpers.
49
+
50
+ Parameters
51
+ ----------
52
+ transport:
53
+ A ``Transport`` (WS or HTTP) — anything with
54
+ ``async send_frame(frame: dict)``. ``None`` is allowed for
55
+ construction-before-connect; sends will raise until ``transport``
56
+ is rebound. Tests may pass a fake with a ``send`` method via the
57
+ legacy positional ``ws=`` keyword for back-compat (deprecated;
58
+ prefer ``transport=``).
59
+ default_tool_call_timeout:
60
+ Seconds to wait for a ``tool_result`` before returning a synthetic
61
+ ``SERVER_TIMEOUT`` response.
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ transport: "Transport | None" = None,
67
+ *,
68
+ ws: object = None, # legacy alias — accepts old WSClient or test
69
+ # FakeWS objects with .send(frame)
70
+ default_tool_call_timeout: float = 30.0,
71
+ pending_tool_calls: dict[str, asyncio.Future] | None = None,
72
+ ) -> None:
73
+ # Back-compat: pre-1j call-sites and unit tests pass a WSClient or
74
+ # FakeWS as the first positional arg. Detect that shape (has
75
+ # ``send`` but no ``send_frame``) and wrap it with the legacy
76
+ # adapter so existing tests keep passing without edits.
77
+ if transport is not None and not hasattr(transport, "send_frame"):
78
+ if hasattr(transport, "send"):
79
+ transport = _LegacyWSAdapter(transport)
80
+ else:
81
+ # Anything else is a programming error — let it fail loudly
82
+ # downstream rather than silently produce a broken queue.
83
+ pass
84
+ if transport is None and ws is not None:
85
+ transport = _LegacyWSAdapter(ws)
86
+ self.transport: "Transport | None" = transport
87
+ self.default_tool_call_timeout = default_tool_call_timeout
88
+ self._send_lock = asyncio.Lock()
89
+ # Shared with FrameDispatcher — see module docstring. Accepting it as
90
+ # a constructor parameter lets ``main_loop.run_daemon`` build the dict
91
+ # once and pass the same instance to both the dispatcher and the
92
+ # outbound queue, avoiding a "rebind on connect" dance for the dict.
93
+ self.pending_tool_calls: dict[str, asyncio.Future] = (
94
+ pending_tool_calls if pending_tool_calls is not None else {}
95
+ )
96
+
97
+ @property
98
+ def ws(self) -> object:
99
+ """Legacy alias kept for ``main_loop._on_connect`` compatibility.
100
+
101
+ Reads return the underlying WSClient when running on the legacy
102
+ WS transport; ``None`` for HTTP/SSE or before connect. Writes
103
+ rebind the transport via ``_LegacyWSAdapter`` so existing
104
+ ``outbound.ws = client`` call-sites keep working unchanged.
105
+ """
106
+ if isinstance(self.transport, _LegacyWSAdapter):
107
+ return self.transport._ws
108
+ return None
109
+
110
+ @ws.setter
111
+ def ws(self, value: object) -> None:
112
+ if value is None:
113
+ self.transport = None
114
+ return
115
+ # If the caller hands us something that already looks like a
116
+ # Transport (has ``send_frame``), use it directly. Otherwise
117
+ # adapt a WSClient-shaped object.
118
+ if hasattr(value, "send_frame"):
119
+ self.transport = value # type: ignore[assignment]
120
+ else:
121
+ self.transport = _LegacyWSAdapter(value)
122
+
123
+ # ------------------------------------------------------------------
124
+ # High-level helpers
125
+ # ------------------------------------------------------------------
126
+
127
+ async def send_ack(
128
+ self,
129
+ seq: int | None,
130
+ message_id: str,
131
+ status: str,
132
+ retry_after_ms: int | None = None,
133
+ ) -> None:
134
+ """Send a ``delivery_ack`` frame.
135
+
136
+ *seq=None* for non-deliver acks; the field is omitted from the
137
+ payload in that case (backend accepts missing seq).
138
+ """
139
+ payload: dict = {"message_id": message_id, "status": status}
140
+ if seq is not None:
141
+ payload["seq"] = seq
142
+ if retry_after_ms is not None:
143
+ payload["retry_after_ms"] = retry_after_ms
144
+
145
+ await self.send_raw({
146
+ "v": 1,
147
+ "type": "delivery_ack",
148
+ "id": _new_frame_id(),
149
+ "ts": _now_iso(),
150
+ "payload": payload,
151
+ })
152
+
153
+ async def send_agent_status(
154
+ self,
155
+ agent_id: str,
156
+ status: str,
157
+ exit_code: int | None = None,
158
+ ) -> None:
159
+ """Send an ``agent_status`` frame."""
160
+ payload: dict = {"agent_id": agent_id, "status": status}
161
+ if exit_code is not None:
162
+ payload["exit_code"] = exit_code
163
+
164
+ await self.send_raw({
165
+ "v": 1,
166
+ "type": "agent_status",
167
+ "id": _new_frame_id(),
168
+ "ts": _now_iso(),
169
+ "payload": payload,
170
+ })
171
+
172
+ async def send_agent_session_start(
173
+ self,
174
+ agent_id: str,
175
+ claude_session_id: str,
176
+ workdir_path: str | None = None,
177
+ ) -> None:
178
+ """Send an ``agent_session_start`` frame."""
179
+ payload: dict = {"agent_id": agent_id, "claude_session_id": claude_session_id}
180
+ if workdir_path:
181
+ payload["workdir_path"] = workdir_path
182
+
183
+ await self.send_raw({
184
+ "v": 1,
185
+ "type": "agent_session_start",
186
+ "id": _new_frame_id(),
187
+ "ts": _now_iso(),
188
+ "payload": payload,
189
+ })
190
+
191
+ async def send_tool_call(
192
+ self,
193
+ agent_id: str,
194
+ session_id: str | None,
195
+ tool_name: str,
196
+ args: dict,
197
+ *,
198
+ timeout: float | None = None,
199
+ ) -> dict:
200
+ """Send a ``tool_call`` frame and await the matching ``tool_result``.
201
+
202
+ Returns the tool_result payload dict on success.
203
+ On timeout returns a synthetic error::
204
+
205
+ {"request_id": rid, "ok": False, "error": {"code": "SERVER_TIMEOUT"}}
206
+ """
207
+ effective_timeout = timeout if timeout is not None else self.default_tool_call_timeout
208
+ rid = _new_request_id()
209
+
210
+ loop = asyncio.get_running_loop()
211
+ fut: asyncio.Future = loop.create_future()
212
+ self.pending_tool_calls[rid] = fut
213
+
214
+ # 1k #55 — fine-grained observability for the multi-tool wake bug.
215
+ # Logs at every state transition let us reconstruct timing of
216
+ # concurrent calls (rid is unique so log lines are unambiguous).
217
+ logger.info(
218
+ "outbound_queue: send_tool_call ENTER tool=%s agent=%s rid=%s "
219
+ "pending_count=%d",
220
+ tool_name, agent_id, rid, len(self.pending_tool_calls),
221
+ )
222
+
223
+ await self.send_raw({
224
+ "v": 1,
225
+ "type": "tool_call",
226
+ "id": _new_frame_id(),
227
+ "ts": _now_iso(),
228
+ "payload": {
229
+ "request_id": rid,
230
+ "agent_id": agent_id,
231
+ "session_id": session_id,
232
+ "tool_name": tool_name,
233
+ "args": args,
234
+ },
235
+ })
236
+ logger.info(
237
+ "outbound_queue: send_tool_call SENT rid=%s — awaiting tool_result",
238
+ rid,
239
+ )
240
+
241
+ try:
242
+ result = await asyncio.wait_for(fut, timeout=effective_timeout)
243
+ self.pending_tool_calls.pop(rid, None)
244
+ logger.info(
245
+ "outbound_queue: send_tool_call RESOLVED rid=%s ok=%s "
246
+ "pending_count=%d",
247
+ rid, result.get("ok"), len(self.pending_tool_calls),
248
+ )
249
+ return result
250
+ except asyncio.TimeoutError:
251
+ self.pending_tool_calls.pop(rid, None)
252
+ logger.warning(
253
+ "outbound_queue: send_tool_call TIMEOUT rid=%s after %.1fs "
254
+ "pending_count=%d",
255
+ rid, effective_timeout, len(self.pending_tool_calls),
256
+ )
257
+ return {
258
+ "request_id": rid,
259
+ "ok": False,
260
+ "error": {"code": "SERVER_TIMEOUT"},
261
+ }
262
+
263
+ async def send_raw(self, frame: dict) -> None:
264
+ """Lock-protected raw send. All other helpers call this."""
265
+ async with self._send_lock:
266
+ if self.transport is None:
267
+ # Daemon constructed the outbound queue but is not yet
268
+ # connected (or just disconnected). Surface a clear error
269
+ # rather than ``AttributeError`` on .send.
270
+ raise RuntimeError("OutboundQueue.transport is not bound")
271
+ await self.transport.send_frame(frame)
272
+
273
+
274
+ class _LegacyWSAdapter:
275
+ """Adapt a WSClient (or test fake with ``async send(frame)``) to Transport.
276
+
277
+ This keeps the unit tests in ``test_outbound_queue.py`` and the legacy
278
+ ``main_loop`` WS path running while new code uses Transport directly.
279
+ """
280
+
281
+ def __init__(self, ws: object) -> None:
282
+ self._ws = ws
283
+
284
+ async def send_frame(self, frame: dict) -> None:
285
+ await self._ws.send(frame) # type: ignore[attr-defined]
286
+
287
+ async def close(self) -> None:
288
+ # Lifecycle owned elsewhere — see ``WSTransport.close`` rationale.
289
+ return
@@ -0,0 +1,204 @@
1
+ """Per-profile path resolution for the cloudagent daemon.
2
+
3
+ A *profile* lets one host nurse multiple cloudagent backends in parallel
4
+ (prod, fr, staging, …) by namespacing every persistent resource the
5
+ daemon owns. The ``"default"`` profile keeps the legacy paths
6
+ byte-for-byte unchanged — that is the foundation of our zero-migration
7
+ guarantee for users already running the daemon.
8
+
9
+ Layout (M6 multi-profile proposal 2026-05-18):
10
+
11
+ default profile named profile "fr"
12
+ ───────────────── ─────────────────
13
+ ~/.cloudagent/daemon/ ~/.cloudagent/daemon/profiles/fr/
14
+ ├── config.json ├── config.json
15
+ ├── state.json ├── state.json
16
+ ├── bridge.sock ├── bridge.sock
17
+ ├── daemon.lock ├── daemon.lock
18
+ └── logs/ └── logs/
19
+
20
+ ~/.cloudagent/agents/ ~/.cloudagent/agents-fr/
21
+
22
+ launchd label: launchd label:
23
+ chat.cloudagent.daemon chat.cloudagent.daemon.fr
24
+ systemd unit: systemd unit:
25
+ cloudagent-daemon.service cloudagent-daemon-fr.service
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import re
31
+ from pathlib import Path
32
+
33
+ DEFAULT_PROFILE = "default"
34
+
35
+ # kebab-case, first char alnum, total length 1..31. Disallow ``.``, ``/``,
36
+ # ``..``, whitespace, uppercase, underscores. Constraints come from:
37
+ # - launchd label segments (RFC-1035-ish, no dots / colons in the suffix)
38
+ # - filesystem safety (no path traversal)
39
+ # - cross-platform sanity (no case-sensitivity surprises on macOS HFS+)
40
+ _PROFILE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,30}$")
41
+
42
+ # Env var name daemon spawns surface to subprocesses (mcp_bridge, future
43
+ # helpers). Keeps the active profile pinned across exec boundaries even
44
+ # when the parent never gets a chance to pass an explicit ``--profile``
45
+ # flag. ``cli.py`` reads it as fallback for ``agent register`` /
46
+ # ``agent unregister`` etc.
47
+ ENV_VAR = "CLOUDAGENT_DAEMON_PROFILE"
48
+
49
+
50
+ def validate_profile_name(name: str) -> str:
51
+ """Return *name* if it is safe to use; raise ``ValueError`` otherwise.
52
+
53
+ ``"default"`` is reserved and always valid (treated as the legacy /
54
+ unnamed profile). Anything else must match ``[a-z0-9][a-z0-9-]{0,30}``.
55
+ """
56
+ if name == DEFAULT_PROFILE:
57
+ return name
58
+ if not isinstance(name, str) or not _PROFILE_NAME_RE.fullmatch(name):
59
+ raise ValueError(
60
+ f"invalid profile name: {name!r} — must match "
61
+ f"[a-z0-9][a-z0-9-]{{0,30}} (e.g. 'fr', 'prod', 'staging-eu')"
62
+ )
63
+ return name
64
+
65
+
66
+ def resolve_profile(name: str | None) -> str:
67
+ """Resolve the effective profile name.
68
+
69
+ Order: explicit *name* > ``CLOUDAGENT_DAEMON_PROFILE`` env > default.
70
+ Always validated. Empty string / ``None`` collapse to default.
71
+ """
72
+ if name:
73
+ return validate_profile_name(name)
74
+ env = os.environ.get(ENV_VAR, "").strip()
75
+ if env:
76
+ return validate_profile_name(env)
77
+ return DEFAULT_PROFILE
78
+
79
+
80
+ # ---------- Filesystem layout ----------
81
+
82
+
83
+ def _home() -> Path:
84
+ """Return ``$HOME``. Indirection so tests can monkeypatch ``HOME``."""
85
+ return Path.home()
86
+
87
+
88
+ def profile_base_dir(profile: str | None = None) -> Path:
89
+ """Per-profile root: ``~/.cloudagent/daemon`` (default) or
90
+ ``~/.cloudagent/daemon/profiles/<name>`` (named)."""
91
+ p = resolve_profile(profile)
92
+ base = _home() / ".cloudagent" / "daemon"
93
+ if p == DEFAULT_PROFILE:
94
+ return base
95
+ return base / "profiles" / p
96
+
97
+
98
+ def profile_config_path(profile: str | None = None) -> Path:
99
+ return profile_base_dir(profile) / "config.json"
100
+
101
+
102
+ def profile_state_path(profile: str | None = None) -> Path:
103
+ return profile_base_dir(profile) / "state.json"
104
+
105
+
106
+ def profile_socket_path(profile: str | None = None) -> Path:
107
+ return profile_base_dir(profile) / "bridge.sock"
108
+
109
+
110
+ def profile_lock_path(profile: str | None = None) -> Path:
111
+ return profile_base_dir(profile) / "daemon.lock"
112
+
113
+
114
+ def profile_pid_path(profile: str | None = None) -> Path:
115
+ return profile_base_dir(profile) / "daemon.pid"
116
+
117
+
118
+ def profile_log_dir(profile: str | None = None) -> Path:
119
+ return profile_base_dir(profile) / "logs"
120
+
121
+
122
+ def profile_workspace_root(profile: str | None = None) -> Path:
123
+ """Default agent workspace root.
124
+
125
+ The default profile keeps the legacy ``~/.cloudagent/agents``;
126
+ named profiles get a sibling ``~/.cloudagent/agents-<name>`` so
127
+ file managers / terminals visually group them next to the
128
+ original tree rather than burying them under another nesting
129
+ level. Callers who want a fully-custom path keep using
130
+ ``--workspace-root`` exactly as before.
131
+ """
132
+ p = resolve_profile(profile)
133
+ if p == DEFAULT_PROFILE:
134
+ return _home() / ".cloudagent" / "agents"
135
+ return _home() / ".cloudagent" / f"agents-{p}"
136
+
137
+
138
+ # ---------- Service identifiers ----------
139
+
140
+
141
+ def profile_launchd_label(profile: str | None = None) -> str:
142
+ """launchd Label. Default profile keeps the legacy ``chat.cloudagent.daemon``;
143
+ named profiles append ``.<name>`` so multiple plists coexist."""
144
+ p = resolve_profile(profile)
145
+ base = "chat.cloudagent.daemon"
146
+ return base if p == DEFAULT_PROFILE else f"{base}.{p}"
147
+
148
+
149
+ def profile_systemd_unit_name(profile: str | None = None) -> str:
150
+ """systemd unit filename. Default keeps legacy ``cloudagent-daemon.service``;
151
+ named profiles use a literal ``cloudagent-daemon-<name>.service``."""
152
+ p = resolve_profile(profile)
153
+ return (
154
+ "cloudagent-daemon.service"
155
+ if p == DEFAULT_PROFILE
156
+ else f"cloudagent-daemon-{p}.service"
157
+ )
158
+
159
+
160
+ # ---------- Discovery ----------
161
+
162
+
163
+ def list_installed_profiles() -> list[str]:
164
+ """Enumerate profiles that have a ``config.json`` on disk.
165
+
166
+ Default is included whenever its config exists; named profiles
167
+ show up under ``~/.cloudagent/daemon/profiles/<name>/``. Returns
168
+ a sorted list with ``default`` first (when present) so output
169
+ order is stable for ``list-profiles``.
170
+ """
171
+ found: list[str] = []
172
+ if profile_config_path(DEFAULT_PROFILE).exists():
173
+ found.append(DEFAULT_PROFILE)
174
+ profiles_dir = _home() / ".cloudagent" / "daemon" / "profiles"
175
+ if profiles_dir.is_dir():
176
+ for child in sorted(profiles_dir.iterdir()):
177
+ if not child.is_dir():
178
+ continue
179
+ try:
180
+ validate_profile_name(child.name)
181
+ except ValueError:
182
+ continue
183
+ if (child / "config.json").exists():
184
+ found.append(child.name)
185
+ return found
186
+
187
+
188
+ __all__ = [
189
+ "DEFAULT_PROFILE",
190
+ "ENV_VAR",
191
+ "validate_profile_name",
192
+ "resolve_profile",
193
+ "profile_base_dir",
194
+ "profile_config_path",
195
+ "profile_state_path",
196
+ "profile_socket_path",
197
+ "profile_lock_path",
198
+ "profile_pid_path",
199
+ "profile_log_dir",
200
+ "profile_workspace_root",
201
+ "profile_launchd_label",
202
+ "profile_systemd_unit_name",
203
+ "list_installed_profiles",
204
+ ]
@@ -0,0 +1,141 @@
1
+ """WebSocket frame schemas — vendored from backend/src/cloudagent/services/daemon_protocol.py.
2
+
3
+ Spec: docs/superpowers/specs/2026-05-08-local-daemon-runtime-design.md §4
4
+
5
+ Vendored (not imported from backend) so daemon_cli can be installed
6
+ independently. If a schema changes on the backend, update this file
7
+ in lock-step. A bigger Phase 2 refactor moves these to a shared package.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from datetime import datetime
12
+ from typing import Any, Literal
13
+
14
+ from pydantic import BaseModel
15
+
16
+ # ---- Payload models ----
17
+
18
+ class HelloAgentEntry(BaseModel):
19
+ agent_id: str
20
+ claude_session_id: str | None = None
21
+ runtime_config: dict[str, Any] | None = None
22
+
23
+
24
+ class HelloPayload(BaseModel):
25
+ host_id: str
26
+ agents: list[HelloAgentEntry]
27
+
28
+
29
+ class ReadyPayload(BaseModel):
30
+ daemon_ver: str
31
+ runtimes: list[dict[str, Any]]
32
+ hostname: str
33
+ os: str
34
+
35
+
36
+ class AssignPayload(BaseModel):
37
+ agent_id: str
38
+ claude_session_id: str | None = None
39
+
40
+
41
+ class UnassignPayload(BaseModel):
42
+ agent_id: str
43
+ reason: str
44
+
45
+
46
+ class DeliverPayload(BaseModel):
47
+ agent_id: str
48
+ session_id: str
49
+ message_id: str
50
+ kind: str
51
+ body: str
52
+ sender: str
53
+ target: str
54
+
55
+
56
+ class DeliveryAckPayload(BaseModel):
57
+ seq: int
58
+ message_id: str
59
+ status: Literal["received", "started", "failed", "queue_full"]
60
+ retry_after_ms: int | None = None
61
+
62
+
63
+ class ToolCallPayload(BaseModel):
64
+ request_id: str
65
+ agent_id: str
66
+ session_id: str | None = None
67
+ tool_name: str
68
+ args: dict[str, Any]
69
+
70
+
71
+ class ToolResultPayload(BaseModel):
72
+ request_id: str
73
+ ok: bool
74
+ data: dict[str, Any] | None = None
75
+ error: dict[str, Any] | None = None
76
+
77
+
78
+ class AgentSessionStartPayload(BaseModel):
79
+ agent_id: str
80
+ claude_session_id: str
81
+ workdir_path: str | None = None
82
+
83
+
84
+ class AgentStatusPayload(BaseModel):
85
+ agent_id: str
86
+ status: Literal["running", "idle", "crashed", "quarantined", "backpressure_dropped"]
87
+ exit_code: int | None = None
88
+ error: str | None = None
89
+
90
+
91
+ class PingPayload(BaseModel):
92
+ pass # empty payload
93
+
94
+
95
+ class PongPayload(BaseModel):
96
+ pass
97
+
98
+
99
+ class ByePayload(BaseModel):
100
+ reason: str
101
+
102
+
103
+ # ---- Frame envelope ----
104
+
105
+ FrameType = Literal[
106
+ "hello", "ready", "assign", "unassign",
107
+ "deliver", "delivery_ack",
108
+ "tool_call", "tool_result",
109
+ "agent_session_start", "agent_status",
110
+ "ping", "pong", "bye",
111
+ ]
112
+
113
+ _PAYLOAD_TYPES = {
114
+ "hello": HelloPayload, "ready": ReadyPayload,
115
+ "assign": AssignPayload, "unassign": UnassignPayload,
116
+ "deliver": DeliverPayload, "delivery_ack": DeliveryAckPayload,
117
+ "tool_call": ToolCallPayload, "tool_result": ToolResultPayload,
118
+ "agent_session_start": AgentSessionStartPayload,
119
+ "agent_status": AgentStatusPayload,
120
+ "ping": PingPayload, "pong": PongPayload, "bye": ByePayload,
121
+ }
122
+
123
+
124
+ class Frame(BaseModel):
125
+ v: Literal[1]
126
+ type: FrameType
127
+ id: str
128
+ ts: datetime
129
+ seq: int | None = None
130
+ payload: Any # validated post-init
131
+
132
+ def model_post_init(self, _ctx: Any) -> None:
133
+ cls = _PAYLOAD_TYPES.get(self.type)
134
+ if cls is None:
135
+ raise ValueError(f"unknown frame type: {self.type}")
136
+ if not isinstance(self.payload, cls):
137
+ self.payload = cls.model_validate(self.payload)
138
+
139
+
140
+ def parse_frame(raw: dict[str, Any]) -> Frame:
141
+ return Frame.model_validate(raw)