zjcode 0.0.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.
Files changed (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,411 @@
1
+ """External event ingress for the Textual app.
2
+
3
+ Exposes a small `EventSource` protocol plus a Unix-domain-socket implementation
4
+ that lets local processes push commands, prompts, and signals into a running
5
+ session over a newline-delimited JSON wire protocol.
6
+
7
+ !!! warning "Experimental"
8
+
9
+ The wire format and configuration env vars may change without semver
10
+ guarantees while this surface stabilizes.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import contextlib
17
+ import json
18
+ import logging
19
+ import os
20
+ import stat
21
+ import tempfile
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import TYPE_CHECKING, Literal, Protocol, get_args
25
+
26
+ from deepagents_code.command_registry import BypassTier
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import Awaitable, Callable
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ ExternalEventKind = Literal["command", "prompt", "signal"]
34
+ """Top-level event kinds carried by the wire protocol."""
35
+
36
+ ExternalSignal = Literal["interrupt", "force-clear"]
37
+ """Closed vocabulary of `kind="signal"` payloads accepted by the listener."""
38
+
39
+ EventSink = "Callable[[ExternalEvent], Awaitable[None]]"
40
+ """Type alias for the async callback that receives parsed events."""
41
+
42
+ _VALID_KINDS: frozenset[str] = frozenset(get_args(ExternalEventKind))
43
+ _VALID_SIGNALS: frozenset[str] = frozenset(get_args(ExternalSignal))
44
+
45
+ _ACK = b'{"ok":true}\n'
46
+ """Wire-level acknowledgement returned for an accepted event."""
47
+
48
+ _MAX_LINE_BYTES = 64 * 1024
49
+ """Per-line read limit; an oversized line is rejected with a NACK."""
50
+
51
+ _CLIENT_IDLE_TIMEOUT_SECONDS = 60.0
52
+ """Maximum idle time on a client connection before the server closes it."""
53
+
54
+
55
+ @dataclass(frozen=True, slots=True, kw_only=True)
56
+ class ExternalEvent:
57
+ """A transport-independent event delivered from outside the TUI."""
58
+
59
+ kind: ExternalEventKind
60
+ payload: str
61
+ source: str
62
+ bypass: BypassTier = BypassTier.QUEUED
63
+ correlation_id: str | None = None
64
+
65
+ def __post_init__(self) -> None:
66
+ """Validate invariants for direct construction.
67
+
68
+ Raises:
69
+ ValueError: If `kind` is not a known kind, the payload is empty
70
+ or whitespace-only, or the kind is `"signal"` but the payload
71
+ is not a recognized signal name.
72
+ """
73
+ if self.kind not in _VALID_KINDS:
74
+ msg = f"Unknown external event kind: {self.kind!r}"
75
+ raise ValueError(msg)
76
+ if not self.payload or not self.payload.strip():
77
+ msg = "External event payload must be a non-empty string"
78
+ raise ValueError(msg)
79
+ if self.kind == "signal" and self.payload.strip().lower() not in _VALID_SIGNALS:
80
+ msg = (
81
+ f"Unknown external signal: {self.payload!r}; "
82
+ f"expected one of {sorted(_VALID_SIGNALS)}"
83
+ )
84
+ raise ValueError(msg)
85
+
86
+
87
+ class EventSource(Protocol):
88
+ """Source of external events for the Textual app.
89
+
90
+ Implementations must be safe to `stop()` even when `start()` failed
91
+ partway through; the app always invokes `stop()` from a `finally` block.
92
+ """
93
+
94
+ async def start(
95
+ self,
96
+ sink: Callable[[ExternalEvent], Awaitable[None]],
97
+ ) -> None:
98
+ """Start forwarding events to `sink`.
99
+
100
+ Args:
101
+ sink: Async callback that receives parsed external events.
102
+ """
103
+
104
+ async def serve_forever(self) -> None:
105
+ """Park until the source is cancelled or its transport dies.
106
+
107
+ Implementations should re-raise `CancelledError` and propagate
108
+ unexpected transport-layer failures so the lifecycle owner can
109
+ notice and surface them.
110
+ """
111
+
112
+ async def stop(self) -> None:
113
+ """Stop forwarding events and release transport resources."""
114
+
115
+
116
+ class UnixSocketEventSource:
117
+ """Line-delimited JSON event source over a local Unix domain socket.
118
+
119
+ The listener creates its parent directory with mode `0o700` and binds the
120
+ socket inside it under a transient `umask(0o077)` so the socket inherits
121
+ `0o600` from the moment of `bind()`. Stale sockets at the configured path
122
+ are removed on start, but only after a `stat` confirms the path is a
123
+ socket — a regular file or directory at that path is left untouched and
124
+ causes start to fail loudly.
125
+ """
126
+
127
+ def __init__(self, path: Path | None = None) -> None:
128
+ """Create a Unix-socket event source.
129
+
130
+ Args:
131
+ path: Socket path. When omitted, a per-process path under the
132
+ runtime or temp directory is used.
133
+ """
134
+ self.path = path or default_unix_socket_path()
135
+ self._server: asyncio.AbstractServer | None = None
136
+ self._sink: Callable[[ExternalEvent], Awaitable[None]] | None = None
137
+
138
+ async def start(
139
+ self,
140
+ sink: Callable[[ExternalEvent], Awaitable[None]],
141
+ ) -> None:
142
+ """Start listening for newline-delimited JSON events.
143
+
144
+ Args:
145
+ sink: Async callback invoked with each decoded event.
146
+
147
+ Raises:
148
+ RuntimeError: If `start()` has already been called on this
149
+ instance without a subsequent `stop()`.
150
+ FileExistsError: If the socket path is occupied by a non-socket
151
+ filesystem entry.
152
+ OSError: If the socket cannot be bound (e.g. permission denied,
153
+ path too long).
154
+ """ # noqa: DOC502 # FileExistsError/OSError propagate from helpers
155
+ if self._server is not None:
156
+ msg = "UnixSocketEventSource is already started"
157
+ raise RuntimeError(msg)
158
+
159
+ self._sink = sink
160
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
161
+ with contextlib.suppress(FileNotFoundError):
162
+ _unlink_existing_socket(self.path)
163
+
164
+ previous_umask = os.umask(0o077)
165
+ try:
166
+ self._server = await asyncio.start_unix_server(
167
+ self._handle_client,
168
+ path=str(self.path),
169
+ limit=_MAX_LINE_BYTES,
170
+ )
171
+ finally:
172
+ os.umask(previous_umask)
173
+
174
+ # Defense in depth: even if `start_unix_server` somehow ignored the
175
+ # umask (different libc, mocked socket layer), force-tighten the mode.
176
+ with contextlib.suppress(OSError):
177
+ self.path.chmod(0o600)
178
+ logger.debug("External event listener bound at %s", self.path)
179
+
180
+ async def serve_forever(self) -> None:
181
+ """Park until the underlying server is cancelled or fails.
182
+
183
+ `asyncio.start_unix_server` already begins accepting connections,
184
+ so this delegates to the server's own `serve_forever`. A fatal
185
+ error inside the accept loop propagates here, letting the
186
+ lifecycle owner notice and react.
187
+
188
+ Raises:
189
+ RuntimeError: If invoked before `start()`.
190
+ """
191
+ if self._server is None:
192
+ msg = "UnixSocketEventSource.serve_forever called before start()"
193
+ raise RuntimeError(msg)
194
+ await self._server.serve_forever()
195
+
196
+ async def stop(self) -> None:
197
+ """Close the listener and remove the socket path.
198
+
199
+ Idempotent: safe to call after a failed or never-started `start()`.
200
+ """
201
+ server = self._server
202
+ self._server = None
203
+ if server is not None:
204
+ server.close()
205
+ with contextlib.suppress(Exception):
206
+ await server.wait_closed()
207
+ try:
208
+ _unlink_existing_socket(self.path)
209
+ except FileNotFoundError:
210
+ pass
211
+ except FileExistsError as exc:
212
+ logger.warning(
213
+ "Leaving non-socket entry at %s during shutdown: %s",
214
+ self.path,
215
+ exc,
216
+ )
217
+ except OSError as exc:
218
+ logger.warning("Failed to unlink event socket %s: %s", self.path, exc)
219
+
220
+ async def _handle_client(
221
+ self,
222
+ reader: asyncio.StreamReader,
223
+ writer: asyncio.StreamWriter,
224
+ ) -> None:
225
+ r"""Read newline-delimited JSON envelopes from one client.
226
+
227
+ Each accepted line yields an event to the configured sink and is
228
+ acked with `{"ok":true}\n` (plus `correlation_id` when present).
229
+ Rejected lines (oversized, malformed, sink unconfigured, sink
230
+ raised) are answered with `{"ok":false,"error":...}\n` and the
231
+ loop continues so a single bad caller cannot kill the connection.
232
+ """
233
+ peer = writer.get_extra_info("peername") or "<unknown>"
234
+ logger.debug("External event client connected: %s", peer)
235
+ try:
236
+ while True:
237
+ try:
238
+ line = await asyncio.wait_for(
239
+ reader.readline(),
240
+ timeout=_CLIENT_IDLE_TIMEOUT_SECONDS,
241
+ )
242
+ except TimeoutError:
243
+ logger.debug("Closing idle external event client %s", peer)
244
+ break
245
+ except (ValueError, asyncio.LimitOverrunError) as exc:
246
+ logger.warning("External event line exceeded read limit: %s", exc)
247
+ await _write_nack(writer, "line exceeds read limit", None)
248
+ break
249
+
250
+ if not line:
251
+ break
252
+
253
+ await self._handle_one_line(line, writer)
254
+ except (BrokenPipeError, ConnectionResetError) as exc:
255
+ logger.debug("External event client %s disconnected: %s", peer, exc)
256
+ finally:
257
+ writer.close()
258
+ with contextlib.suppress(Exception):
259
+ await writer.wait_closed()
260
+ logger.debug("External event client closed: %s", peer)
261
+
262
+ async def _handle_one_line(
263
+ self,
264
+ line: bytes,
265
+ writer: asyncio.StreamWriter,
266
+ ) -> None:
267
+ """Decode and dispatch one envelope, replying with ACK or NACK."""
268
+ correlation_id: str | None = None
269
+ try:
270
+ event = decode_external_event(line, source=f"unix:{self.path}")
271
+ except (ValueError, TypeError) as exc:
272
+ logger.warning("Rejected malformed external event: %s", exc)
273
+ with contextlib.suppress(ValueError, TypeError, json.JSONDecodeError):
274
+ parsed = json.loads(line)
275
+ if isinstance(parsed, dict):
276
+ candidate = parsed.get("correlation_id")
277
+ if isinstance(candidate, str):
278
+ correlation_id = candidate
279
+ await _write_nack(writer, str(exc), correlation_id)
280
+ return
281
+
282
+ correlation_id = event.correlation_id
283
+
284
+ if self._sink is None:
285
+ logger.warning("External event arrived before sink was set; dropping")
286
+ await _write_nack(writer, "listener not ready", correlation_id)
287
+ return
288
+
289
+ try:
290
+ await self._sink(event)
291
+ except Exception as exc:
292
+ logger.exception("External event sink raised")
293
+ await _write_nack(writer, f"sink failed: {exc}", correlation_id)
294
+ return
295
+
296
+ await _write_ack(writer, correlation_id)
297
+
298
+
299
+ async def _write_ack(
300
+ writer: asyncio.StreamWriter,
301
+ correlation_id: str | None,
302
+ ) -> None:
303
+ """Write the success acknowledgement frame, echoing `correlation_id`."""
304
+ if correlation_id is None:
305
+ writer.write(_ACK)
306
+ else:
307
+ body = json.dumps({"ok": True, "correlation_id": correlation_id})
308
+ writer.write(body.encode("utf-8") + b"\n")
309
+ with contextlib.suppress(BrokenPipeError, ConnectionResetError):
310
+ await writer.drain()
311
+
312
+
313
+ async def _write_nack(
314
+ writer: asyncio.StreamWriter,
315
+ error: str,
316
+ correlation_id: str | None,
317
+ ) -> None:
318
+ """Write a failure response frame; never raises on a closed socket."""
319
+ body: dict[str, object] = {"ok": False, "error": error}
320
+ if correlation_id is not None:
321
+ body["correlation_id"] = correlation_id
322
+ try:
323
+ writer.write(json.dumps(body).encode("utf-8") + b"\n")
324
+ await writer.drain()
325
+ except (BrokenPipeError, ConnectionResetError):
326
+ pass
327
+
328
+
329
+ def default_unix_socket_path() -> Path:
330
+ """Return the default per-process Unix socket path.
331
+
332
+ Prefers `XDG_RUNTIME_DIR` (per-user, tmpfs-backed, auto-cleaned on
333
+ logout) and falls back to the system temp dir when the runtime
334
+ directory is unset.
335
+ """
336
+ root = os.environ.get("XDG_RUNTIME_DIR")
337
+ base = Path(root) if root else Path(tempfile.gettempdir())
338
+ return base / "deepagents" / f"events-{os.getpid()}.sock"
339
+
340
+
341
+ def _unlink_existing_socket(path: Path) -> None:
342
+ """Remove a stale Unix socket without touching other filesystem entries.
343
+
344
+ Args:
345
+ path: Candidate socket path to remove.
346
+
347
+ Raises:
348
+ FileNotFoundError: If `path` does not exist.
349
+ FileExistsError: If `path` exists but is not a Unix socket.
350
+ OSError: If the entry exists but cannot be removed.
351
+ """ # noqa: DOC502 # FileNotFoundError/OSError propagate from stat/unlink
352
+ info = path.stat(follow_symlinks=False)
353
+ if not stat.S_ISSOCK(info.st_mode):
354
+ msg = f"Refusing to remove non-socket external event path: {path}"
355
+ raise FileExistsError(msg)
356
+ path.unlink()
357
+
358
+
359
+ def decode_external_event(data: bytes, *, source: str) -> ExternalEvent:
360
+ """Decode one newline-delimited JSON external event.
361
+
362
+ Args:
363
+ data: Raw JSON line.
364
+ source: Transport-specific source label attached to the event.
365
+
366
+ Returns:
367
+ Parsed external event.
368
+
369
+ Raises:
370
+ TypeError: If the envelope is not a JSON object.
371
+ ValueError: If any envelope field is missing, of the wrong type, or
372
+ otherwise invalid.
373
+ """
374
+ try:
375
+ raw = json.loads(data)
376
+ except json.JSONDecodeError as exc:
377
+ msg = f"External event must be valid JSON: {exc.msg}"
378
+ raise ValueError(msg) from exc
379
+ if not isinstance(raw, dict):
380
+ msg = "External event must be a JSON object"
381
+ raise TypeError(msg)
382
+
383
+ kind = raw.get("kind")
384
+ if kind not in _VALID_KINDS:
385
+ msg = f"External event kind must be one of {sorted(_VALID_KINDS)}; got {kind!r}"
386
+ raise ValueError(msg)
387
+
388
+ payload = raw.get("payload")
389
+ if not isinstance(payload, str) or not payload.strip():
390
+ msg = "External event payload must be a non-empty string"
391
+ raise ValueError(msg)
392
+
393
+ bypass = raw.get("bypass", BypassTier.QUEUED.value)
394
+ try:
395
+ bypass_tier = BypassTier(bypass)
396
+ except ValueError as exc:
397
+ msg = f"External event bypass must be a valid bypass tier: {exc}"
398
+ raise ValueError(msg) from exc
399
+
400
+ correlation_id = raw.get("correlation_id")
401
+ if correlation_id is not None and not isinstance(correlation_id, str):
402
+ msg = "External event correlation_id must be a string when present"
403
+ raise ValueError(msg)
404
+
405
+ return ExternalEvent(
406
+ kind=kind,
407
+ payload=payload,
408
+ source=source,
409
+ bypass=bypass_tier,
410
+ correlation_id=correlation_id,
411
+ )