genesis-sandbox-client-python 0.1.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.
- genesis_sandbox_client/__init__.py +57 -0
- genesis_sandbox_client/async_session.py +663 -0
- genesis_sandbox_client/client.py +993 -0
- genesis_sandbox_client/control_probe.py +119 -0
- genesis_sandbox_client/deadline_http.py +96 -0
- genesis_sandbox_client/errors.py +133 -0
- genesis_sandbox_client/execution_governance.py +270 -0
- genesis_sandbox_client/invocation_output.py +11 -0
- genesis_sandbox_client/json_codec.py +31 -0
- genesis_sandbox_client/py.typed +0 -0
- genesis_sandbox_client/session.py +536 -0
- genesis_sandbox_client/session_exec_client.py +76 -0
- genesis_sandbox_client/types.py +150 -0
- genesis_sandbox_client/view_preparation.py +64 -0
- genesis_sandbox_client/workspace_client.py +180 -0
- genesis_sandbox_client/workspace_lifecycle.py +154 -0
- genesis_sandbox_client/workspace_purge.py +26 -0
- genesis_sandbox_client_python-0.1.0.dist-info/METADATA +197 -0
- genesis_sandbox_client_python-0.1.0.dist-info/RECORD +21 -0
- genesis_sandbox_client_python-0.1.0.dist-info/WHEEL +4 -0
- genesis_sandbox_client_python-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
"""Asynchronous session-backed SandboxSession helper.
|
|
2
|
+
|
|
3
|
+
Usage (async context manager — preferred):
|
|
4
|
+
async with new_sandbox_async(client, profile="python") as sb:
|
|
5
|
+
result = await sb.run_python('print("hello")')
|
|
6
|
+
print(result.stdout)
|
|
7
|
+
|
|
8
|
+
Usage (manual lifecycle):
|
|
9
|
+
sb = await open_sandbox_async(client, profile="shell")
|
|
10
|
+
try:
|
|
11
|
+
result = await sb.run_shell("uname -a")
|
|
12
|
+
finally:
|
|
13
|
+
await sb.close()
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import logging
|
|
20
|
+
import uuid
|
|
21
|
+
from collections.abc import AsyncIterator
|
|
22
|
+
from contextlib import asynccontextmanager
|
|
23
|
+
from typing import Any, cast
|
|
24
|
+
|
|
25
|
+
from .client import Client
|
|
26
|
+
from .errors import APIError, ExecRecoveryError, TransportError
|
|
27
|
+
from .session import _to_exec_result
|
|
28
|
+
from .types import (
|
|
29
|
+
JOB_TIMEOUT,
|
|
30
|
+
RENEW_EXTEND,
|
|
31
|
+
RENEW_INTERVAL,
|
|
32
|
+
SESSION_TTL,
|
|
33
|
+
CancelReceipt,
|
|
34
|
+
ExecResult,
|
|
35
|
+
SandboxOptions,
|
|
36
|
+
SSEEvent,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
log = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AsyncSandboxSession:
|
|
43
|
+
"""Async helper backed by ``/v1/sessions/{id}`` APIs.
|
|
44
|
+
|
|
45
|
+
Includes a built-in background heartbeat (Eureka-style) implemented as an
|
|
46
|
+
asyncio task that periodically renews the session. Call ``_start_heartbeat``
|
|
47
|
+
once a running loop is available (done automatically by the open helpers).
|
|
48
|
+
|
|
49
|
+
The SDK is transport-level sync (stdlib urllib) by design; async methods
|
|
50
|
+
offload blocking I/O via ``asyncio.to_thread`` so the event loop stays
|
|
51
|
+
responsive without pulling in third-party HTTP dependencies.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, client: Client, session: dict[str, Any], options: SandboxOptions) -> None:
|
|
55
|
+
self._client = client
|
|
56
|
+
self._session = session
|
|
57
|
+
self._session_id = session["session_id"]
|
|
58
|
+
self._sandbox_id = session.get("active_sandbox_id", "")
|
|
59
|
+
self._workspace_id = session.get("workspace_id", "")
|
|
60
|
+
self._opts = options
|
|
61
|
+
self._closed = False
|
|
62
|
+
self._operation_by_exec_id: dict[str, str] = {}
|
|
63
|
+
|
|
64
|
+
self._renew_interval = options.renew_interval
|
|
65
|
+
self._renew_extend = options.renew_extend
|
|
66
|
+
self._heartbeat_enabled = options.heartbeat and self._renew_interval > 0 and self._renew_extend > 0
|
|
67
|
+
self._renew_stop = asyncio.Event()
|
|
68
|
+
self._renew_task: asyncio.Task[None] | None = None
|
|
69
|
+
self._heartbeat_error: Exception | None = None
|
|
70
|
+
|
|
71
|
+
def _start_heartbeat(self) -> None:
|
|
72
|
+
if self._heartbeat_enabled and self._renew_task is None:
|
|
73
|
+
self._renew_task = asyncio.create_task(self._renew_loop(), name=f"sandbox-renew-{self._session_id[:8]}")
|
|
74
|
+
|
|
75
|
+
async def _renew_loop(self) -> None:
|
|
76
|
+
try:
|
|
77
|
+
while not self._renew_stop.is_set():
|
|
78
|
+
try:
|
|
79
|
+
await asyncio.wait_for(self._renew_stop.wait(), timeout=self._renew_interval)
|
|
80
|
+
return
|
|
81
|
+
except TimeoutError:
|
|
82
|
+
pass
|
|
83
|
+
try:
|
|
84
|
+
res = await asyncio.to_thread(
|
|
85
|
+
self._client.renew_session,
|
|
86
|
+
self._session_id,
|
|
87
|
+
self._renew_extend,
|
|
88
|
+
15,
|
|
89
|
+
1,
|
|
90
|
+
)
|
|
91
|
+
log.debug(
|
|
92
|
+
"Lease renewed: session=%s new_expires=%s",
|
|
93
|
+
self._session_id,
|
|
94
|
+
res.get("expires_at") if res else None,
|
|
95
|
+
)
|
|
96
|
+
except Exception as e: # noqa: BLE001
|
|
97
|
+
self._heartbeat_error = e
|
|
98
|
+
self._renew_stop.set()
|
|
99
|
+
log.warning("Heartbeat suspended; inspect original session before renewing: session=%s",
|
|
100
|
+
self._session_id)
|
|
101
|
+
return
|
|
102
|
+
except asyncio.CancelledError:
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def heartbeat_error(self) -> Exception | None:
|
|
107
|
+
return self._heartbeat_error
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def session_id(self) -> str:
|
|
111
|
+
return self._session_id
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def sandbox_id(self) -> str:
|
|
115
|
+
return self._sandbox_id
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def workspace_id(self) -> str:
|
|
119
|
+
"""Durable workspace identity that can outlive the current container."""
|
|
120
|
+
return self._workspace_id
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def session(self) -> dict[str, Any]:
|
|
124
|
+
return self._session
|
|
125
|
+
|
|
126
|
+
# ─── Execution ─────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
async def run(
|
|
129
|
+
self,
|
|
130
|
+
code_or_cmd: str | list[str],
|
|
131
|
+
*,
|
|
132
|
+
lang: str | None = None,
|
|
133
|
+
timeout: int | None = None,
|
|
134
|
+
working_dir: str | None = None,
|
|
135
|
+
env: dict[str, str] | None = None,
|
|
136
|
+
operation_id: str | None = None,
|
|
137
|
+
) -> ExecResult:
|
|
138
|
+
"""General-purpose execution in the session sandbox.
|
|
139
|
+
|
|
140
|
+
env 与 SandboxOptions.env 合并(单次调用优先);operation_id 未传时本地生成
|
|
141
|
+
uuid4,生产必须显式传入并持久保存。
|
|
142
|
+
"""
|
|
143
|
+
kwargs: dict[str, Any] = {}
|
|
144
|
+
if isinstance(code_or_cmd, list):
|
|
145
|
+
kwargs["command"] = code_or_cmd
|
|
146
|
+
else:
|
|
147
|
+
kwargs["code"] = code_or_cmd
|
|
148
|
+
if lang:
|
|
149
|
+
kwargs["language"] = lang
|
|
150
|
+
if working_dir:
|
|
151
|
+
kwargs["working_dir"] = working_dir
|
|
152
|
+
merged_env = {**(self._opts.env or {}), **(env or {})}
|
|
153
|
+
if merged_env:
|
|
154
|
+
kwargs["env"] = merged_env
|
|
155
|
+
if timeout:
|
|
156
|
+
kwargs["timeout_seconds"] = timeout
|
|
157
|
+
operation_id = operation_id or uuid.uuid4().hex
|
|
158
|
+
record = await self._submit_exec(kwargs, operation_id)
|
|
159
|
+
exec_id = record.get("exec_id") if isinstance(record, dict) else None
|
|
160
|
+
if not isinstance(exec_id, str) or not exec_id.strip():
|
|
161
|
+
raise ExecRecoveryError(
|
|
162
|
+
self._session_id,
|
|
163
|
+
operation_id,
|
|
164
|
+
phase="submit",
|
|
165
|
+
cause=ValueError("async exec response did not include exec_id"),
|
|
166
|
+
)
|
|
167
|
+
self._operation_by_exec_id[exec_id] = operation_id
|
|
168
|
+
try:
|
|
169
|
+
while record.get("status") in {"queued", "running"}:
|
|
170
|
+
await asyncio.sleep(0.2)
|
|
171
|
+
record = await self._observe_exec(exec_id, operation_id)
|
|
172
|
+
except asyncio.CancelledError:
|
|
173
|
+
await self._cancel_remote(exec_id)
|
|
174
|
+
raise
|
|
175
|
+
self._record_execution(record)
|
|
176
|
+
return _to_exec_result(record)
|
|
177
|
+
|
|
178
|
+
async def run_python(self, code: str, timeout: int = JOB_TIMEOUT, *, operation_id: str | None = None) -> ExecResult:
|
|
179
|
+
return await self.run(code, lang="python", timeout=timeout, operation_id=operation_id)
|
|
180
|
+
|
|
181
|
+
async def run_shell(
|
|
182
|
+
self, script: str, timeout: int = JOB_TIMEOUT, *, operation_id: str | None = None
|
|
183
|
+
) -> ExecResult:
|
|
184
|
+
return await self.run(script, lang="shell", timeout=timeout, operation_id=operation_id)
|
|
185
|
+
|
|
186
|
+
async def run_node(self, code: str, timeout: int = JOB_TIMEOUT, *, operation_id: str | None = None) -> ExecResult:
|
|
187
|
+
return await self.run(code, lang="javascript", timeout=timeout, operation_id=operation_id)
|
|
188
|
+
|
|
189
|
+
async def run_command(self, *command: str) -> ExecResult:
|
|
190
|
+
return await self.run(list(command))
|
|
191
|
+
|
|
192
|
+
# ─── Async Execution ───────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
async def run_async(
|
|
195
|
+
self,
|
|
196
|
+
code_or_cmd: str | list[str],
|
|
197
|
+
*,
|
|
198
|
+
lang: str | None = None,
|
|
199
|
+
timeout: int | None = None,
|
|
200
|
+
working_dir: str | None = None,
|
|
201
|
+
env: dict[str, str] | None = None,
|
|
202
|
+
operation_id: str | None = None,
|
|
203
|
+
) -> str:
|
|
204
|
+
"""Submit asynchronous execution; returns exec_id for polling.
|
|
205
|
+
|
|
206
|
+
env 与 SandboxOptions.env 合并(单次调用优先);operation_id 语义同 run。
|
|
207
|
+
"""
|
|
208
|
+
kwargs: dict[str, Any] = {}
|
|
209
|
+
if isinstance(code_or_cmd, list):
|
|
210
|
+
kwargs["command"] = code_or_cmd
|
|
211
|
+
else:
|
|
212
|
+
kwargs["code"] = code_or_cmd
|
|
213
|
+
if lang:
|
|
214
|
+
kwargs["language"] = lang
|
|
215
|
+
if working_dir:
|
|
216
|
+
kwargs["working_dir"] = working_dir
|
|
217
|
+
merged_env = {**(self._opts.env or {}), **(env or {})}
|
|
218
|
+
if merged_env:
|
|
219
|
+
kwargs["env"] = merged_env
|
|
220
|
+
if timeout:
|
|
221
|
+
kwargs["timeout_seconds"] = timeout
|
|
222
|
+
operation_id = operation_id or uuid.uuid4().hex
|
|
223
|
+
result = await self._submit_exec(kwargs, operation_id)
|
|
224
|
+
exec_id = result.get("exec_id") if isinstance(result, dict) else None
|
|
225
|
+
if not isinstance(exec_id, str) or not exec_id.strip():
|
|
226
|
+
raise ExecRecoveryError(
|
|
227
|
+
self._session_id,
|
|
228
|
+
operation_id,
|
|
229
|
+
phase="submit",
|
|
230
|
+
cause=ValueError("async exec response did not include exec_id"),
|
|
231
|
+
)
|
|
232
|
+
self._operation_by_exec_id[exec_id] = operation_id
|
|
233
|
+
return exec_id
|
|
234
|
+
|
|
235
|
+
async def list_execs(self, *, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
|
236
|
+
return await asyncio.to_thread(self._client.list_session_execs, self._session_id, limit=limit, cursor=cursor)
|
|
237
|
+
|
|
238
|
+
async def get_exec_by_operation(self, operation_id: str) -> dict[str, Any]:
|
|
239
|
+
"""Look up an ExecRecord using the stable identity returned in recovery errors."""
|
|
240
|
+
if not operation_id:
|
|
241
|
+
raise ValueError("operation_id is required")
|
|
242
|
+
try:
|
|
243
|
+
result = await self._observe_by_operation(operation_id)
|
|
244
|
+
except ExecRecoveryError:
|
|
245
|
+
raise
|
|
246
|
+
exec_id = result.get("exec_id")
|
|
247
|
+
if exec_id:
|
|
248
|
+
self._operation_by_exec_id[exec_id] = operation_id
|
|
249
|
+
return result
|
|
250
|
+
|
|
251
|
+
async def wait_exec(
|
|
252
|
+
self, exec_id: str, poll_interval: float = 1.0, max_wait: float = 300.0, *, operation_id: str | None = None
|
|
253
|
+
) -> ExecResult:
|
|
254
|
+
"""Poll get_exec until execution completes or max_wait is exceeded."""
|
|
255
|
+
operation_id = operation_id or self._operation_by_exec_id.get(exec_id, "")
|
|
256
|
+
loop = asyncio.get_running_loop()
|
|
257
|
+
deadline = loop.time() + max_wait
|
|
258
|
+
while loop.time() < deadline:
|
|
259
|
+
result = await self._observe_exec(exec_id, operation_id)
|
|
260
|
+
operation_id = result.get("operation_id") or operation_id
|
|
261
|
+
if operation_id:
|
|
262
|
+
self._operation_by_exec_id[exec_id] = operation_id
|
|
263
|
+
status = result.get("status", "")
|
|
264
|
+
if status in ("succeeded", "failed", "cancelled", "timed_out", "interrupted"):
|
|
265
|
+
self._record_execution(result)
|
|
266
|
+
return _to_exec_result(result)
|
|
267
|
+
await asyncio.sleep(poll_interval)
|
|
268
|
+
raise TimeoutError(f"exec {exec_id} did not complete within {max_wait}s")
|
|
269
|
+
|
|
270
|
+
async def wait_exec_by_operation(
|
|
271
|
+
self, operation_id: str, poll_interval: float = 1.0, max_wait: float = 300.0
|
|
272
|
+
) -> ExecResult:
|
|
273
|
+
"""Resume polling after process/client state loss using the operation identity."""
|
|
274
|
+
record = await self.get_exec_by_operation(operation_id)
|
|
275
|
+
exec_id = record.get("exec_id")
|
|
276
|
+
if not isinstance(exec_id, str) or not exec_id:
|
|
277
|
+
raise ExecRecoveryError(self._session_id, operation_id, phase="lookup")
|
|
278
|
+
if record.get("status") in ("succeeded", "failed", "cancelled", "timed_out", "interrupted"):
|
|
279
|
+
self._record_execution(record)
|
|
280
|
+
return _to_exec_result(record)
|
|
281
|
+
return await self.wait_exec(exec_id, poll_interval, max_wait, operation_id=operation_id)
|
|
282
|
+
|
|
283
|
+
async def cancel_exec(self, exec_id: str) -> dict[str, Any]:
|
|
284
|
+
"""Cancel a running async execution (raw ExecRecord; 语义见 Client.cancel_exec)."""
|
|
285
|
+
return await asyncio.to_thread(self._client.cancel_exec, self._session_id, exec_id)
|
|
286
|
+
|
|
287
|
+
async def cancel_exec_receipt(self, exec_id: str, *, operation_id: str | None = None) -> CancelReceipt:
|
|
288
|
+
"""取消并表达「接受 vs 停止确认」(CancelReceipt)。
|
|
289
|
+
|
|
290
|
+
成功返回 outcome 为 accepted / already_terminal / stop_confirmed 的回执;
|
|
291
|
+
取消失败或超时(停止未知)抛 ExecRecoveryError(phase="cancel"),携带
|
|
292
|
+
operation_id 供核对,未确认停止前不得回收资源或重跑替代执行。
|
|
293
|
+
"""
|
|
294
|
+
if not exec_id:
|
|
295
|
+
raise ValueError("exec_id is required")
|
|
296
|
+
operation_id = operation_id or self._operation_by_exec_id.get(exec_id, "")
|
|
297
|
+
try:
|
|
298
|
+
record = await asyncio.to_thread(self._client.cancel_exec, self._session_id, exec_id)
|
|
299
|
+
except (APIError, TransportError) as error:
|
|
300
|
+
raise ExecRecoveryError(
|
|
301
|
+
self._session_id,
|
|
302
|
+
operation_id,
|
|
303
|
+
exec_id=exec_id,
|
|
304
|
+
phase="cancel",
|
|
305
|
+
cause=error,
|
|
306
|
+
) from error
|
|
307
|
+
return CancelReceipt.from_record(exec_id, record)
|
|
308
|
+
|
|
309
|
+
async def lookup_session(self, idempotency_key: str) -> dict[str, Any] | None:
|
|
310
|
+
"""按幂等键查回会话(会话创建响应丢失恢复),404 返回 None。见 Client.lookup_session。"""
|
|
311
|
+
return await asyncio.to_thread(self._client.lookup_session, idempotency_key)
|
|
312
|
+
|
|
313
|
+
async def stream_exec_logs(self, exec_id: str, cursor: int | str = 0) -> AsyncIterator[SSEEvent]:
|
|
314
|
+
"""异步惰性日志流,逐条产出 SSEEvent(底层为 Client.stream_exec_logs)。"""
|
|
315
|
+
iterator = self._client.stream_exec_logs(self._session_id, exec_id, cursor=cursor)
|
|
316
|
+
sentinel = object()
|
|
317
|
+
try:
|
|
318
|
+
while True:
|
|
319
|
+
item = await asyncio.to_thread(next, iterator, sentinel)
|
|
320
|
+
if item is sentinel:
|
|
321
|
+
return
|
|
322
|
+
yield cast(SSEEvent, item)
|
|
323
|
+
finally:
|
|
324
|
+
close = getattr(iterator, "close", None)
|
|
325
|
+
if close is not None:
|
|
326
|
+
close()
|
|
327
|
+
|
|
328
|
+
async def read_exec_logs(
|
|
329
|
+
self, exec_id: str, *, cursor: int | str | None = None, byte_budget: int, max_events: int
|
|
330
|
+
) -> dict[str, Any]:
|
|
331
|
+
"""按预算读取一页日志(events/next_cursor/exhausted/gap_detected)。见 Client.read_exec_logs。"""
|
|
332
|
+
return await asyncio.to_thread(
|
|
333
|
+
self._client.read_exec_logs,
|
|
334
|
+
self._session_id,
|
|
335
|
+
exec_id,
|
|
336
|
+
cursor=cursor,
|
|
337
|
+
byte_budget=byte_budget,
|
|
338
|
+
max_events=max_events,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
# ─── File Operations ───────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
async def _cancel_remote(self, exec_id: str) -> None:
|
|
344
|
+
"""协程被取消时的尽力远端取消(不抛错,避免覆盖 CancelledError)。
|
|
345
|
+
|
|
346
|
+
停止未确认只记 error 日志——这是 stop unknown 的降级路径;需要严格
|
|
347
|
+
「停止未知门禁」时使用 cancel_exec_receipt(抛 ExecRecoveryError)。
|
|
348
|
+
"""
|
|
349
|
+
try:
|
|
350
|
+
await asyncio.wait_for(
|
|
351
|
+
asyncio.to_thread(self._client.cancel_exec, self._session_id, exec_id, timeout=10, max_retries=1),
|
|
352
|
+
timeout=12,
|
|
353
|
+
)
|
|
354
|
+
except Exception as exc:
|
|
355
|
+
log.error("Remote cancellation unconfirmed: exec=%s error_type=%s", exec_id, type(exc).__name__)
|
|
356
|
+
|
|
357
|
+
async def _observe_exec(self, exec_id: str, operation_id: str) -> dict[str, Any]:
|
|
358
|
+
return await self._observe_receipt(
|
|
359
|
+
lambda: self._client.get_exec(self._session_id, exec_id),
|
|
360
|
+
operation_id=operation_id,
|
|
361
|
+
exec_id=exec_id,
|
|
362
|
+
phase="observe",
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
async def _observe_by_operation(self, operation_id: str) -> dict[str, Any]:
|
|
366
|
+
return await self._observe_receipt(
|
|
367
|
+
lambda: self._client.get_exec_by_operation(self._session_id, operation_id),
|
|
368
|
+
operation_id=operation_id,
|
|
369
|
+
exec_id=None,
|
|
370
|
+
phase="lookup",
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
async def _observe_receipt(self, request, *, operation_id: str, exec_id: str | None, phase: str) -> dict[str, Any]:
|
|
374
|
+
retries = 2
|
|
375
|
+
for attempt in range(retries + 1):
|
|
376
|
+
try:
|
|
377
|
+
return await asyncio.to_thread(request)
|
|
378
|
+
except asyncio.CancelledError:
|
|
379
|
+
raise
|
|
380
|
+
except Exception as error:
|
|
381
|
+
retryable = isinstance(error, TransportError) or (isinstance(error, APIError) and error.retryable)
|
|
382
|
+
if retryable and attempt < retries:
|
|
383
|
+
await asyncio.sleep(0.2 * (2**attempt))
|
|
384
|
+
continue
|
|
385
|
+
if isinstance(error, APIError) and not error.retryable:
|
|
386
|
+
raise
|
|
387
|
+
raise ExecRecoveryError(
|
|
388
|
+
self._session_id,
|
|
389
|
+
operation_id,
|
|
390
|
+
exec_id=exec_id,
|
|
391
|
+
phase=phase,
|
|
392
|
+
cause=error,
|
|
393
|
+
) from error
|
|
394
|
+
raise AssertionError("unreachable observation retry state")
|
|
395
|
+
|
|
396
|
+
async def _submit_exec(self, kwargs: dict[str, Any], operation_id: str) -> dict[str, Any]:
|
|
397
|
+
# 同一个提交身份贯穿传输重试,取消协程不能留下无主远端执行。
|
|
398
|
+
kwargs["operation_id"] = operation_id
|
|
399
|
+
submit = asyncio.create_task(asyncio.to_thread(self._client.exec_session_async, self._session_id, **kwargs))
|
|
400
|
+
try:
|
|
401
|
+
return await asyncio.shield(submit)
|
|
402
|
+
except asyncio.CancelledError:
|
|
403
|
+
try:
|
|
404
|
+
result = await asyncio.wait_for(asyncio.shield(submit), timeout=5)
|
|
405
|
+
await self._cancel_remote(result["exec_id"])
|
|
406
|
+
except Exception:
|
|
407
|
+
# 提交响应丢失时按操作身份查回执,避免分页列表遗漏已启动执行。
|
|
408
|
+
for _ in range(3):
|
|
409
|
+
try:
|
|
410
|
+
result = await asyncio.wait_for(
|
|
411
|
+
asyncio.to_thread(
|
|
412
|
+
self._client.get_exec_by_operation, self._session_id, kwargs["operation_id"]
|
|
413
|
+
),
|
|
414
|
+
timeout=5,
|
|
415
|
+
)
|
|
416
|
+
await self._cancel_remote(result["exec_id"])
|
|
417
|
+
break
|
|
418
|
+
except Exception:
|
|
419
|
+
await asyncio.sleep(0.2)
|
|
420
|
+
else:
|
|
421
|
+
log.error("Remote submission cancellation unconfirmed: operation=%s", kwargs["operation_id"])
|
|
422
|
+
raise
|
|
423
|
+
except APIError as error:
|
|
424
|
+
if not error.retryable:
|
|
425
|
+
raise
|
|
426
|
+
raise ExecRecoveryError(
|
|
427
|
+
self._session_id,
|
|
428
|
+
operation_id,
|
|
429
|
+
phase="submit",
|
|
430
|
+
cause=error,
|
|
431
|
+
) from error
|
|
432
|
+
except TransportError as error:
|
|
433
|
+
raise ExecRecoveryError(
|
|
434
|
+
self._session_id,
|
|
435
|
+
operation_id,
|
|
436
|
+
phase="submit",
|
|
437
|
+
cause=error,
|
|
438
|
+
) from error
|
|
439
|
+
|
|
440
|
+
async def write_file(
|
|
441
|
+
self, path: str, content: bytes | str, *, if_match: str | None = None, create_only: bool = False
|
|
442
|
+
) -> dict[str, Any]:
|
|
443
|
+
return await asyncio.to_thread(
|
|
444
|
+
self._client.upload_session_file,
|
|
445
|
+
self._session_id,
|
|
446
|
+
path,
|
|
447
|
+
content,
|
|
448
|
+
if_match,
|
|
449
|
+
create_only,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
async def read_file(self, path: str) -> bytes:
|
|
453
|
+
return await asyncio.to_thread(self._client.download_session_file, self._session_id, path)
|
|
454
|
+
|
|
455
|
+
async def list_files(self, path: str = ".", recursive: bool = False, limit: int | None = None) -> dict[str, Any]:
|
|
456
|
+
return await asyncio.to_thread(self._client.list_session_files, self._session_id, path, recursive, limit)
|
|
457
|
+
|
|
458
|
+
async def stat_file(self, path: str) -> dict[str, Any]:
|
|
459
|
+
return await asyncio.to_thread(self._client.stat_session_file, self._session_id, path)
|
|
460
|
+
|
|
461
|
+
async def mkdir(self, path: str) -> dict[str, Any]:
|
|
462
|
+
return await asyncio.to_thread(self._client.mkdir_session_dir, self._session_id, path)
|
|
463
|
+
|
|
464
|
+
async def remove(self, path: str, recursive: bool = False) -> dict[str, Any] | None:
|
|
465
|
+
return await asyncio.to_thread(self._client.remove_session_file, self._session_id, path, recursive)
|
|
466
|
+
|
|
467
|
+
# ─── Lifecycle ─────────────────────────────────────────────────────
|
|
468
|
+
|
|
469
|
+
async def suspend(self) -> dict[str, Any]:
|
|
470
|
+
"""Release the runtime while keeping the session and workspace."""
|
|
471
|
+
session = await asyncio.to_thread(self._client.suspend_session, self._session_id)
|
|
472
|
+
self._session = session or self._session
|
|
473
|
+
self._sandbox_id = (session or {}).get("active_sandbox_id", "")
|
|
474
|
+
return session
|
|
475
|
+
|
|
476
|
+
async def resume(self) -> dict[str, Any]:
|
|
477
|
+
"""Restore the runtime while preserving the logical session and workspace."""
|
|
478
|
+
session = await asyncio.to_thread(self._client.resume_session, self._session_id)
|
|
479
|
+
self._session = session or self._session
|
|
480
|
+
self._sandbox_id = (session or {}).get("active_sandbox_id", "")
|
|
481
|
+
return session
|
|
482
|
+
|
|
483
|
+
async def close(self) -> None:
|
|
484
|
+
if self._closed:
|
|
485
|
+
return
|
|
486
|
+
self._renew_stop.set()
|
|
487
|
+
if self._renew_task is not None:
|
|
488
|
+
self._renew_task.cancel()
|
|
489
|
+
try:
|
|
490
|
+
await self._renew_task
|
|
491
|
+
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
|
492
|
+
pass
|
|
493
|
+
log.info("Closing session: %s", self._session_id)
|
|
494
|
+
await asyncio.to_thread(self._client.delete_session, self._session_id)
|
|
495
|
+
self._closed = True
|
|
496
|
+
|
|
497
|
+
# ─── Internal ──────────────────────────────────────────────────────
|
|
498
|
+
|
|
499
|
+
def _record_execution(self, result: dict[str, Any] | None) -> None:
|
|
500
|
+
if not result:
|
|
501
|
+
return
|
|
502
|
+
sandbox_id = result.get("sandbox_id") or ""
|
|
503
|
+
if sandbox_id:
|
|
504
|
+
self._sandbox_id = sandbox_id
|
|
505
|
+
self._session["active_sandbox_id"] = sandbox_id
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# ─── Factory Functions ─────────────────────────────────────────────────────
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _build_opts(
|
|
512
|
+
profile: str | None,
|
|
513
|
+
hints: list[str] | None,
|
|
514
|
+
strict: bool,
|
|
515
|
+
description: str | None,
|
|
516
|
+
resolution_id: str | None,
|
|
517
|
+
workspace_id: str | None,
|
|
518
|
+
env: dict[str, str] | None,
|
|
519
|
+
metadata: dict[str, str] | None,
|
|
520
|
+
ttl_seconds: int,
|
|
521
|
+
workspace_retention: str | None,
|
|
522
|
+
workspace_ttl_seconds: int | None,
|
|
523
|
+
idempotency_key: str | None,
|
|
524
|
+
heartbeat: bool,
|
|
525
|
+
renew_interval: int,
|
|
526
|
+
renew_extend: int,
|
|
527
|
+
) -> SandboxOptions:
|
|
528
|
+
return SandboxOptions(
|
|
529
|
+
profile=profile,
|
|
530
|
+
hints=hints,
|
|
531
|
+
strict=strict,
|
|
532
|
+
description=description,
|
|
533
|
+
resolution_id=resolution_id,
|
|
534
|
+
workspace_id=workspace_id,
|
|
535
|
+
env=env,
|
|
536
|
+
metadata=metadata or {},
|
|
537
|
+
ttl_seconds=ttl_seconds,
|
|
538
|
+
workspace_retention=workspace_retention,
|
|
539
|
+
workspace_ttl_seconds=workspace_ttl_seconds,
|
|
540
|
+
idempotency_key=idempotency_key,
|
|
541
|
+
heartbeat=heartbeat,
|
|
542
|
+
renew_interval=renew_interval,
|
|
543
|
+
renew_extend=renew_extend,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
async def _create_session(client: Client, opts: SandboxOptions) -> dict[str, Any]:
|
|
548
|
+
# 会话创建不接受持久环境变量(协议拒绝 env 字段);opts.env 作为默认执行
|
|
549
|
+
# 环境变量在 run/run_async 时合并下发。
|
|
550
|
+
return await asyncio.to_thread(
|
|
551
|
+
client.create_session,
|
|
552
|
+
workspace_id=opts.workspace_id,
|
|
553
|
+
profile=opts.profile,
|
|
554
|
+
hints=opts.hints,
|
|
555
|
+
strict=opts.strict,
|
|
556
|
+
description=opts.description,
|
|
557
|
+
resolution_id=opts.resolution_id,
|
|
558
|
+
state_policy="session",
|
|
559
|
+
ttl_seconds=opts.ttl_seconds,
|
|
560
|
+
metadata=opts.metadata,
|
|
561
|
+
workspace_retention=opts.workspace_retention,
|
|
562
|
+
workspace_ttl_seconds=opts.workspace_ttl_seconds,
|
|
563
|
+
idempotency_key=opts.idempotency_key,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
@asynccontextmanager
|
|
568
|
+
async def new_sandbox_async(
|
|
569
|
+
client: Client,
|
|
570
|
+
*,
|
|
571
|
+
profile: str | None = None,
|
|
572
|
+
hints: list[str] | None = None,
|
|
573
|
+
strict: bool = False,
|
|
574
|
+
description: str | None = None,
|
|
575
|
+
resolution_id: str | None = None,
|
|
576
|
+
workspace_id: str | None = None,
|
|
577
|
+
env: dict[str, str] | None = None,
|
|
578
|
+
metadata: dict[str, str] | None = None,
|
|
579
|
+
ttl_seconds: int = SESSION_TTL,
|
|
580
|
+
workspace_retention: str | None = None,
|
|
581
|
+
workspace_ttl_seconds: int | None = None,
|
|
582
|
+
idempotency_key: str | None = None,
|
|
583
|
+
heartbeat: bool = True,
|
|
584
|
+
renew_interval: int = RENEW_INTERVAL,
|
|
585
|
+
renew_extend: int = RENEW_EXTEND,
|
|
586
|
+
) -> AsyncIterator[AsyncSandboxSession]:
|
|
587
|
+
"""Async context manager that creates and cleans up a session sandbox."""
|
|
588
|
+
opts = _build_opts(
|
|
589
|
+
profile,
|
|
590
|
+
hints,
|
|
591
|
+
strict,
|
|
592
|
+
description,
|
|
593
|
+
resolution_id,
|
|
594
|
+
workspace_id,
|
|
595
|
+
env,
|
|
596
|
+
metadata,
|
|
597
|
+
ttl_seconds,
|
|
598
|
+
workspace_retention,
|
|
599
|
+
workspace_ttl_seconds,
|
|
600
|
+
idempotency_key,
|
|
601
|
+
heartbeat,
|
|
602
|
+
renew_interval,
|
|
603
|
+
renew_extend,
|
|
604
|
+
)
|
|
605
|
+
session = await _create_session(client, opts)
|
|
606
|
+
sb = AsyncSandboxSession(client, session, opts)
|
|
607
|
+
sb._start_heartbeat()
|
|
608
|
+
log.info(
|
|
609
|
+
"AsyncSandboxSession opened: session=%s profile=%s heartbeat=%s", sb.session_id, profile, sb._heartbeat_enabled
|
|
610
|
+
)
|
|
611
|
+
try:
|
|
612
|
+
yield sb
|
|
613
|
+
finally:
|
|
614
|
+
await sb.close()
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
async def open_sandbox_async(
|
|
618
|
+
client: Client,
|
|
619
|
+
*,
|
|
620
|
+
profile: str | None = None,
|
|
621
|
+
hints: list[str] | None = None,
|
|
622
|
+
strict: bool = False,
|
|
623
|
+
description: str | None = None,
|
|
624
|
+
resolution_id: str | None = None,
|
|
625
|
+
workspace_id: str | None = None,
|
|
626
|
+
env: dict[str, str] | None = None,
|
|
627
|
+
metadata: dict[str, str] | None = None,
|
|
628
|
+
ttl_seconds: int = SESSION_TTL,
|
|
629
|
+
workspace_retention: str | None = None,
|
|
630
|
+
workspace_ttl_seconds: int | None = None,
|
|
631
|
+
idempotency_key: str | None = None,
|
|
632
|
+
heartbeat: bool = True,
|
|
633
|
+
renew_interval: int = RENEW_INTERVAL,
|
|
634
|
+
renew_extend: int = RENEW_EXTEND,
|
|
635
|
+
) -> AsyncSandboxSession:
|
|
636
|
+
"""Create an AsyncSandboxSession for manual lifecycle management."""
|
|
637
|
+
opts = _build_opts(
|
|
638
|
+
profile,
|
|
639
|
+
hints,
|
|
640
|
+
strict,
|
|
641
|
+
description,
|
|
642
|
+
resolution_id,
|
|
643
|
+
workspace_id,
|
|
644
|
+
env,
|
|
645
|
+
metadata,
|
|
646
|
+
ttl_seconds,
|
|
647
|
+
workspace_retention,
|
|
648
|
+
workspace_ttl_seconds,
|
|
649
|
+
idempotency_key,
|
|
650
|
+
heartbeat,
|
|
651
|
+
renew_interval,
|
|
652
|
+
renew_extend,
|
|
653
|
+
)
|
|
654
|
+
session = await _create_session(client, opts)
|
|
655
|
+
sb = AsyncSandboxSession(client, session, opts)
|
|
656
|
+
sb._start_heartbeat()
|
|
657
|
+
log.info(
|
|
658
|
+
"AsyncSandboxSession opened (manual): session=%s profile=%s heartbeat=%s",
|
|
659
|
+
sb.session_id,
|
|
660
|
+
profile,
|
|
661
|
+
sb._heartbeat_enabled,
|
|
662
|
+
)
|
|
663
|
+
return sb
|