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.
@@ -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