aisolate-client 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/PKG-INFO +2 -2
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/README.md +1 -1
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/pyproject.toml +1 -1
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/client/gateway_client.py +200 -28
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/client/models.py +22 -21
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/client/sandbox.py +148 -2
- aisolate_client-0.3.0/src/aisolate/client/tests/integration/conftest.py +101 -0
- aisolate_client-0.3.0/src/aisolate/client/tests/test_gateway_client_demux.py +75 -0
- aisolate_client-0.3.0/src/aisolate/client/tests/test_sandbox_workspace_prefix.py +47 -0
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/common/codec.py +95 -9
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/common/models.py +32 -1
- aisolate_client-0.3.0/src/aisolate/common/tests/test_codec_channel.py +96 -0
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate_client.egg-info/PKG-INFO +2 -2
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate_client.egg-info/SOURCES.txt +3 -0
- aisolate_client-0.2.0/src/aisolate/client/tests/integration/conftest.py +0 -81
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/setup.cfg +0 -0
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/__init__.py +0 -0
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/client/__init__.py +0 -0
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/client/exception.py +0 -0
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/client/remote_executer.py +0 -0
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/client/sandbox_manager.py +0 -0
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/client/tests/__init__.py +0 -0
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/client/tests/integration/__init__.py +0 -0
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/client/tests/integration/test_gateway_client.py +0 -0
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/client/tests/integration/test_sandbox.py +0 -0
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/common/__init__.py +0 -0
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/common/errors.py +0 -0
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/common/tests/__init__.py +0 -0
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/common/tests/fixtures.py +0 -0
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/common/tests/utils.py +0 -0
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate_client.egg-info/dependency_links.txt +0 -0
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate_client.egg-info/requires.txt +0 -0
- {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate_client.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aisolate-client
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Client library for sandbox environments
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -16,7 +16,7 @@ Client library for sandbox environments
|
|
|
16
16
|
## Local Installation
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
|
-
pip install path/to/aisolate_client-0.
|
|
19
|
+
pip install path/to/aisolate_client-0.3.0-py3-none-any.whl
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
## Usage
|
|
@@ -13,13 +13,22 @@ from typing import (
|
|
|
13
13
|
TypeVar,
|
|
14
14
|
AsyncContextManager,
|
|
15
15
|
)
|
|
16
|
-
from urllib.parse import urljoin
|
|
16
|
+
from urllib.parse import quote, urljoin
|
|
17
17
|
|
|
18
18
|
import httpx
|
|
19
19
|
from websockets.exceptions import ConnectionClosed
|
|
20
20
|
from websockets.asyncio.client import connect as ws_connect, ClientConnection
|
|
21
21
|
|
|
22
|
-
from aisolate.common.codec import
|
|
22
|
+
from aisolate.common.codec import (
|
|
23
|
+
MAX_MULTIPLEX_FRAME_BYTES,
|
|
24
|
+
decode_frame,
|
|
25
|
+
decode_multiplex_frame,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
#: Per-sandbox bound on the client's live change feed. A chatty (or hostile)
|
|
29
|
+
#: sandbox can only fill its own queue; drop-oldest on overflow keeps the shared
|
|
30
|
+
#: client's memory bounded, and the /workspace/changes resync recovers the gap.
|
|
31
|
+
CHANGES_QUEUE_MAXSIZE = 1024
|
|
23
32
|
from aisolate.common.models import KernelMessage, KernelExecutionCompleted
|
|
24
33
|
from .exception import (
|
|
25
34
|
SandboxAPIError,
|
|
@@ -60,6 +69,7 @@ class GatewayClient:
|
|
|
60
69
|
api_key: Optional[str] = None,
|
|
61
70
|
*,
|
|
62
71
|
timeout: float = 60.0,
|
|
72
|
+
connect_timeout: Optional[float] = None,
|
|
63
73
|
session: Optional[httpx.AsyncClient] = None,
|
|
64
74
|
ws_connect: Optional[
|
|
65
75
|
Callable[..., AsyncContextManager[ClientConnection]]
|
|
@@ -67,10 +77,19 @@ class GatewayClient:
|
|
|
67
77
|
ping_interval: Optional[float] = 30.0,
|
|
68
78
|
ping_timeout: Optional[float] = 3600.0,
|
|
69
79
|
) -> None:
|
|
70
|
-
"""Initializes the GatewayClient configuration.
|
|
80
|
+
"""Initializes the GatewayClient configuration.
|
|
81
|
+
|
|
82
|
+
``timeout`` is the blanket read/write/pool timeout of the httpx
|
|
83
|
+
session. ``connect_timeout`` (when set) bounds connection
|
|
84
|
+
establishment separately — including the WebSocket open — so a
|
|
85
|
+
long read timeout (e.g. a create request parked in the gateway's
|
|
86
|
+
admission queue) does not also slow down failure detection
|
|
87
|
+
against an unreachable or hung host.
|
|
88
|
+
"""
|
|
71
89
|
self.base_url = base_url.rstrip("/") + "/"
|
|
72
90
|
self.api_key = api_key
|
|
73
91
|
self._timeout = timeout
|
|
92
|
+
self._connect_timeout = connect_timeout
|
|
74
93
|
self._ping_interval = ping_interval
|
|
75
94
|
self._ping_timeout = ping_timeout
|
|
76
95
|
|
|
@@ -83,7 +102,13 @@ class GatewayClient:
|
|
|
83
102
|
# WebSocket connection state
|
|
84
103
|
self._ws: Optional[ClientConnection] = None
|
|
85
104
|
self._listener_task: Optional[asyncio.Task] = None
|
|
105
|
+
# Per-request exec response queues, keyed by (globally unique) request_id.
|
|
86
106
|
self._response_queues: Dict[str, Queue[KernelMessage]] = {}
|
|
107
|
+
# request_id → sandbox_id, so a per-sandbox failure fails only that
|
|
108
|
+
# sandbox's in-flight requests, never every co-tenant's.
|
|
109
|
+
self._request_sandbox: Dict[str, str] = {}
|
|
110
|
+
# Per-sandbox live change feeds (channel="changes"), bounded.
|
|
111
|
+
self._changes_queues: Dict[str, Queue[Any]] = {}
|
|
87
112
|
self._is_active: bool = False
|
|
88
113
|
|
|
89
114
|
async def start(self) -> "GatewayClient":
|
|
@@ -97,8 +122,13 @@ class GatewayClient:
|
|
|
97
122
|
# 1. Set up the HTTPX session for standard API calls
|
|
98
123
|
if self._session is None:
|
|
99
124
|
headers = {"Authorization": f"Token {self.api_key}"} if self.api_key else {}
|
|
125
|
+
timeout = (
|
|
126
|
+
httpx.Timeout(self._timeout, connect=self._connect_timeout)
|
|
127
|
+
if self._connect_timeout is not None
|
|
128
|
+
else self._timeout
|
|
129
|
+
)
|
|
100
130
|
self._session = httpx.AsyncClient(
|
|
101
|
-
base_url=self.base_url, timeout=
|
|
131
|
+
base_url=self.base_url, timeout=timeout, headers=headers
|
|
102
132
|
)
|
|
103
133
|
|
|
104
134
|
# 2. Establish the persistent WebSocket connection
|
|
@@ -112,17 +142,26 @@ class GatewayClient:
|
|
|
112
142
|
ws_connect_func = self._ws_connect or ws_connect
|
|
113
143
|
ws_cm = ws_connect_func(
|
|
114
144
|
ws_url,
|
|
115
|
-
open_timeout=
|
|
145
|
+
open_timeout=(
|
|
146
|
+
self._connect_timeout
|
|
147
|
+
if self._connect_timeout is not None
|
|
148
|
+
else self._timeout
|
|
149
|
+
),
|
|
116
150
|
additional_headers=headers,
|
|
117
151
|
ping_interval=self._ping_interval,
|
|
118
152
|
ping_timeout=self._ping_timeout,
|
|
119
|
-
|
|
153
|
+
# Defence in depth: the gateway already drops backend frames that
|
|
154
|
+
# would exceed this, scoped to one sandbox, so this cap is never the
|
|
155
|
+
# thing that closes the shared connection.
|
|
156
|
+
max_size=MAX_MULTIPLEX_FRAME_BYTES,
|
|
120
157
|
)
|
|
121
158
|
|
|
122
159
|
self._ws = await ws_cm.__aenter__()
|
|
123
160
|
|
|
124
161
|
# 3. Start the background task to listen for and route all incoming messages
|
|
125
162
|
self._response_queues = {}
|
|
163
|
+
self._request_sandbox = {}
|
|
164
|
+
self._changes_queues = {}
|
|
126
165
|
self._listener_task = asyncio.create_task(self._listen_for_messages())
|
|
127
166
|
|
|
128
167
|
self._is_active = True
|
|
@@ -173,50 +212,113 @@ class GatewayClient:
|
|
|
173
212
|
"""Context manager exit point. Closes the client."""
|
|
174
213
|
await self.close(exc_type, exc, tb)
|
|
175
214
|
|
|
176
|
-
|
|
215
|
+
def _changes_queue_for(self, sandbox_id: str) -> "Queue[Any]":
|
|
216
|
+
"""The bounded live-change feed for one sandbox, created on demand."""
|
|
217
|
+
q = self._changes_queues.get(sandbox_id)
|
|
218
|
+
if q is None:
|
|
219
|
+
q = asyncio.Queue(maxsize=CHANGES_QUEUE_MAXSIZE)
|
|
220
|
+
self._changes_queues[sandbox_id] = q
|
|
221
|
+
return q
|
|
222
|
+
|
|
223
|
+
def _push_change(self, sandbox_id: str, change: Any) -> None:
|
|
224
|
+
"""Enqueue a change for a sandbox, dropping the oldest if the feed is full.
|
|
225
|
+
|
|
226
|
+
Bounding per sandbox means a flooding sandbox can only fill its own
|
|
227
|
+
queue; the dropped record is recovered by the /workspace/changes resync.
|
|
177
228
|
"""
|
|
178
|
-
|
|
179
|
-
|
|
229
|
+
q = self._changes_queue_for(sandbox_id)
|
|
230
|
+
if q.full():
|
|
231
|
+
try:
|
|
232
|
+
q.get_nowait()
|
|
233
|
+
except asyncio.QueueEmpty:
|
|
234
|
+
pass
|
|
235
|
+
q.put_nowait(change)
|
|
236
|
+
|
|
237
|
+
def _fail_sandbox_requests(self, sandbox_id: str, message: str) -> None:
|
|
238
|
+
"""Fail only the in-flight exec requests belonging to one sandbox."""
|
|
239
|
+
error = SandboxConnectionError(message)
|
|
240
|
+
for request_id, sid in list(self._request_sandbox.items()):
|
|
241
|
+
if sid == sandbox_id:
|
|
242
|
+
queue = self._response_queues.get(request_id)
|
|
243
|
+
if queue is not None:
|
|
244
|
+
queue.put_nowait(error)
|
|
245
|
+
|
|
246
|
+
def _fail_all_requests(self, message: str) -> None:
|
|
247
|
+
"""Fail every in-flight request — the whole connection is gone."""
|
|
248
|
+
error = SandboxConnectionError(message)
|
|
249
|
+
for queue in self._response_queues.values():
|
|
250
|
+
queue.put_nowait(error)
|
|
251
|
+
|
|
252
|
+
def _handle_sandbox_control(self, sandbox_id: str, control: Dict[str, Any]) -> None:
|
|
253
|
+
"""Act on a gateway control signal scoped to one sandbox."""
|
|
254
|
+
if control.get("control") == "frame_dropped":
|
|
255
|
+
logger.warning(
|
|
256
|
+
"Gateway dropped a frame for sandbox %s (%s, %s bytes); "
|
|
257
|
+
"failing only its requests",
|
|
258
|
+
sandbox_id,
|
|
259
|
+
control.get("reason"),
|
|
260
|
+
control.get("size"),
|
|
261
|
+
)
|
|
262
|
+
self._fail_sandbox_requests(
|
|
263
|
+
sandbox_id, f"sandbox frame dropped: {control.get('reason')}"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
async def _listen_for_messages(self):
|
|
267
|
+
"""Route every inbound frame, isolating failures to one sandbox.
|
|
268
|
+
|
|
269
|
+
Each frame is wrapped by the gateway with its source ``sandbox_id``. A
|
|
270
|
+
frame that fails to decode fails only that sandbox's requests and the
|
|
271
|
+
listener keeps running for every co-tenant — one sandbox can never take
|
|
272
|
+
the shared connection (or another sandbox's work) down with it. Only a
|
|
273
|
+
real connection loss fails everyone.
|
|
180
274
|
"""
|
|
181
275
|
if not self._ws:
|
|
182
276
|
return
|
|
183
277
|
try:
|
|
184
278
|
async for msg in self._ws:
|
|
279
|
+
data = self._extract_message_data(msg)
|
|
280
|
+
if data is None:
|
|
281
|
+
continue
|
|
185
282
|
try:
|
|
186
|
-
|
|
187
|
-
|
|
283
|
+
sandbox_id, payload = decode_multiplex_frame(data)
|
|
284
|
+
except Exception:
|
|
285
|
+
# Can't attribute it to a sandbox — drop this frame only and
|
|
286
|
+
# keep listening; never tear the loop down over one frame.
|
|
287
|
+
logger.exception("Failed to peel multiplex envelope; dropping frame")
|
|
288
|
+
continue
|
|
289
|
+
try:
|
|
290
|
+
if isinstance(payload, dict):
|
|
291
|
+
self._handle_sandbox_control(sandbox_id, payload)
|
|
188
292
|
continue
|
|
189
|
-
request_id, kernel_msg =
|
|
190
|
-
if
|
|
293
|
+
channel, request_id, kernel_msg = decode_frame(payload)
|
|
294
|
+
if channel == "changes":
|
|
295
|
+
# Unsolicited push, correlated by cursor, not request_id.
|
|
296
|
+
self._push_change(sandbox_id, kernel_msg)
|
|
297
|
+
elif request_id and request_id in self._response_queues:
|
|
191
298
|
await self._response_queues[request_id].put(kernel_msg)
|
|
192
299
|
else:
|
|
193
300
|
logger.warning(
|
|
194
|
-
|
|
301
|
+
"Unknown request_id %s for sandbox %s",
|
|
302
|
+
request_id,
|
|
303
|
+
sandbox_id,
|
|
195
304
|
)
|
|
196
305
|
except asyncio.CancelledError:
|
|
197
|
-
# we were explicitly cancelled — just exit
|
|
198
306
|
return
|
|
199
307
|
except Exception:
|
|
200
308
|
logger.exception(
|
|
201
|
-
"Failed to decode
|
|
309
|
+
"Failed to decode/route frame; failing only sandbox %s",
|
|
310
|
+
sandbox_id,
|
|
202
311
|
)
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
error = SandboxConnectionError(
|
|
206
|
-
"Failed to decode sandbox message"
|
|
312
|
+
self._fail_sandbox_requests(
|
|
313
|
+
sandbox_id, "Failed to decode sandbox message"
|
|
207
314
|
)
|
|
208
|
-
for queue in self._response_queues.values():
|
|
209
|
-
await queue.put(error)
|
|
210
315
|
except ConnectionClosed as e:
|
|
211
316
|
logger.info(f"WebSocket connection closed cleanly (code={e.code}).")
|
|
212
317
|
except Exception:
|
|
213
318
|
logger.exception("Listener task terminated due to an unexpected error.")
|
|
214
319
|
|
|
215
|
-
#
|
|
216
|
-
|
|
217
|
-
error = SandboxConnectionError("WebSocket connection lost")
|
|
218
|
-
for queue in self._response_queues.values():
|
|
219
|
-
await queue.put(error)
|
|
320
|
+
# The connection itself dropped: every sandbox's requests are dead.
|
|
321
|
+
self._fail_all_requests("WebSocket connection lost")
|
|
220
322
|
|
|
221
323
|
async def execute_code(
|
|
222
324
|
self,
|
|
@@ -246,6 +348,7 @@ class GatewayClient:
|
|
|
246
348
|
request_id = str(uuid.uuid4())
|
|
247
349
|
queue: Queue[KernelMessage | Exception] = asyncio.Queue()
|
|
248
350
|
self._response_queues[request_id] = queue
|
|
351
|
+
self._request_sandbox[request_id] = sandbox_id
|
|
249
352
|
|
|
250
353
|
try:
|
|
251
354
|
payload_data: Dict[str, Any] = {"code": code, "env_vars": env_vars}
|
|
@@ -274,6 +377,7 @@ class GatewayClient:
|
|
|
274
377
|
finally:
|
|
275
378
|
# Clean up the queue for this specific request
|
|
276
379
|
self._response_queues.pop(request_id, None)
|
|
380
|
+
self._request_sandbox.pop(request_id, None)
|
|
277
381
|
|
|
278
382
|
async def _request(
|
|
279
383
|
self,
|
|
@@ -297,6 +401,7 @@ class GatewayClient:
|
|
|
297
401
|
http_method: Callable[..., Any] = getattr(self._session, method.lower())
|
|
298
402
|
|
|
299
403
|
attempt = 0
|
|
404
|
+
retried_503 = False
|
|
300
405
|
while True:
|
|
301
406
|
try:
|
|
302
407
|
# Only pass json parameter if it's provided
|
|
@@ -313,6 +418,17 @@ class GatewayClient:
|
|
|
313
418
|
attempt += 1
|
|
314
419
|
await asyncio.sleep(delay)
|
|
315
420
|
except httpx.HTTPStatusError as exc:
|
|
421
|
+
# 503 = the gateway's RAM-budget admission queue is full
|
|
422
|
+
# or timed out; it sets Retry-After. One honored retry —
|
|
423
|
+
# callers that want to fail fast still get the error on
|
|
424
|
+
# sustained pressure.
|
|
425
|
+
if exc.response.status_code == 503 and not retried_503:
|
|
426
|
+
retried_503 = True
|
|
427
|
+
delay = min(
|
|
428
|
+
float(exc.response.headers.get("retry-after", 5)), 60.0
|
|
429
|
+
)
|
|
430
|
+
await asyncio.sleep(delay)
|
|
431
|
+
continue
|
|
316
432
|
try:
|
|
317
433
|
response_data = exc.response.json()
|
|
318
434
|
except Exception:
|
|
@@ -360,7 +476,12 @@ class GatewayClient:
|
|
|
360
476
|
payload["ramMb"] = ram_mb
|
|
361
477
|
if vcpu is not None:
|
|
362
478
|
payload["vcpu"] = vcpu
|
|
363
|
-
|
|
479
|
+
# No transport retries here: callers (the agent's provisioning
|
|
480
|
+
# barrier) already retry creation with a fresh connection, and an
|
|
481
|
+
# inner re-POST of the same sandbox_id after a read timeout risks
|
|
482
|
+
# double admission of a request the gateway may still be holding
|
|
483
|
+
# in its queue. The 503 Retry-After branch is unaffected.
|
|
484
|
+
return await self._request("POST", "sandboxes", json=payload, retries=0)
|
|
364
485
|
|
|
365
486
|
async def delete_sandbox(self, sandbox_id: str) -> None:
|
|
366
487
|
await self._request("DELETE", f"sandboxes/{sandbox_id}")
|
|
@@ -403,3 +524,54 @@ class GatewayClient:
|
|
|
403
524
|
"DELETE",
|
|
404
525
|
f"sandboxes/{sandbox_id}/contexts/{context_id}",
|
|
405
526
|
)
|
|
527
|
+
|
|
528
|
+
# Workspace (files) methods
|
|
529
|
+
async def list_tree(self, sandbox_id: str, root: str = "") -> Dict[str, Any]:
|
|
530
|
+
"""List the sandbox workspace tree ({root, entries}).
|
|
531
|
+
|
|
532
|
+
``root`` restricts the walk to one workspace subtree (per-agent subdir);
|
|
533
|
+
entry paths stay workspace-root-relative either way.
|
|
534
|
+
"""
|
|
535
|
+
suffix = f"?root={quote(root, safe='')}" if root else ""
|
|
536
|
+
return await self._request(
|
|
537
|
+
"GET", f"sandboxes/{sandbox_id}/workspace/tree{suffix}"
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
async def read_file(self, sandbox_id: str, path: str) -> Dict[str, Any]:
|
|
541
|
+
"""Read one workspace file (utf-8 text or base64 binary)."""
|
|
542
|
+
return await self._request(
|
|
543
|
+
"GET",
|
|
544
|
+
f"sandboxes/{sandbox_id}/workspace/file?path={quote(path, safe='')}",
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
async def write_file(
|
|
548
|
+
self,
|
|
549
|
+
sandbox_id: str,
|
|
550
|
+
path: str,
|
|
551
|
+
content: str,
|
|
552
|
+
encoding: str = "utf-8",
|
|
553
|
+
) -> Dict[str, Any]:
|
|
554
|
+
"""Create or overwrite one workspace file."""
|
|
555
|
+
return await self._request(
|
|
556
|
+
"PUT",
|
|
557
|
+
f"sandboxes/{sandbox_id}/workspace/file?path={quote(path, safe='')}",
|
|
558
|
+
json={"content": content, "encoding": encoding},
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
async def workspace_changes(self, sandbox_id: str, since: int = 0) -> Dict[str, Any]:
|
|
562
|
+
"""Return the workspace change delta since ``since`` ({cursor, changes, snapshot})."""
|
|
563
|
+
return await self._request(
|
|
564
|
+
"GET",
|
|
565
|
+
f"sandboxes/{sandbox_id}/workspace/changes?since={int(since)}",
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
async def aiter_workspace_changes(self, sandbox_id: str) -> AsyncIterator[Any]:
|
|
569
|
+
"""Yield each pushed ``WorkspaceChange`` for one sandbox as it arrives.
|
|
570
|
+
|
|
571
|
+
Attributed by the gateway's per-frame ``sandbox_id`` tag, so one client
|
|
572
|
+
multiplexing several sandboxes gets a separate, non-interleaved feed for
|
|
573
|
+
each — a chatty or hostile sandbox cannot pollute another's stream.
|
|
574
|
+
"""
|
|
575
|
+
q = self._changes_queue_for(sandbox_id)
|
|
576
|
+
while True:
|
|
577
|
+
yield await q.get()
|
|
@@ -12,6 +12,7 @@ from aisolate.common.models import (
|
|
|
12
12
|
ErrorOutput,
|
|
13
13
|
ExecuteResult,
|
|
14
14
|
KernelMessage,
|
|
15
|
+
ShellExitStatus,
|
|
15
16
|
StreamOutput,
|
|
16
17
|
)
|
|
17
18
|
|
|
@@ -124,27 +125,6 @@ class Execution:
|
|
|
124
125
|
"""Returns True if the execution produced a final answer."""
|
|
125
126
|
return self.final_answer is not None
|
|
126
127
|
|
|
127
|
-
@property
|
|
128
|
-
def review_request(self) -> Optional[Dict[str, Any]]:
|
|
129
|
-
"""Returns the review request data if present in DisplayData.
|
|
130
|
-
|
|
131
|
-
Checks for the custom MIME type 'application/vnd.aisolate.review-request+json'
|
|
132
|
-
which is sent by the request_report_review() action.
|
|
133
|
-
"""
|
|
134
|
-
for event in self.events:
|
|
135
|
-
if isinstance(event, DisplayData):
|
|
136
|
-
review_data = event.data.get(
|
|
137
|
-
"application/vnd.aisolate.review-request+json"
|
|
138
|
-
)
|
|
139
|
-
if review_data:
|
|
140
|
-
return review_data
|
|
141
|
-
return None
|
|
142
|
-
|
|
143
|
-
@property
|
|
144
|
-
def has_review_request(self) -> bool:
|
|
145
|
-
"""Returns True if the execution requested a review."""
|
|
146
|
-
return self.review_request is not None
|
|
147
|
-
|
|
148
128
|
def _find_main_result_event(
|
|
149
129
|
self,
|
|
150
130
|
) -> Optional[Union["ExecuteResult", "DisplayData"]]:
|
|
@@ -320,6 +300,27 @@ class Execution:
|
|
|
320
300
|
if isinstance(e, StreamOutput) and e.name == "stdout"
|
|
321
301
|
)
|
|
322
302
|
|
|
303
|
+
@property
|
|
304
|
+
def stderr(self) -> str:
|
|
305
|
+
"""Concatenates all text from standard error streams."""
|
|
306
|
+
return "".join(
|
|
307
|
+
e.text
|
|
308
|
+
for e in self.events
|
|
309
|
+
if isinstance(e, StreamOutput) and e.name == "stderr"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
@property
|
|
313
|
+
def exit_code(self) -> Optional[int]:
|
|
314
|
+
"""The shell command's process exit code, if this was a shell run.
|
|
315
|
+
|
|
316
|
+
Carried by the ShellExecutor's ``shell_exit`` message; ``None`` for
|
|
317
|
+
kernel executions (which signal errors via ``ErrorOutput`` instead).
|
|
318
|
+
"""
|
|
319
|
+
return next(
|
|
320
|
+
(e.exit_code for e in self.events if isinstance(e, ShellExitStatus)),
|
|
321
|
+
None,
|
|
322
|
+
)
|
|
323
|
+
|
|
323
324
|
def __repr__(self) -> str:
|
|
324
325
|
if self.has_error:
|
|
325
326
|
status = f"error='{self.error.error_name}'"
|