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,536 @@
|
|
|
1
|
+
"""Synchronous session-backed SandboxSession helper.
|
|
2
|
+
|
|
3
|
+
Usage (context manager — preferred):
|
|
4
|
+
with new_sandbox(client, profile="python") as sb:
|
|
5
|
+
result = sb.run_python('print("hello")')
|
|
6
|
+
print(result.stdout)
|
|
7
|
+
|
|
8
|
+
Usage (manual lifecycle):
|
|
9
|
+
sb = new_sandbox(client, profile="shell")
|
|
10
|
+
try:
|
|
11
|
+
result = sb.run_shell("uname -a")
|
|
12
|
+
finally:
|
|
13
|
+
sb.close()
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
from collections.abc import Iterator
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from .client import Client
|
|
26
|
+
from .errors import APIError, ExecRecoveryError, TransportError
|
|
27
|
+
from .types import (
|
|
28
|
+
JOB_TIMEOUT,
|
|
29
|
+
RENEW_EXTEND,
|
|
30
|
+
RENEW_INTERVAL,
|
|
31
|
+
SESSION_TTL,
|
|
32
|
+
CancelReceipt,
|
|
33
|
+
EffectiveEnvironment,
|
|
34
|
+
ExecResult,
|
|
35
|
+
SandboxOptions,
|
|
36
|
+
SSEEvent,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
log = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SandboxSession:
|
|
43
|
+
"""Synchronous helper backed by ``/v1/sessions/{id}`` APIs.
|
|
44
|
+
|
|
45
|
+
Includes a built-in background heartbeat (Eureka-style) that periodically
|
|
46
|
+
renews the session so long-running work is not reclaimed by the server's
|
|
47
|
+
Lease Reaper. The heartbeat is a daemon thread stopped on ``close()``.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, client: Client, session: dict[str, Any], options: SandboxOptions) -> None:
|
|
51
|
+
self._client = client
|
|
52
|
+
self._session = session
|
|
53
|
+
self._session_id = session["session_id"]
|
|
54
|
+
self._sandbox_id = session.get("active_sandbox_id", "")
|
|
55
|
+
self._workspace_id = session.get("workspace_id", "")
|
|
56
|
+
self._opts = options
|
|
57
|
+
self._closed = False
|
|
58
|
+
self._operation_by_exec_id: dict[str, str] = {}
|
|
59
|
+
self._lock = threading.Lock()
|
|
60
|
+
|
|
61
|
+
self._renew_interval = options.renew_interval
|
|
62
|
+
self._renew_extend = options.renew_extend
|
|
63
|
+
self._renew_stop = threading.Event()
|
|
64
|
+
self._renew_thread: threading.Thread | None = None
|
|
65
|
+
self._heartbeat_error: Exception | None = None
|
|
66
|
+
if options.heartbeat and self._renew_interval > 0 and self._renew_extend > 0:
|
|
67
|
+
self._renew_thread = threading.Thread(
|
|
68
|
+
target=self._renew_loop,
|
|
69
|
+
name=f"sandbox-renew-{self._session_id[:8]}",
|
|
70
|
+
daemon=True,
|
|
71
|
+
)
|
|
72
|
+
self._renew_thread.start()
|
|
73
|
+
|
|
74
|
+
log.info(
|
|
75
|
+
"SandboxSession opened: session=%s sandbox=%s profile=%s expires=%s heartbeat=%s",
|
|
76
|
+
self._session_id,
|
|
77
|
+
self._sandbox_id,
|
|
78
|
+
options.profile,
|
|
79
|
+
session.get("expires_at"),
|
|
80
|
+
self._renew_thread is not None,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def _renew_loop(self) -> None:
|
|
84
|
+
while not self._renew_stop.wait(self._renew_interval):
|
|
85
|
+
try:
|
|
86
|
+
res = self._client.renew_session(self._session_id, self._renew_extend, timeout=15, max_retries=1)
|
|
87
|
+
log.debug(
|
|
88
|
+
"Lease renewed: session=%s new_expires=%s",
|
|
89
|
+
self._session_id,
|
|
90
|
+
res.get("expires_at") if res else None,
|
|
91
|
+
)
|
|
92
|
+
except Exception as e: # noqa: BLE001
|
|
93
|
+
self._heartbeat_error = e
|
|
94
|
+
self._renew_stop.set()
|
|
95
|
+
log.warning("Heartbeat suspended; inspect original session before renewing: session=%s",
|
|
96
|
+
self._session_id)
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def heartbeat_error(self) -> Exception | None:
|
|
101
|
+
return self._heartbeat_error
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def session_id(self) -> str:
|
|
105
|
+
return self._session_id
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def sandbox_id(self) -> str:
|
|
109
|
+
return self._sandbox_id
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def workspace_id(self) -> str:
|
|
113
|
+
"""Durable workspace identity that can outlive the current container."""
|
|
114
|
+
return self._workspace_id
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def session(self) -> dict[str, Any]:
|
|
118
|
+
return self._session
|
|
119
|
+
|
|
120
|
+
def __enter__(self) -> SandboxSession:
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
def __exit__(self, *_) -> None:
|
|
124
|
+
self.close()
|
|
125
|
+
|
|
126
|
+
# ─── Execution ─────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
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
|
+
Args:
|
|
141
|
+
code_or_cmd: Source code string or command list.
|
|
142
|
+
lang: Language hint (python, shell, javascript) when code_or_cmd is code.
|
|
143
|
+
timeout: Execution timeout in seconds.
|
|
144
|
+
working_dir: Working directory override.
|
|
145
|
+
env: Extra environment variables(与 SandboxOptions.env 合并,单次调用优先)。
|
|
146
|
+
operation_id: 原生幂等身份。未传时本地生成 uuid4;生产必须显式传入并
|
|
147
|
+
持久保存,否则进程崩溃后无法按 operation_id 恢复查询。
|
|
148
|
+
"""
|
|
149
|
+
kwargs: dict[str, Any] = {}
|
|
150
|
+
if isinstance(code_or_cmd, list):
|
|
151
|
+
kwargs["command"] = code_or_cmd
|
|
152
|
+
else:
|
|
153
|
+
kwargs["code"] = code_or_cmd
|
|
154
|
+
if lang:
|
|
155
|
+
kwargs["language"] = lang
|
|
156
|
+
if working_dir:
|
|
157
|
+
kwargs["working_dir"] = working_dir
|
|
158
|
+
merged_env = {**(self._opts.env or {}), **(env or {})}
|
|
159
|
+
if merged_env:
|
|
160
|
+
kwargs["env"] = merged_env
|
|
161
|
+
if timeout:
|
|
162
|
+
kwargs["timeout_seconds"] = timeout
|
|
163
|
+
operation_id = operation_id or uuid.uuid4().hex
|
|
164
|
+
kwargs["operation_id"] = operation_id
|
|
165
|
+
try:
|
|
166
|
+
result = self._client.exec_named_session(self._session_id, **kwargs)
|
|
167
|
+
except APIError as error:
|
|
168
|
+
if not error.retryable:
|
|
169
|
+
raise
|
|
170
|
+
raise ExecRecoveryError(
|
|
171
|
+
self._session_id,
|
|
172
|
+
operation_id,
|
|
173
|
+
phase="submit",
|
|
174
|
+
cause=error,
|
|
175
|
+
) from error
|
|
176
|
+
except TransportError as error:
|
|
177
|
+
raise ExecRecoveryError(
|
|
178
|
+
self._session_id,
|
|
179
|
+
operation_id,
|
|
180
|
+
phase="submit",
|
|
181
|
+
cause=error,
|
|
182
|
+
) from error
|
|
183
|
+
self._record_execution(result)
|
|
184
|
+
return _to_exec_result(result)
|
|
185
|
+
|
|
186
|
+
def run_python(self, code: str, timeout: int = JOB_TIMEOUT, *, operation_id: str | None = None) -> ExecResult:
|
|
187
|
+
return self.run(code, lang="python", timeout=timeout, operation_id=operation_id)
|
|
188
|
+
|
|
189
|
+
def run_shell(self, script: str, timeout: int = JOB_TIMEOUT, *, operation_id: str | None = None) -> ExecResult:
|
|
190
|
+
return self.run(script, lang="shell", timeout=timeout, operation_id=operation_id)
|
|
191
|
+
|
|
192
|
+
def run_node(self, code: str, timeout: int = JOB_TIMEOUT, *, operation_id: str | None = None) -> ExecResult:
|
|
193
|
+
return self.run(code, lang="javascript", timeout=timeout, operation_id=operation_id)
|
|
194
|
+
|
|
195
|
+
def run_command(self, *command: str) -> ExecResult:
|
|
196
|
+
return self.run(list(command))
|
|
197
|
+
|
|
198
|
+
# ─── Async Execution ───────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
def run_async(
|
|
201
|
+
self,
|
|
202
|
+
code_or_cmd: str | list[str],
|
|
203
|
+
*,
|
|
204
|
+
lang: str | None = None,
|
|
205
|
+
timeout: int | None = None,
|
|
206
|
+
working_dir: str | None = None,
|
|
207
|
+
env: dict[str, str] | None = None,
|
|
208
|
+
operation_id: str | None = None,
|
|
209
|
+
) -> str:
|
|
210
|
+
"""Submit asynchronous execution; returns exec_id for polling.
|
|
211
|
+
|
|
212
|
+
operation_id 语义同 run:生产必须显式传入并持久保存。
|
|
213
|
+
"""
|
|
214
|
+
kwargs: dict[str, Any] = {}
|
|
215
|
+
if isinstance(code_or_cmd, list):
|
|
216
|
+
kwargs["command"] = code_or_cmd
|
|
217
|
+
else:
|
|
218
|
+
kwargs["code"] = code_or_cmd
|
|
219
|
+
if lang:
|
|
220
|
+
kwargs["language"] = lang
|
|
221
|
+
if working_dir:
|
|
222
|
+
kwargs["working_dir"] = working_dir
|
|
223
|
+
merged_env = {**(self._opts.env or {}), **(env or {})}
|
|
224
|
+
if merged_env:
|
|
225
|
+
kwargs["env"] = merged_env
|
|
226
|
+
if timeout:
|
|
227
|
+
kwargs["timeout_seconds"] = timeout
|
|
228
|
+
operation_id = operation_id or uuid.uuid4().hex
|
|
229
|
+
kwargs["operation_id"] = operation_id
|
|
230
|
+
try:
|
|
231
|
+
result = self._client.exec_session_async(self._session_id, **kwargs)
|
|
232
|
+
except (APIError, TransportError) as error:
|
|
233
|
+
raise ExecRecoveryError(
|
|
234
|
+
self._session_id,
|
|
235
|
+
operation_id,
|
|
236
|
+
phase="submit",
|
|
237
|
+
cause=error,
|
|
238
|
+
) from error
|
|
239
|
+
exec_id = result.get("exec_id") if isinstance(result, dict) else None
|
|
240
|
+
if not isinstance(exec_id, str) or not exec_id.strip():
|
|
241
|
+
raise ExecRecoveryError(
|
|
242
|
+
self._session_id,
|
|
243
|
+
operation_id,
|
|
244
|
+
phase="submit",
|
|
245
|
+
cause=ValueError("async exec response did not include exec_id"),
|
|
246
|
+
)
|
|
247
|
+
self._operation_by_exec_id[exec_id] = operation_id
|
|
248
|
+
return exec_id
|
|
249
|
+
|
|
250
|
+
def list_execs(self, *, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
|
251
|
+
return self._client.list_session_execs(self._session_id, limit=limit, cursor=cursor)
|
|
252
|
+
|
|
253
|
+
def get_exec_by_operation(self, operation_id: str) -> dict[str, Any]:
|
|
254
|
+
"""Look up an ExecRecord by its durable Session-scoped operation identity."""
|
|
255
|
+
if not operation_id:
|
|
256
|
+
raise ValueError("operation_id is required")
|
|
257
|
+
try:
|
|
258
|
+
result = self._client.get_exec_by_operation(self._session_id, operation_id)
|
|
259
|
+
except Exception as error:
|
|
260
|
+
raise ExecRecoveryError(
|
|
261
|
+
self._session_id,
|
|
262
|
+
operation_id,
|
|
263
|
+
phase="lookup",
|
|
264
|
+
cause=error,
|
|
265
|
+
) from error
|
|
266
|
+
exec_id = result.get("exec_id")
|
|
267
|
+
if exec_id:
|
|
268
|
+
self._operation_by_exec_id[exec_id] = operation_id
|
|
269
|
+
return result
|
|
270
|
+
|
|
271
|
+
def wait_exec(
|
|
272
|
+
self, exec_id: str, poll_interval: float = 1.0, max_wait: float = 300.0, *, operation_id: str | None = None
|
|
273
|
+
) -> ExecResult:
|
|
274
|
+
"""Poll get_exec until execution completes or max_wait is exceeded."""
|
|
275
|
+
operation_id = operation_id or self._operation_by_exec_id.get(exec_id, "")
|
|
276
|
+
deadline = time.monotonic() + max_wait
|
|
277
|
+
while time.monotonic() < deadline:
|
|
278
|
+
result = self._observe_exec(exec_id, operation_id)
|
|
279
|
+
operation_id = result.get("operation_id") or operation_id
|
|
280
|
+
if operation_id:
|
|
281
|
+
self._operation_by_exec_id[exec_id] = operation_id
|
|
282
|
+
status = result.get("status", "")
|
|
283
|
+
if status in ("succeeded", "failed", "cancelled", "timed_out", "interrupted"):
|
|
284
|
+
self._record_execution(result)
|
|
285
|
+
return _to_exec_result(result)
|
|
286
|
+
time.sleep(poll_interval)
|
|
287
|
+
raise TimeoutError(f"exec {exec_id} did not complete within {max_wait}s")
|
|
288
|
+
|
|
289
|
+
def wait_exec_by_operation(
|
|
290
|
+
self, operation_id: str, poll_interval: float = 1.0, max_wait: float = 300.0
|
|
291
|
+
) -> ExecResult:
|
|
292
|
+
"""按 operation_id 恢复等待(进程/客户端状态丢失后的恢复路径)。"""
|
|
293
|
+
record = self.get_exec_by_operation(operation_id)
|
|
294
|
+
exec_id = record.get("exec_id")
|
|
295
|
+
if not isinstance(exec_id, str) or not exec_id:
|
|
296
|
+
raise ExecRecoveryError(self._session_id, operation_id, phase="lookup")
|
|
297
|
+
if record.get("status") in ("succeeded", "failed", "cancelled", "timed_out", "interrupted"):
|
|
298
|
+
self._record_execution(record)
|
|
299
|
+
return _to_exec_result(record)
|
|
300
|
+
return self.wait_exec(exec_id, poll_interval, max_wait, operation_id=operation_id)
|
|
301
|
+
|
|
302
|
+
def _observe_exec(self, exec_id: str, operation_id: str) -> dict[str, Any]:
|
|
303
|
+
retries = 2
|
|
304
|
+
for attempt in range(retries + 1):
|
|
305
|
+
try:
|
|
306
|
+
return self._client.get_exec(self._session_id, exec_id)
|
|
307
|
+
except Exception as error:
|
|
308
|
+
retryable = isinstance(error, TransportError) or (isinstance(error, APIError) and error.retryable)
|
|
309
|
+
if retryable and attempt < retries:
|
|
310
|
+
time.sleep(0.2 * (2**attempt))
|
|
311
|
+
continue
|
|
312
|
+
if isinstance(error, APIError) and not error.retryable:
|
|
313
|
+
raise
|
|
314
|
+
raise ExecRecoveryError(
|
|
315
|
+
self._session_id,
|
|
316
|
+
operation_id,
|
|
317
|
+
exec_id=exec_id,
|
|
318
|
+
phase="observe",
|
|
319
|
+
cause=error,
|
|
320
|
+
) from error
|
|
321
|
+
raise AssertionError("unreachable observation retry state")
|
|
322
|
+
|
|
323
|
+
def cancel_exec(self, exec_id: str) -> dict[str, Any]:
|
|
324
|
+
"""Cancel a running async execution (raw ExecRecord; 语义见 Client.cancel_exec)."""
|
|
325
|
+
return self._client.cancel_exec(self._session_id, exec_id)
|
|
326
|
+
|
|
327
|
+
def cancel_exec_receipt(self, exec_id: str, *, operation_id: str | None = None) -> CancelReceipt:
|
|
328
|
+
"""取消并表达「接受 vs 停止确认」(CancelReceipt)。
|
|
329
|
+
|
|
330
|
+
成功返回 outcome 为 accepted / already_terminal / stop_confirmed 的回执;
|
|
331
|
+
取消失败或超时(停止未知)抛 ExecRecoveryError(phase="cancel"),携带
|
|
332
|
+
operation_id 供核对,未确认停止前不得回收资源或重跑替代执行。
|
|
333
|
+
"""
|
|
334
|
+
if not exec_id:
|
|
335
|
+
raise ValueError("exec_id is required")
|
|
336
|
+
operation_id = operation_id or self._operation_by_exec_id.get(exec_id, "")
|
|
337
|
+
try:
|
|
338
|
+
record = self._client.cancel_exec(self._session_id, exec_id)
|
|
339
|
+
except (APIError, TransportError) as error:
|
|
340
|
+
raise ExecRecoveryError(
|
|
341
|
+
self._session_id,
|
|
342
|
+
operation_id,
|
|
343
|
+
exec_id=exec_id,
|
|
344
|
+
phase="cancel",
|
|
345
|
+
cause=error,
|
|
346
|
+
) from error
|
|
347
|
+
return CancelReceipt.from_record(exec_id, record)
|
|
348
|
+
|
|
349
|
+
def lookup_session(self, idempotency_key: str) -> dict[str, Any] | None:
|
|
350
|
+
"""按幂等键查回会话(会话创建响应丢失恢复),404 返回 None。见 Client.lookup_session。"""
|
|
351
|
+
return self._client.lookup_session(idempotency_key)
|
|
352
|
+
|
|
353
|
+
def stream_exec_logs(self, exec_id: str, cursor: int | str = 0) -> Iterator[SSEEvent]:
|
|
354
|
+
"""惰性 SSE 日志流(SSEEvent 迭代器);cursor 见 Client.stream_exec_logs。"""
|
|
355
|
+
return self._client.stream_exec_logs(self._session_id, exec_id, cursor=cursor)
|
|
356
|
+
|
|
357
|
+
def read_exec_logs(
|
|
358
|
+
self, exec_id: str, *, cursor: int | str | None = None, byte_budget: int, max_events: int
|
|
359
|
+
) -> dict[str, Any]:
|
|
360
|
+
"""按预算读取一页日志(events/next_cursor/exhausted/gap_detected)。见 Client.read_exec_logs。"""
|
|
361
|
+
return self._client.read_exec_logs(
|
|
362
|
+
self._session_id, exec_id, cursor=cursor, byte_budget=byte_budget, max_events=max_events
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# ─── File Operations ───────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
def write_file(
|
|
368
|
+
self, path: str, content: bytes | str, *, if_match: str | None = None, create_only: bool = False
|
|
369
|
+
) -> dict[str, Any]:
|
|
370
|
+
return self._client.upload_session_file(
|
|
371
|
+
self._session_id, path, content, if_match=if_match, create_only=create_only
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
def read_file(self, path: str) -> bytes:
|
|
375
|
+
return self._client.download_session_file(self._session_id, path)
|
|
376
|
+
|
|
377
|
+
def list_files(self, path: str = ".", recursive: bool = False, limit: int | None = None) -> dict[str, Any]:
|
|
378
|
+
return self._client.list_session_files(self._session_id, path=path, recursive=recursive, limit=limit)
|
|
379
|
+
|
|
380
|
+
def stat_file(self, path: str) -> dict[str, Any]:
|
|
381
|
+
return self._client.stat_session_file(self._session_id, path)
|
|
382
|
+
|
|
383
|
+
def mkdir(self, path: str) -> dict[str, Any]:
|
|
384
|
+
return self._client.mkdir_session_dir(self._session_id, path)
|
|
385
|
+
|
|
386
|
+
def remove(self, path: str, recursive: bool = False) -> dict[str, Any] | None:
|
|
387
|
+
return self._client.remove_session_file(self._session_id, path, recursive=recursive)
|
|
388
|
+
|
|
389
|
+
# ─── Lifecycle ─────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
def suspend(self) -> dict[str, Any]:
|
|
392
|
+
"""Release the runtime while keeping the session and workspace."""
|
|
393
|
+
session = self._client.suspend_session(self._session_id)
|
|
394
|
+
self._session = session or self._session
|
|
395
|
+
self._sandbox_id = (session or {}).get("active_sandbox_id", "")
|
|
396
|
+
return session
|
|
397
|
+
|
|
398
|
+
def resume(self) -> dict[str, Any]:
|
|
399
|
+
"""Restore the runtime while preserving the logical session and workspace."""
|
|
400
|
+
session = self._client.resume_session(self._session_id)
|
|
401
|
+
self._session = session or self._session
|
|
402
|
+
self._sandbox_id = (session or {}).get("active_sandbox_id", "")
|
|
403
|
+
return session
|
|
404
|
+
|
|
405
|
+
def close(self) -> None:
|
|
406
|
+
with self._lock:
|
|
407
|
+
if self._closed:
|
|
408
|
+
return
|
|
409
|
+
self._renew_stop.set()
|
|
410
|
+
if self._renew_thread is not None:
|
|
411
|
+
self._renew_thread.join(timeout=20.0)
|
|
412
|
+
log.info("Closing session: %s", self._session_id)
|
|
413
|
+
self._client.delete_session(self._session_id)
|
|
414
|
+
with self._lock:
|
|
415
|
+
self._closed = True
|
|
416
|
+
|
|
417
|
+
# ─── Internal ──────────────────────────────────────────────────────
|
|
418
|
+
|
|
419
|
+
def _record_execution(self, result: dict[str, Any] | None) -> None:
|
|
420
|
+
if not result:
|
|
421
|
+
return
|
|
422
|
+
sandbox_id = result.get("sandbox_id") or ""
|
|
423
|
+
if sandbox_id:
|
|
424
|
+
self._sandbox_id = sandbox_id
|
|
425
|
+
self._session["active_sandbox_id"] = sandbox_id
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# ─── Factory Functions ─────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def new_sandbox(
|
|
432
|
+
client: Client,
|
|
433
|
+
*,
|
|
434
|
+
profile: str | None = None,
|
|
435
|
+
hints: list[str] | None = None,
|
|
436
|
+
strict: bool = False,
|
|
437
|
+
description: str | None = None,
|
|
438
|
+
resolution_id: str | None = None,
|
|
439
|
+
workspace_id: str | None = None,
|
|
440
|
+
env: dict[str, str] | None = None,
|
|
441
|
+
metadata: dict[str, str] | None = None,
|
|
442
|
+
ttl_seconds: int = SESSION_TTL,
|
|
443
|
+
workspace_retention: str | None = None,
|
|
444
|
+
workspace_ttl_seconds: int | None = None,
|
|
445
|
+
idempotency_key: str | None = None,
|
|
446
|
+
heartbeat: bool = True,
|
|
447
|
+
renew_interval: int = RENEW_INTERVAL,
|
|
448
|
+
renew_extend: int = RENEW_EXTEND,
|
|
449
|
+
) -> SandboxSession:
|
|
450
|
+
"""Create a session-backed SandboxSession helper with built-in heartbeat."""
|
|
451
|
+
opts = SandboxOptions(
|
|
452
|
+
profile=profile,
|
|
453
|
+
hints=hints,
|
|
454
|
+
strict=strict,
|
|
455
|
+
description=description,
|
|
456
|
+
resolution_id=resolution_id,
|
|
457
|
+
workspace_id=workspace_id,
|
|
458
|
+
env=env,
|
|
459
|
+
metadata=metadata or {},
|
|
460
|
+
ttl_seconds=ttl_seconds,
|
|
461
|
+
workspace_retention=workspace_retention,
|
|
462
|
+
workspace_ttl_seconds=workspace_ttl_seconds,
|
|
463
|
+
idempotency_key=idempotency_key,
|
|
464
|
+
heartbeat=heartbeat,
|
|
465
|
+
renew_interval=renew_interval,
|
|
466
|
+
renew_extend=renew_extend,
|
|
467
|
+
)
|
|
468
|
+
# 会话创建不接受持久环境变量(协议拒绝 env 字段);opts.env 作为默认执行
|
|
469
|
+
# 环境变量在 run/run_async 时合并下发。
|
|
470
|
+
session = client.create_session(
|
|
471
|
+
workspace_id=opts.workspace_id,
|
|
472
|
+
profile=opts.profile,
|
|
473
|
+
hints=opts.hints,
|
|
474
|
+
strict=opts.strict,
|
|
475
|
+
description=opts.description,
|
|
476
|
+
resolution_id=opts.resolution_id,
|
|
477
|
+
state_policy="session",
|
|
478
|
+
ttl_seconds=opts.ttl_seconds,
|
|
479
|
+
metadata=opts.metadata,
|
|
480
|
+
workspace_retention=opts.workspace_retention,
|
|
481
|
+
workspace_ttl_seconds=opts.workspace_ttl_seconds,
|
|
482
|
+
idempotency_key=opts.idempotency_key,
|
|
483
|
+
)
|
|
484
|
+
return SandboxSession(client, session, opts)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
# ─── Layer 3: Quick Helpers (Job mode, no session) ─────────────────────────
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def quick_run(client: Client, command: list[str], **kwargs: Any) -> ExecResult:
|
|
491
|
+
"""One-shot Job execution (no session). Returns ExecResult."""
|
|
492
|
+
submitted = client.submit_job(command=command, **kwargs)
|
|
493
|
+
result = client.wait_job(submitted["job_id"])
|
|
494
|
+
return _to_exec_result(result)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def quick_python(client: Client, code: str, **kwargs: Any) -> ExecResult:
|
|
498
|
+
"""One-shot Python Job execution (no session, auto profile=python)."""
|
|
499
|
+
kwargs.setdefault("profile", "python")
|
|
500
|
+
submitted = client.submit_job(code=code, **kwargs)
|
|
501
|
+
result = client.wait_job(submitted["job_id"])
|
|
502
|
+
return _to_exec_result(result)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# ─── Helpers ───────────────────────────────────────────────────────────────
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _parse_effective_environment(data: dict[str, Any] | None) -> EffectiveEnvironment | None:
|
|
509
|
+
"""Parse effective_environment from a response dict."""
|
|
510
|
+
if not data:
|
|
511
|
+
return None
|
|
512
|
+
ee = data.get("effective_environment")
|
|
513
|
+
if not ee or not isinstance(ee, dict):
|
|
514
|
+
return None
|
|
515
|
+
return EffectiveEnvironment(
|
|
516
|
+
profile_name=ee.get("profile_name", ""),
|
|
517
|
+
profile_revision=ee.get("profile_revision", ""),
|
|
518
|
+
selection_mode=ee.get("selection_mode", ""),
|
|
519
|
+
selection_reason=ee.get("selection_reason") or [],
|
|
520
|
+
capabilities=ee.get("capabilities") or [],
|
|
521
|
+
degraded=ee.get("degraded", False),
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _to_exec_result(result: dict[str, Any] | None) -> ExecResult:
|
|
526
|
+
if not result:
|
|
527
|
+
return ExecResult(exit_code=-1, stdout="", stderr="", error_code="no_response")
|
|
528
|
+
return ExecResult(
|
|
529
|
+
exit_code=result.get("exit_code", -1),
|
|
530
|
+
stdout=result.get("stdout", ""),
|
|
531
|
+
stderr=result.get("stderr", ""),
|
|
532
|
+
error_code=result.get("error_code", ""),
|
|
533
|
+
stdout_truncated=result.get("stdout_truncated", False),
|
|
534
|
+
stderr_truncated=result.get("stderr_truncated", False),
|
|
535
|
+
effective_environment=_parse_effective_environment(result),
|
|
536
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""只读执行记录分页;不创建、恢复或重新提交执行。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import quote, urlencode
|
|
7
|
+
|
|
8
|
+
from .errors import ProtocolError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def validate_exec_record(
|
|
12
|
+
value: Any, session: str, execution: str | None = None, operation: str | None = None
|
|
13
|
+
) -> dict[str, Any]:
|
|
14
|
+
if not isinstance(value, dict) or value.get("session_id") != session:
|
|
15
|
+
raise ProtocolError("execution receipt original session mismatch")
|
|
16
|
+
if (
|
|
17
|
+
not isinstance(value.get("exec_id"), str)
|
|
18
|
+
or not value["exec_id"]
|
|
19
|
+
or not isinstance(value.get("operation_id"), str)
|
|
20
|
+
or not value["operation_id"]
|
|
21
|
+
):
|
|
22
|
+
raise ProtocolError("execution receipt original identity missing")
|
|
23
|
+
if (execution is not None and value["exec_id"] != execution) or (
|
|
24
|
+
operation is not None and value["operation_id"] != operation
|
|
25
|
+
):
|
|
26
|
+
raise ProtocolError("execution receipt original operation mismatch")
|
|
27
|
+
if value.get("status") not in {"queued", "running", "succeeded", "failed", "cancelled", "timed_out", "interrupted"}:
|
|
28
|
+
raise ProtocolError("invalid execution receipt status")
|
|
29
|
+
if "stop_confirmed" in value and not isinstance(value["stop_confirmed"], bool):
|
|
30
|
+
raise ProtocolError("invalid physical stop evidence")
|
|
31
|
+
if value.get("stop_confirmed") and value["status"] in {"queued", "running"}:
|
|
32
|
+
raise ProtocolError("nonterminal execution cannot confirm physical stop")
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SessionExecClientMixin:
|
|
37
|
+
def _request(self, *args: Any, **kwargs: Any) -> Any:
|
|
38
|
+
raise NotImplementedError
|
|
39
|
+
|
|
40
|
+
def list_session_execs(
|
|
41
|
+
self, session_id: str, *, limit: int | None = None, cursor: str | None = None
|
|
42
|
+
) -> dict[str, Any]:
|
|
43
|
+
if not isinstance(session_id, str) or not session_id or any(ord(char) < 32 for char in session_id):
|
|
44
|
+
raise ValueError("session_id must be a nonempty identity")
|
|
45
|
+
if limit is not None and (isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 100):
|
|
46
|
+
raise ValueError("limit must be an integer between 1 and 100")
|
|
47
|
+
if cursor is not None and (not isinstance(cursor, str) or not cursor):
|
|
48
|
+
raise ValueError("cursor must be a nonempty string")
|
|
49
|
+
params: dict[str, Any] = {}
|
|
50
|
+
if limit is not None:
|
|
51
|
+
params["limit"] = limit
|
|
52
|
+
if cursor is not None:
|
|
53
|
+
params["cursor"] = cursor
|
|
54
|
+
target = f"/v1/sessions/{quote(session_id, safe='')}/execs"
|
|
55
|
+
if params:
|
|
56
|
+
target += f"?{urlencode(params)}"
|
|
57
|
+
page = self._request("GET", target)
|
|
58
|
+
if not isinstance(page, dict) or not isinstance(page.get("items"), list):
|
|
59
|
+
raise ProtocolError("invalid execution history page")
|
|
60
|
+
total = page.get("total")
|
|
61
|
+
if (
|
|
62
|
+
isinstance(total, bool)
|
|
63
|
+
or not isinstance(total, int)
|
|
64
|
+
or total < len(page["items"])
|
|
65
|
+
or len(page["items"]) > (limit or 50)
|
|
66
|
+
):
|
|
67
|
+
raise ProtocolError("invalid execution history page size")
|
|
68
|
+
if "next_cursor" in page and not isinstance(page["next_cursor"], str):
|
|
69
|
+
raise ProtocolError("invalid opaque execution page cursor")
|
|
70
|
+
seen = set()
|
|
71
|
+
for item in page["items"]:
|
|
72
|
+
validate_exec_record(item, session_id)
|
|
73
|
+
if item["exec_id"] in seen:
|
|
74
|
+
raise ProtocolError("duplicate execution history identity")
|
|
75
|
+
seen.add(item["exec_id"])
|
|
76
|
+
return page
|