archastro-sdk 0.1.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.
- archastro/__init__.py +4 -0
- archastro/phx_channel/__init__.py +11 -0
- archastro/phx_channel/channel.py +290 -0
- archastro/phx_channel/harness.py +120 -0
- archastro/phx_channel/socket.py +280 -0
- archastro/platform/__init__.py +11 -0
- archastro/platform/auth.py +187 -0
- archastro/platform/channels/__init__.py +7 -0
- archastro/platform/channels/api_activity_feed_channel.py +71 -0
- archastro/platform/channels/api_chat_channel.py +573 -0
- archastro/platform/channels/api_object_channel.py +76 -0
- archastro/platform/client.py +135 -0
- archastro/platform/runtime/__init__.py +0 -0
- archastro/platform/runtime/http_client.py +210 -0
- archastro/platform/types/__init__.py +16 -0
- archastro/platform/types/ai.py +56 -0
- archastro/platform/types/artifacts.py +31 -0
- archastro/platform/types/automations.py +23 -0
- archastro/platform/types/chat.py +77 -0
- archastro/platform/types/common.py +1179 -0
- archastro/platform/types/config.py +77 -0
- archastro/platform/types/image.py +17 -0
- archastro/platform/types/invites.py +19 -0
- archastro/platform/types/notifications.py +37 -0
- archastro/platform/types/teams.py +60 -0
- archastro/platform/types/threads.py +64 -0
- archastro/platform/types/users.py +32 -0
- archastro/platform/v1/__init__.py +79 -0
- archastro/platform/v1/resources/__init__.py +39 -0
- archastro/platform/v1/resources/activity_feed.py +83 -0
- archastro/platform/v1/resources/agent_computers.py +39 -0
- archastro/platform/v1/resources/agent_env_vars.py +33 -0
- archastro/platform/v1/resources/agent_health_actions.py +22 -0
- archastro/platform/v1/resources/agent_installations.py +82 -0
- archastro/platform/v1/resources/agent_routine_runs.py +35 -0
- archastro/platform/v1/resources/agent_routines.py +176 -0
- archastro/platform/v1/resources/agent_sessions.py +89 -0
- archastro/platform/v1/resources/agent_skills.py +62 -0
- archastro/platform/v1/resources/agent_tools.py +63 -0
- archastro/platform/v1/resources/agents.py +565 -0
- archastro/platform/v1/resources/ai.py +149 -0
- archastro/platform/v1/resources/artifacts.py +42 -0
- archastro/platform/v1/resources/automation_runs.py +22 -0
- archastro/platform/v1/resources/automations.py +27 -0
- archastro/platform/v1/resources/bug_reports.py +25 -0
- archastro/platform/v1/resources/config.py +427 -0
- archastro/platform/v1/resources/custom_objects.py +116 -0
- archastro/platform/v1/resources/files.py +17 -0
- archastro/platform/v1/resources/installation_sources.py +15 -0
- archastro/platform/v1/resources/invites.py +22 -0
- archastro/platform/v1/resources/knowledge_documents.py +93 -0
- archastro/platform/v1/resources/knowledge_sources.py +143 -0
- archastro/platform/v1/resources/kv.py +63 -0
- archastro/platform/v1/resources/notification_preferences.py +35 -0
- archastro/platform/v1/resources/notifications.py +80 -0
- archastro/platform/v1/resources/orgs.py +42 -0
- archastro/platform/v1/resources/solution_categories.py +41 -0
- archastro/platform/v1/resources/solution_tags.py +38 -0
- archastro/platform/v1/resources/solutions.py +242 -0
- archastro/platform/v1/resources/team_memberships.py +38 -0
- archastro/platform/v1/resources/teams.py +719 -0
- archastro/platform/v1/resources/thread_messages.py +86 -0
- archastro/platform/v1/resources/threads.py +337 -0
- archastro/platform/v1/resources/users.py +405 -0
- archastro_sdk-0.1.1.dist-info/METADATA +139 -0
- archastro_sdk-0.1.1.dist-info/RECORD +68 -0
- archastro_sdk-0.1.1.dist-info/WHEEL +4 -0
- archastro_sdk-0.1.1.dist-info/licenses/LICENSE +21 -0
archastro/__init__.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Phoenix Channel — manages a single topic subscription, push/reply, and events.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import logging
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .socket import Socket
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("archastro.phx_channel")
|
|
16
|
+
|
|
17
|
+
DEFAULT_TIMEOUT_S = 10
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Channel:
|
|
21
|
+
"""
|
|
22
|
+
Represents a single Phoenix Channel subscription on a topic.
|
|
23
|
+
|
|
24
|
+
Created via ``socket.channel("topic", params)``.
|
|
25
|
+
Call ``await channel.join()`` to subscribe.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, socket: Socket, topic: str, params: dict[str, Any]):
|
|
29
|
+
self._socket = socket
|
|
30
|
+
self._topic = topic
|
|
31
|
+
self._params = params
|
|
32
|
+
|
|
33
|
+
self._state: str = "closed" # closed | joining | joined | leaving | errored
|
|
34
|
+
self._join_ref: str | None = None
|
|
35
|
+
self._join_push_ref: str | None = None
|
|
36
|
+
|
|
37
|
+
self._event_handlers: dict[str, list[Callable[..., Any]]] = {}
|
|
38
|
+
self._pending_replies: dict[str, asyncio.Future[dict[str, Any]]] = {}
|
|
39
|
+
self._push_buffer: list[tuple[str, Any, asyncio.Future[dict[str, Any]]]] = []
|
|
40
|
+
# Inbound pushes that arrived before any handler was registered.
|
|
41
|
+
# Server scenarios can `autoPush` in response to a join frame, and
|
|
42
|
+
# that push can land on the asyncio queue before the caller has
|
|
43
|
+
# had a chance to register a handler post-join. We replay these
|
|
44
|
+
# to the first matching handler registered within the window.
|
|
45
|
+
# Bounded so a forgotten handler doesn't leak unbounded memory.
|
|
46
|
+
self._pending_pushes: dict[str, list[Any]] = {}
|
|
47
|
+
self._pending_pushes_cap: int = 32
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def topic(self) -> str:
|
|
51
|
+
return self._topic
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def state(self) -> str:
|
|
55
|
+
return self._state
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def is_joined(self) -> bool:
|
|
59
|
+
return self._state == "joined"
|
|
60
|
+
|
|
61
|
+
# ─── Join / Leave ─────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
async def join(
|
|
64
|
+
self,
|
|
65
|
+
payload: dict[str, Any] | None = None,
|
|
66
|
+
*,
|
|
67
|
+
timeout: float = DEFAULT_TIMEOUT_S,
|
|
68
|
+
) -> dict[str, Any]:
|
|
69
|
+
"""
|
|
70
|
+
Join the channel. Returns the join response payload.
|
|
71
|
+
|
|
72
|
+
``payload`` overrides the params passed to ``socket.channel(topic, params)``
|
|
73
|
+
when provided — generated SDK channel classes pass the join payload
|
|
74
|
+
directly here, while hand-written callers typically rely on the params
|
|
75
|
+
captured at channel creation time.
|
|
76
|
+
|
|
77
|
+
Raises ``TimeoutError`` if the server doesn't reply within ``timeout``.
|
|
78
|
+
Raises ``ChannelError`` if the server rejects the join.
|
|
79
|
+
Raises ``ChannelError`` if the channel is already joined — callers
|
|
80
|
+
should ``leave()`` before re-joining. Silently returning an empty
|
|
81
|
+
response would let a double-call (e.g. two ``LiveDocChannel.join_*``
|
|
82
|
+
invocations reusing the same underlying channel) masquerade as a
|
|
83
|
+
successful join while dropping the real server response.
|
|
84
|
+
"""
|
|
85
|
+
if self._state == "joined":
|
|
86
|
+
raise ChannelError(f"Channel {self._topic} is already joined; call leave() first")
|
|
87
|
+
|
|
88
|
+
self._state = "joining"
|
|
89
|
+
ref = self._socket._make_ref()
|
|
90
|
+
self._join_ref = ref
|
|
91
|
+
self._join_push_ref = ref
|
|
92
|
+
|
|
93
|
+
future: asyncio.Future[dict[str, Any]] = asyncio.get_event_loop().create_future()
|
|
94
|
+
self._pending_replies[ref] = future
|
|
95
|
+
|
|
96
|
+
params = self._params if payload is None else payload
|
|
97
|
+
await self._socket._send(ref, ref, self._topic, "phx_join", params)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
result = await asyncio.wait_for(future, timeout=timeout)
|
|
101
|
+
except TimeoutError:
|
|
102
|
+
self._pending_replies.pop(ref, None)
|
|
103
|
+
self._state = "errored"
|
|
104
|
+
raise TimeoutError(f"Join timed out for {self._topic}") from None
|
|
105
|
+
|
|
106
|
+
status = result.get("status")
|
|
107
|
+
if status == "ok":
|
|
108
|
+
self._state = "joined"
|
|
109
|
+
logger.info("Joined %s", self._topic)
|
|
110
|
+
# Flush buffered pushes
|
|
111
|
+
await self._flush_push_buffer()
|
|
112
|
+
return result.get("response", {})
|
|
113
|
+
else:
|
|
114
|
+
self._state = "errored"
|
|
115
|
+
raise ChannelError(f"Join rejected for {self._topic}: {result.get('response', {})}")
|
|
116
|
+
|
|
117
|
+
async def leave(self, timeout: float = DEFAULT_TIMEOUT_S) -> None:
|
|
118
|
+
"""Leave the channel."""
|
|
119
|
+
if self._state == "closed":
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
self._state = "leaving"
|
|
123
|
+
ref = self._socket._make_ref()
|
|
124
|
+
future: asyncio.Future[dict[str, Any]] = asyncio.get_event_loop().create_future()
|
|
125
|
+
self._pending_replies[ref] = future
|
|
126
|
+
|
|
127
|
+
await self._socket._send(self._join_ref, ref, self._topic, "phx_leave", {})
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
await asyncio.wait_for(future, timeout=timeout)
|
|
131
|
+
except TimeoutError:
|
|
132
|
+
pass # Leave is best-effort
|
|
133
|
+
finally:
|
|
134
|
+
self._state = "closed"
|
|
135
|
+
self._join_ref = None
|
|
136
|
+
self._socket._remove_channel(self._topic)
|
|
137
|
+
logger.info("Left %s", self._topic)
|
|
138
|
+
|
|
139
|
+
async def _rejoin(self) -> None:
|
|
140
|
+
"""Rejoin after reconnection."""
|
|
141
|
+
if self._state in ("closed", "leaving"):
|
|
142
|
+
return
|
|
143
|
+
self._state = "closed"
|
|
144
|
+
self._join_ref = None
|
|
145
|
+
try:
|
|
146
|
+
await self.join()
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
logger.warning("Rejoin failed for %s: %s", self._topic, exc)
|
|
149
|
+
self._state = "errored"
|
|
150
|
+
|
|
151
|
+
# ─── Push / Reply ─────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
async def push(
|
|
154
|
+
self, event: str, payload: Any = None, timeout: float = DEFAULT_TIMEOUT_S
|
|
155
|
+
) -> dict[str, Any]:
|
|
156
|
+
"""
|
|
157
|
+
Push an event to the channel and wait for a reply.
|
|
158
|
+
|
|
159
|
+
Returns the reply ``{"status": ..., "response": ...}``.
|
|
160
|
+
Raises ``TimeoutError`` if no reply within ``timeout``.
|
|
161
|
+
"""
|
|
162
|
+
if payload is None:
|
|
163
|
+
payload = {}
|
|
164
|
+
|
|
165
|
+
if self._state != "joined":
|
|
166
|
+
# Buffer the push for when we rejoin
|
|
167
|
+
future: asyncio.Future[dict[str, Any]] = asyncio.get_event_loop().create_future()
|
|
168
|
+
self._push_buffer.append((event, payload, future))
|
|
169
|
+
return await asyncio.wait_for(future, timeout=timeout)
|
|
170
|
+
|
|
171
|
+
return await self._do_push(event, payload, timeout)
|
|
172
|
+
|
|
173
|
+
async def _do_push(self, event: str, payload: Any, timeout: float) -> dict[str, Any]:
|
|
174
|
+
ref = self._socket._make_ref()
|
|
175
|
+
future: asyncio.Future[dict[str, Any]] = asyncio.get_event_loop().create_future()
|
|
176
|
+
self._pending_replies[ref] = future
|
|
177
|
+
|
|
178
|
+
await self._socket._send(self._join_ref, ref, self._topic, event, payload)
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
result = await asyncio.wait_for(future, timeout=timeout)
|
|
182
|
+
except TimeoutError:
|
|
183
|
+
self._pending_replies.pop(ref, None)
|
|
184
|
+
raise TimeoutError(f"Push '{event}' timed out on {self._topic}") from None
|
|
185
|
+
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
async def _flush_push_buffer(self) -> None:
|
|
189
|
+
buffer = self._push_buffer[:]
|
|
190
|
+
self._push_buffer.clear()
|
|
191
|
+
for event, payload, future in buffer:
|
|
192
|
+
try:
|
|
193
|
+
result = await self._do_push(event, payload, DEFAULT_TIMEOUT_S)
|
|
194
|
+
if not future.done():
|
|
195
|
+
future.set_result(result)
|
|
196
|
+
except Exception as exc:
|
|
197
|
+
if not future.done():
|
|
198
|
+
future.set_exception(exc)
|
|
199
|
+
|
|
200
|
+
# ─── Event handlers ──────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
def on(self, event: str, callback: Callable[..., Any]) -> Callable[[], None]:
|
|
203
|
+
"""
|
|
204
|
+
Register a callback for a channel event.
|
|
205
|
+
|
|
206
|
+
Returns an unsubscribe function. If pushes for this event already
|
|
207
|
+
arrived before any handler was registered (e.g. server-side
|
|
208
|
+
autoPush fired on join), they're replayed to this callback in
|
|
209
|
+
arrival order so the caller can't miss them due to scheduling.
|
|
210
|
+
"""
|
|
211
|
+
handlers = self._event_handlers.setdefault(event, [])
|
|
212
|
+
handlers.append(callback)
|
|
213
|
+
|
|
214
|
+
pending = self._pending_pushes.pop(event, None)
|
|
215
|
+
if pending:
|
|
216
|
+
for payload in pending:
|
|
217
|
+
try:
|
|
218
|
+
callback(payload)
|
|
219
|
+
except Exception:
|
|
220
|
+
logger.exception("Error in handler for %s:%s (replayed)", self._topic, event)
|
|
221
|
+
|
|
222
|
+
def unsubscribe() -> None:
|
|
223
|
+
handlers.remove(callback)
|
|
224
|
+
|
|
225
|
+
return unsubscribe
|
|
226
|
+
|
|
227
|
+
# ─── Internal message dispatch ────────────────────────────
|
|
228
|
+
|
|
229
|
+
def _on_message(
|
|
230
|
+
self,
|
|
231
|
+
join_ref: str | None,
|
|
232
|
+
ref: str | None,
|
|
233
|
+
event: str,
|
|
234
|
+
payload: Any,
|
|
235
|
+
) -> None:
|
|
236
|
+
# Ignore messages from stale join
|
|
237
|
+
if join_ref is not None and join_ref != self._join_ref:
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
if event == "phx_reply":
|
|
241
|
+
self._handle_reply(ref, payload)
|
|
242
|
+
elif event == "phx_close":
|
|
243
|
+
self._handle_close()
|
|
244
|
+
elif event == "phx_error":
|
|
245
|
+
self._handle_error(payload)
|
|
246
|
+
else:
|
|
247
|
+
# User event — dispatch to handlers, or buffer if none yet.
|
|
248
|
+
handlers = self._event_handlers.get(event)
|
|
249
|
+
if handlers:
|
|
250
|
+
for handler in handlers:
|
|
251
|
+
try:
|
|
252
|
+
handler(payload)
|
|
253
|
+
except Exception:
|
|
254
|
+
logger.exception("Error in handler for %s:%s", self._topic, event)
|
|
255
|
+
else:
|
|
256
|
+
buf = self._pending_pushes.setdefault(event, [])
|
|
257
|
+
if len(buf) < self._pending_pushes_cap:
|
|
258
|
+
buf.append(payload)
|
|
259
|
+
else:
|
|
260
|
+
# Drop the oldest to bound memory for orphan events.
|
|
261
|
+
buf.pop(0)
|
|
262
|
+
buf.append(payload)
|
|
263
|
+
|
|
264
|
+
def _handle_reply(self, ref: str | None, payload: Any) -> None:
|
|
265
|
+
if ref and ref in self._pending_replies:
|
|
266
|
+
future = self._pending_replies.pop(ref)
|
|
267
|
+
if not future.done():
|
|
268
|
+
future.set_result(payload)
|
|
269
|
+
|
|
270
|
+
def _handle_close(self) -> None:
|
|
271
|
+
logger.info("Channel closed: %s", self._topic)
|
|
272
|
+
self._state = "closed"
|
|
273
|
+
self._trigger_event("phx_close", {})
|
|
274
|
+
|
|
275
|
+
def _handle_error(self, payload: Any) -> None:
|
|
276
|
+
logger.warning("Channel error: %s — %s", self._topic, payload)
|
|
277
|
+
self._state = "errored"
|
|
278
|
+
self._trigger_event("phx_error", payload)
|
|
279
|
+
|
|
280
|
+
def _trigger_event(self, event: str, payload: Any) -> None:
|
|
281
|
+
handlers = self._event_handlers.get(event, [])
|
|
282
|
+
for handler in handlers:
|
|
283
|
+
try:
|
|
284
|
+
handler(payload)
|
|
285
|
+
except Exception:
|
|
286
|
+
logger.exception("Error in handler for %s:%s", self._topic, event)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class ChannelError(Exception):
|
|
290
|
+
"""Raised when a channel operation fails (e.g., join rejected)."""
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HarnessServiceClient — Python counterpart to the TypeScript client in
|
|
3
|
+
``@archastro/sdk-generator/channel-harness``.
|
|
4
|
+
|
|
5
|
+
The channel-harness service exposes two surfaces that this client speaks to:
|
|
6
|
+
|
|
7
|
+
1. A **WebSocket** endpoint carrying the real Phoenix channel protocol.
|
|
8
|
+
Generated SDKs connect here to exercise their channel classes
|
|
9
|
+
end-to-end.
|
|
10
|
+
|
|
11
|
+
2. An **HTTP control** endpoint for scenario management and observation
|
|
12
|
+
queries. Callers POST a JSON scenario to set up per-topic behavior,
|
|
13
|
+
GET observations to assert on what the SDK actually put on the wire,
|
|
14
|
+
and POST ``/reset`` between tests to drop state.
|
|
15
|
+
|
|
16
|
+
There is no in-process shortcut. Tests drive the SAME service the TypeScript
|
|
17
|
+
tests (or any other language) drive — the only differences are which SDK is
|
|
18
|
+
under test and which client library is opening the socket.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from .socket import Socket
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class HarnessServiceClient:
|
|
31
|
+
"""Thin client over the harness service's HTTP + WebSocket surfaces.
|
|
32
|
+
|
|
33
|
+
One instance per test is typical::
|
|
34
|
+
|
|
35
|
+
client = HarnessServiceClient(ws_url, control_url)
|
|
36
|
+
try:
|
|
37
|
+
await client.reset()
|
|
38
|
+
await client.register_scenario({
|
|
39
|
+
"topic": "doc:doc_42",
|
|
40
|
+
"onJoin": [{"type": "autoReply"}],
|
|
41
|
+
})
|
|
42
|
+
socket = await client.open_socket()
|
|
43
|
+
channel = await LiveDocChannel.join_document(
|
|
44
|
+
socket, "doc_42", user_id="user_1"
|
|
45
|
+
)
|
|
46
|
+
assert channel.join_response is not None
|
|
47
|
+
finally:
|
|
48
|
+
await client.close()
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, ws_url: str, control_url: str, *, request_timeout: float = 5.0):
|
|
52
|
+
self.ws_url = ws_url
|
|
53
|
+
self.control_url = control_url.rstrip("/")
|
|
54
|
+
self._http = httpx.AsyncClient(timeout=request_timeout)
|
|
55
|
+
self._sockets: list[Socket] = []
|
|
56
|
+
|
|
57
|
+
async def close(self) -> None:
|
|
58
|
+
"""Disconnect every socket opened through this client + close HTTP."""
|
|
59
|
+
for s in self._sockets:
|
|
60
|
+
try:
|
|
61
|
+
await s.disconnect()
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
self._sockets.clear()
|
|
65
|
+
await self._http.aclose()
|
|
66
|
+
|
|
67
|
+
# ─── HTTP control ────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
async def reset(self) -> None:
|
|
70
|
+
"""Clear every scenario, observation, and handler error on the server."""
|
|
71
|
+
r = await self._http.post(f"{self.control_url}/reset")
|
|
72
|
+
r.raise_for_status()
|
|
73
|
+
|
|
74
|
+
async def register_scenario(self, scenario: dict[str, Any]) -> None:
|
|
75
|
+
"""Register a scenario for an exact topic.
|
|
76
|
+
|
|
77
|
+
See the TS ``ScenarioRequest`` / ``ScenarioAction`` types for the
|
|
78
|
+
JSON shape — the server validates and returns 400 on invalid bodies.
|
|
79
|
+
"""
|
|
80
|
+
r = await self._http.post(f"{self.control_url}/scenarios", json=scenario)
|
|
81
|
+
if r.status_code != 201:
|
|
82
|
+
raise HarnessServiceError(f"register_scenario failed: {r.status_code} {r.text}")
|
|
83
|
+
|
|
84
|
+
async def observations(
|
|
85
|
+
self, topic: str | None = None, event: str | None = None
|
|
86
|
+
) -> list[dict[str, Any]]:
|
|
87
|
+
"""Fetch inbound frames the server validated, optionally filtered."""
|
|
88
|
+
params: dict[str, str] = {}
|
|
89
|
+
if topic is not None:
|
|
90
|
+
params["topic"] = topic
|
|
91
|
+
if event is not None:
|
|
92
|
+
params["event"] = event
|
|
93
|
+
r = await self._http.get(f"{self.control_url}/observations", params=params)
|
|
94
|
+
r.raise_for_status()
|
|
95
|
+
return r.json()
|
|
96
|
+
|
|
97
|
+
async def handler_errors(self) -> list[dict[str, Any]]:
|
|
98
|
+
"""Fetch scenario handler errors recorded by the server."""
|
|
99
|
+
r = await self._http.get(f"{self.control_url}/handler-errors")
|
|
100
|
+
r.raise_for_status()
|
|
101
|
+
return r.json()
|
|
102
|
+
|
|
103
|
+
# ─── Socket lifecycle ────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
async def open_socket(self, *, auto_reconnect: bool = False) -> Socket:
|
|
106
|
+
"""Open a fresh Phoenix socket to the service's WebSocket endpoint.
|
|
107
|
+
|
|
108
|
+
Every call produces a new connection — the generated SDK receives
|
|
109
|
+
the same ``Socket`` it would talk to in production. ``auto_reconnect``
|
|
110
|
+
defaults to ``False`` so a disconnected test surfaces immediately
|
|
111
|
+
rather than silently retrying in the background.
|
|
112
|
+
"""
|
|
113
|
+
socket = Socket(self.ws_url, auto_reconnect=auto_reconnect)
|
|
114
|
+
await socket.connect()
|
|
115
|
+
self._sockets.append(socket)
|
|
116
|
+
return socket
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class HarnessServiceError(RuntimeError):
|
|
120
|
+
"""Raised when the harness service returns a non-success HTTP status."""
|