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,993 @@
|
|
|
1
|
+
"""Low-level HTTP client for the Genesis Sandbox service (stdlib only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import email.message
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import Callable, Iterator
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .control_probe import RuntimeControlClientMixin
|
|
16
|
+
from .errors import APIError, ProtocolError, TransportError
|
|
17
|
+
from .execution_governance import trusted_governance as validate_trusted_governance
|
|
18
|
+
from .invocation_output import output_request
|
|
19
|
+
from .json_codec import loads as decode_response_json
|
|
20
|
+
from .session_exec_client import SessionExecClientMixin, validate_exec_record
|
|
21
|
+
from .types import SSEEvent
|
|
22
|
+
from .workspace_client import WorkspaceClientMixin
|
|
23
|
+
from .workspace_lifecycle import WorkspaceLifecycleClientMixin
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
27
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
28
|
+
# Bearer 身份只发往配置的服务,禁止重定向携带凭据。
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _urlopen(request, timeout=None):
|
|
33
|
+
return urllib.request.build_opener(_NoRedirect()).open(request, timeout=timeout)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _header_message(headers: dict[str, str]) -> email.message.Message:
|
|
37
|
+
"""dict 头转 email.message.Message(urllib.error.HTTPError 的类型要求)。"""
|
|
38
|
+
message = email.message.Message()
|
|
39
|
+
for key, value in headers.items():
|
|
40
|
+
message[key] = value
|
|
41
|
+
return message
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _validate_base_url(base_url: str) -> str:
|
|
48
|
+
parsed = urllib.parse.urlparse(base_url)
|
|
49
|
+
if (
|
|
50
|
+
parsed.scheme not in {"http", "https"}
|
|
51
|
+
or not parsed.netloc
|
|
52
|
+
or parsed.username
|
|
53
|
+
or parsed.password
|
|
54
|
+
or parsed.query
|
|
55
|
+
or parsed.fragment
|
|
56
|
+
):
|
|
57
|
+
raise ValueError("base_url must be an absolute http(s) URL")
|
|
58
|
+
# 与 Platform 端点约束一致:拒绝内嵌凭据/查询/片段,也拒绝携带路径前缀。
|
|
59
|
+
if parsed.path not in {"", "/"}:
|
|
60
|
+
raise ValueError("base_url must not contain a path")
|
|
61
|
+
if parsed.scheme != "https" and parsed.hostname not in _LOOPBACK_HOSTS:
|
|
62
|
+
raise ValueError("non-https base_url is only allowed for loopback hosts (localhost/127.0.0.1/::1)")
|
|
63
|
+
return base_url.rstrip("/")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _validate_token(token: str) -> str:
|
|
67
|
+
"""拒绝空白边缘与 CR/LF,避免 Authorization 头注入。"""
|
|
68
|
+
if not isinstance(token, str) or not token or token.strip() != token or any(c in token for c in "\r\n"):
|
|
69
|
+
raise ValueError("token must be a non-empty string without surrounding whitespace or CR/LF")
|
|
70
|
+
return token
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _opaque_cursor_to_int(cursor: int | str | None, name: str = "cursor") -> int:
|
|
74
|
+
"""游标对外保持不透明字符串;此处仅内部换算为服务端整数游标。"""
|
|
75
|
+
if cursor is None:
|
|
76
|
+
return 0
|
|
77
|
+
try:
|
|
78
|
+
value = int(str(cursor).strip())
|
|
79
|
+
except ValueError:
|
|
80
|
+
raise ValueError(f"{name} is opaque; pass the next_cursor value returned by read_exec_logs") from None
|
|
81
|
+
if value < 0:
|
|
82
|
+
raise ValueError(f"{name} must not be negative")
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_DEFAULT_TIMEOUT = object()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _build_environment(
|
|
90
|
+
profile: str | None = None,
|
|
91
|
+
hints: list[str] | None = None,
|
|
92
|
+
strict: bool = False,
|
|
93
|
+
description: str | None = None,
|
|
94
|
+
profile_revision: str | None = None,
|
|
95
|
+
) -> dict[str, Any] | None:
|
|
96
|
+
"""构造精确 profile 或能力 hints;两种选择方式互斥。"""
|
|
97
|
+
if profile and hints:
|
|
98
|
+
raise ValueError("profile and hints are mutually exclusive")
|
|
99
|
+
if profile_revision and not profile:
|
|
100
|
+
raise ValueError("profile_revision requires profile")
|
|
101
|
+
if profile:
|
|
102
|
+
exact_profile: dict[str, Any] = {"name": profile}
|
|
103
|
+
if profile_revision:
|
|
104
|
+
exact_profile["revision"] = profile_revision
|
|
105
|
+
return {"profile": exact_profile}
|
|
106
|
+
if hints:
|
|
107
|
+
h: dict[str, Any] = {"capabilities": hints}
|
|
108
|
+
if strict:
|
|
109
|
+
h["strict"] = True
|
|
110
|
+
if description:
|
|
111
|
+
h["description"] = description
|
|
112
|
+
return {"hints": h}
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _validate_resolution(environment: dict[str, Any] | None, resolution_id: str | None) -> None:
|
|
117
|
+
if environment and resolution_id:
|
|
118
|
+
raise ValueError("resolution_id and environment selector are mutually exclusive")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Client(WorkspaceClientMixin, SessionExecClientMixin, WorkspaceLifecycleClientMixin, RuntimeControlClientMixin):
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
base_url: str,
|
|
125
|
+
token: str | None = None,
|
|
126
|
+
timeout: float = 30,
|
|
127
|
+
*,
|
|
128
|
+
max_attempts: int = 3,
|
|
129
|
+
retry_base_delay: float = 0.2,
|
|
130
|
+
user_agent: str = "genesis-sandbox-client-python/1",
|
|
131
|
+
token_provider: Callable[[], str] | None = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
"""创建绑定服务端点的客户端。
|
|
134
|
+
|
|
135
|
+
``token_provider``:可选的凭据回调 ``Callable[[], str]``,在每次请求发出前
|
|
136
|
+
取值(含每次重试),优先于固定 ``token``;返回空值时回退到固定 token。
|
|
137
|
+
适用于短期凭据轮转。凭据只发往配置的 base_url,不随重定向转发。
|
|
138
|
+
"""
|
|
139
|
+
self.base_url = _validate_base_url(base_url)
|
|
140
|
+
if token is not None:
|
|
141
|
+
token = _validate_token(token)
|
|
142
|
+
if token_provider is not None and not callable(token_provider):
|
|
143
|
+
raise ValueError("token_provider must be callable")
|
|
144
|
+
if timeout <= 0:
|
|
145
|
+
raise ValueError("timeout must be positive")
|
|
146
|
+
self.token = token
|
|
147
|
+
self.token_provider = token_provider
|
|
148
|
+
self.timeout = timeout
|
|
149
|
+
self.max_attempts = max(1, int(max_attempts))
|
|
150
|
+
self.retry_base_delay = max(0.0, float(retry_base_delay))
|
|
151
|
+
self.user_agent = user_agent
|
|
152
|
+
|
|
153
|
+
def _auth_token(self) -> str | None:
|
|
154
|
+
"""每请求前解析凭据:token_provider 优先于固定 token。"""
|
|
155
|
+
if self.token_provider is not None:
|
|
156
|
+
provided = self.token_provider()
|
|
157
|
+
if provided:
|
|
158
|
+
return _validate_token(provided)
|
|
159
|
+
return self.token
|
|
160
|
+
|
|
161
|
+
def _request(
|
|
162
|
+
self,
|
|
163
|
+
method: str,
|
|
164
|
+
path: str,
|
|
165
|
+
body: dict[str, Any] | None = None,
|
|
166
|
+
timeout: float | None = None,
|
|
167
|
+
max_retries: int | None = None,
|
|
168
|
+
deadline_seconds: float | None = None,
|
|
169
|
+
) -> Any:
|
|
170
|
+
# 幂等身份用于查询原结果,不证明未知写操作可以自动重放。
|
|
171
|
+
max_attempts = self._attempts_for(method)
|
|
172
|
+
if max_retries is not None and self._is_idempotent(method):
|
|
173
|
+
max_attempts = max(1, int(max_retries))
|
|
174
|
+
delay_seconds = self.retry_base_delay
|
|
175
|
+
last_err: BaseException | None = None
|
|
176
|
+
|
|
177
|
+
for attempt in range(max_attempts):
|
|
178
|
+
url = f"{self.base_url}{path}"
|
|
179
|
+
headers = {
|
|
180
|
+
"Content-Type": "application/json",
|
|
181
|
+
"Accept": "application/json",
|
|
182
|
+
"User-Agent": self.user_agent,
|
|
183
|
+
}
|
|
184
|
+
token = self._auth_token()
|
|
185
|
+
if token:
|
|
186
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
187
|
+
|
|
188
|
+
data = None
|
|
189
|
+
if body is not None:
|
|
190
|
+
data = json.dumps(body).encode("utf-8")
|
|
191
|
+
|
|
192
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
if deadline_seconds is None:
|
|
196
|
+
opened = _urlopen(req, timeout=self.timeout if timeout is None else timeout)
|
|
197
|
+
else:
|
|
198
|
+
from .deadline_http import deadline_urlopen
|
|
199
|
+
|
|
200
|
+
opened = deadline_urlopen(req, deadline_seconds)
|
|
201
|
+
with opened as response:
|
|
202
|
+
if response.status == 204:
|
|
203
|
+
return None
|
|
204
|
+
raw = response.read((16 << 20) + 1)
|
|
205
|
+
if len(raw) > 16 << 20:
|
|
206
|
+
raise TransportError("sandbox JSON response exceeds limit")
|
|
207
|
+
res_body = raw.decode("utf-8")
|
|
208
|
+
if not res_body:
|
|
209
|
+
return None
|
|
210
|
+
try:
|
|
211
|
+
return decode_response_json(res_body)
|
|
212
|
+
except ValueError as exc:
|
|
213
|
+
raise ProtocolError(f"sandbox response is not valid JSON: {exc}") from exc
|
|
214
|
+
except urllib.error.HTTPError as exc:
|
|
215
|
+
last_err = exc
|
|
216
|
+
if self._is_transient(exc.code) and attempt < max_attempts - 1:
|
|
217
|
+
time.sleep(self._retry_after(exc, delay_seconds))
|
|
218
|
+
delay_seconds *= 2
|
|
219
|
+
continue
|
|
220
|
+
raise self._api_error(exc) from None
|
|
221
|
+
except ProtocolError:
|
|
222
|
+
raise
|
|
223
|
+
except Exception as exc:
|
|
224
|
+
last_err = exc
|
|
225
|
+
if attempt < max_attempts - 1:
|
|
226
|
+
time.sleep(delay_seconds)
|
|
227
|
+
delay_seconds *= 2
|
|
228
|
+
continue
|
|
229
|
+
raise TransportError(f"sandbox request failed: {exc}") from exc
|
|
230
|
+
raise TransportError(f"sandbox request failed after {max_attempts} attempts: {last_err}")
|
|
231
|
+
|
|
232
|
+
def _raw_request(
|
|
233
|
+
self,
|
|
234
|
+
method: str,
|
|
235
|
+
path: str,
|
|
236
|
+
data: bytes | None = None,
|
|
237
|
+
content_type: str | None = None,
|
|
238
|
+
extra_headers: dict[str, str] | None = None,
|
|
239
|
+
timeout: Any = _DEFAULT_TIMEOUT,
|
|
240
|
+
):
|
|
241
|
+
max_attempts = self._attempts_for(method) if data is None else 1
|
|
242
|
+
delay_seconds = self.retry_base_delay
|
|
243
|
+
last_err: BaseException | None = None
|
|
244
|
+
|
|
245
|
+
for attempt in range(max_attempts):
|
|
246
|
+
url = f"{self.base_url}{path}"
|
|
247
|
+
headers: dict[str, str] = {}
|
|
248
|
+
if content_type:
|
|
249
|
+
headers["Content-Type"] = content_type
|
|
250
|
+
token = self._auth_token()
|
|
251
|
+
if token:
|
|
252
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
253
|
+
headers["Accept"] = "application/json"
|
|
254
|
+
headers["User-Agent"] = self.user_agent
|
|
255
|
+
if extra_headers:
|
|
256
|
+
headers.update(extra_headers)
|
|
257
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
258
|
+
try:
|
|
259
|
+
request_timeout = self.timeout if timeout is _DEFAULT_TIMEOUT else timeout
|
|
260
|
+
response = _urlopen(req, timeout=request_timeout)
|
|
261
|
+
if response.status == 429 or 500 <= response.status <= 599:
|
|
262
|
+
response.close()
|
|
263
|
+
raise urllib.error.HTTPError(
|
|
264
|
+
url, response.status, "Transient status", _header_message(headers), None
|
|
265
|
+
)
|
|
266
|
+
return response
|
|
267
|
+
except urllib.error.HTTPError as exc:
|
|
268
|
+
last_err = exc
|
|
269
|
+
if self._is_transient(exc.code) and attempt < max_attempts - 1:
|
|
270
|
+
time.sleep(self._retry_after(exc, delay_seconds))
|
|
271
|
+
delay_seconds *= 2
|
|
272
|
+
continue
|
|
273
|
+
raise self._api_error(exc) from None
|
|
274
|
+
except Exception as exc:
|
|
275
|
+
last_err = exc
|
|
276
|
+
if attempt < max_attempts - 1:
|
|
277
|
+
time.sleep(delay_seconds)
|
|
278
|
+
delay_seconds *= 2
|
|
279
|
+
continue
|
|
280
|
+
raise TransportError(f"sandbox raw request failed: {exc}") from exc
|
|
281
|
+
raise TransportError(f"sandbox raw request failed after {max_attempts} attempts: {last_err}")
|
|
282
|
+
|
|
283
|
+
def _attempts_for(self, method: str) -> int:
|
|
284
|
+
return self.max_attempts if self._is_idempotent(method) else 1
|
|
285
|
+
|
|
286
|
+
@staticmethod
|
|
287
|
+
def _is_idempotent(method: str) -> bool:
|
|
288
|
+
return method.upper() in {"GET", "HEAD", "OPTIONS"}
|
|
289
|
+
|
|
290
|
+
@staticmethod
|
|
291
|
+
def _is_transient(status: int) -> bool:
|
|
292
|
+
return status == 429 or status >= 500
|
|
293
|
+
|
|
294
|
+
@staticmethod
|
|
295
|
+
def _retry_after(exc: urllib.error.HTTPError, fallback: float) -> float:
|
|
296
|
+
value = exc.headers.get("Retry-After") if exc.headers else None
|
|
297
|
+
if value is None:
|
|
298
|
+
return fallback
|
|
299
|
+
try:
|
|
300
|
+
return max(0.0, float(value))
|
|
301
|
+
except ValueError:
|
|
302
|
+
return fallback
|
|
303
|
+
|
|
304
|
+
@staticmethod
|
|
305
|
+
def _api_error(exc: urllib.error.HTTPError) -> APIError:
|
|
306
|
+
try:
|
|
307
|
+
raw = exc.read(65536).decode("utf-8", errors="replace")
|
|
308
|
+
payload = decode_response_json(raw)
|
|
309
|
+
except Exception:
|
|
310
|
+
payload = {}
|
|
311
|
+
raw = str(exc.reason)
|
|
312
|
+
details = payload.get("details") if isinstance(payload, dict) else {}
|
|
313
|
+
retry_after = Client._retry_after(exc, 0.0)
|
|
314
|
+
return APIError(
|
|
315
|
+
exc.code,
|
|
316
|
+
payload.get("error_code", "") if isinstance(payload, dict) else "",
|
|
317
|
+
payload.get("message", raw) if isinstance(payload, dict) else raw,
|
|
318
|
+
request_id=payload.get("request_id", "") if isinstance(payload, dict) else "",
|
|
319
|
+
details=details if isinstance(details, dict) else {},
|
|
320
|
+
retry_after=retry_after or None,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
def lease(
|
|
324
|
+
self,
|
|
325
|
+
workspace_id: str | None = None,
|
|
326
|
+
profile: str | None = None,
|
|
327
|
+
hints: list[str] | None = None,
|
|
328
|
+
resolution_id: str | None = None,
|
|
329
|
+
metadata: dict[str, str] | None = None,
|
|
330
|
+
) -> dict[str, Any]:
|
|
331
|
+
payload: dict[str, Any] = {}
|
|
332
|
+
if workspace_id:
|
|
333
|
+
payload["workspace_id"] = workspace_id
|
|
334
|
+
env = _build_environment(profile, hints)
|
|
335
|
+
_validate_resolution(env, resolution_id)
|
|
336
|
+
if env:
|
|
337
|
+
payload["environment"] = env
|
|
338
|
+
if resolution_id:
|
|
339
|
+
payload["resolution_id"] = resolution_id
|
|
340
|
+
if metadata:
|
|
341
|
+
payload["metadata"] = metadata
|
|
342
|
+
return self._request("POST", "/v1/sandboxes:lease", payload)
|
|
343
|
+
|
|
344
|
+
def release(self, sandbox_id: str) -> dict[str, Any]:
|
|
345
|
+
return self._request("POST", f"/v1/sandboxes/{urllib.parse.quote(sandbox_id)}:release")
|
|
346
|
+
|
|
347
|
+
def destroy(self, sandbox_id: str) -> dict[str, Any] | None:
|
|
348
|
+
return self._request("DELETE", f"/v1/sandboxes/{urllib.parse.quote(sandbox_id)}")
|
|
349
|
+
|
|
350
|
+
def list_sandboxes(self) -> dict[str, Any]:
|
|
351
|
+
return self._request("GET", "/v1/sandboxes")
|
|
352
|
+
|
|
353
|
+
def get_sandbox(self, sandbox_id: str) -> dict[str, Any]:
|
|
354
|
+
return self._request("GET", f"/v1/sandboxes/{urllib.parse.quote(sandbox_id)}")
|
|
355
|
+
|
|
356
|
+
def renew(self, sandbox_id: str, extend_seconds: int) -> dict[str, Any]:
|
|
357
|
+
return self._request(
|
|
358
|
+
"POST",
|
|
359
|
+
f"/v1/sandboxes/{urllib.parse.quote(sandbox_id)}:renew",
|
|
360
|
+
{"extend_seconds": int(extend_seconds)},
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
def submit_job(
|
|
364
|
+
self,
|
|
365
|
+
*,
|
|
366
|
+
code: str | None = None,
|
|
367
|
+
command: list[str] | None = None,
|
|
368
|
+
profile: str | None = None,
|
|
369
|
+
hints: list[str] | None = None,
|
|
370
|
+
strict: bool = False,
|
|
371
|
+
description: str | None = None,
|
|
372
|
+
resolution_id: str | None = None,
|
|
373
|
+
timeout_seconds: int | None = None,
|
|
374
|
+
queue_wait_timeout_seconds: int | None = None,
|
|
375
|
+
workspace_id: str | None = None,
|
|
376
|
+
session_id: str | None = None,
|
|
377
|
+
env: dict[str, str] | None = None,
|
|
378
|
+
metadata: dict[str, str] | None = None,
|
|
379
|
+
idempotency_key: str | None = None,
|
|
380
|
+
callback_url: str | None = None,
|
|
381
|
+
input_artifact_ids: list[str] | None = None,
|
|
382
|
+
) -> dict[str, Any]:
|
|
383
|
+
if bool(code) == bool(command):
|
|
384
|
+
raise ValueError("exactly one of code or command is required")
|
|
385
|
+
payload: dict[str, Any] = {}
|
|
386
|
+
environment = _build_environment(profile, hints, strict=strict, description=description)
|
|
387
|
+
_validate_resolution(environment, resolution_id)
|
|
388
|
+
if environment:
|
|
389
|
+
payload["environment"] = environment
|
|
390
|
+
if resolution_id:
|
|
391
|
+
payload["resolution_id"] = resolution_id
|
|
392
|
+
if code:
|
|
393
|
+
payload["code"] = code
|
|
394
|
+
if command:
|
|
395
|
+
payload["command"] = command
|
|
396
|
+
if timeout_seconds:
|
|
397
|
+
payload["timeout_seconds"] = int(timeout_seconds)
|
|
398
|
+
if queue_wait_timeout_seconds:
|
|
399
|
+
payload["queue_wait_timeout_seconds"] = int(queue_wait_timeout_seconds)
|
|
400
|
+
if workspace_id:
|
|
401
|
+
payload["workspace_id"] = workspace_id
|
|
402
|
+
if session_id:
|
|
403
|
+
payload["session_id"] = session_id
|
|
404
|
+
if env:
|
|
405
|
+
payload["env"] = env
|
|
406
|
+
if metadata:
|
|
407
|
+
payload["metadata"] = metadata
|
|
408
|
+
if idempotency_key:
|
|
409
|
+
payload["idempotency_key"] = idempotency_key
|
|
410
|
+
if callback_url:
|
|
411
|
+
payload["callback_url"] = callback_url
|
|
412
|
+
if input_artifact_ids:
|
|
413
|
+
payload["input_artifact_ids"] = input_artifact_ids
|
|
414
|
+
return self._request("POST", "/v1/jobs", payload)
|
|
415
|
+
|
|
416
|
+
def wait_job(self, job_id: str, poll_interval: float = 0.2, max_wait: float = 300.0) -> dict[str, Any]:
|
|
417
|
+
if poll_interval <= 0 or max_wait < 0:
|
|
418
|
+
raise ValueError("poll_interval must be positive and max_wait cannot be negative")
|
|
419
|
+
deadline = time.monotonic() + max_wait
|
|
420
|
+
while time.monotonic() < deadline:
|
|
421
|
+
result = self.get_job(job_id)
|
|
422
|
+
if result.get("status") in {"succeeded", "failed", "cancelled", "timed_out", "interrupted"}:
|
|
423
|
+
return result
|
|
424
|
+
time.sleep(poll_interval)
|
|
425
|
+
raise TimeoutError(f"job {job_id} did not complete within {max_wait}s")
|
|
426
|
+
|
|
427
|
+
def list_jobs(
|
|
428
|
+
self, status: str | None = None, limit: int | None = None, offset: int | None = None
|
|
429
|
+
) -> dict[str, Any]:
|
|
430
|
+
params: dict[str, Any] = {}
|
|
431
|
+
if status:
|
|
432
|
+
params["status"] = status
|
|
433
|
+
if limit:
|
|
434
|
+
params["limit"] = int(limit)
|
|
435
|
+
if offset:
|
|
436
|
+
params["offset"] = int(offset)
|
|
437
|
+
query = urllib.parse.urlencode(params)
|
|
438
|
+
return self._request("GET", "/v1/jobs" + (f"?{query}" if query else ""))
|
|
439
|
+
|
|
440
|
+
def get_job(self, job_id: str) -> dict[str, Any]:
|
|
441
|
+
return self._request("GET", f"/v1/jobs/{urllib.parse.quote(job_id)}")
|
|
442
|
+
|
|
443
|
+
def cancel_job(self, job_id: str) -> dict[str, Any]:
|
|
444
|
+
return self._request("POST", f"/v1/jobs/{urllib.parse.quote(job_id)}:cancel")
|
|
445
|
+
|
|
446
|
+
def upload_job_file(self, job_id: str, name: str, content: bytes | str) -> dict[str, Any]:
|
|
447
|
+
if isinstance(content, str):
|
|
448
|
+
content = content.encode("utf-8")
|
|
449
|
+
path = f"/v1/jobs/{urllib.parse.quote(job_id)}/files?name={urllib.parse.quote(name)}"
|
|
450
|
+
with self._raw_request("POST", path, data=content, content_type="application/octet-stream") as response:
|
|
451
|
+
return decode_response_json(response.read().decode("utf-8"))
|
|
452
|
+
|
|
453
|
+
def download_artifact(self, artifact_id: str) -> bytes:
|
|
454
|
+
path = f"/v1/artifacts/{urllib.parse.quote(artifact_id)}"
|
|
455
|
+
with self._raw_request("GET", path) as response:
|
|
456
|
+
return response.read()
|
|
457
|
+
|
|
458
|
+
def list_job_artifacts(self, job_id: str, offset: int | None = None, limit: int | None = None) -> dict[str, Any]:
|
|
459
|
+
params: dict[str, Any] = {}
|
|
460
|
+
if offset:
|
|
461
|
+
params["offset"] = int(offset)
|
|
462
|
+
if limit:
|
|
463
|
+
params["limit"] = int(limit)
|
|
464
|
+
query = urllib.parse.urlencode(params)
|
|
465
|
+
path = f"/v1/jobs/{urllib.parse.quote(job_id)}/artifacts"
|
|
466
|
+
if query:
|
|
467
|
+
path += f"?{query}"
|
|
468
|
+
return self._request("GET", path)
|
|
469
|
+
|
|
470
|
+
def job_logs(self, job_id: str, cursor: int | str = 0) -> Iterator[SSEEvent]:
|
|
471
|
+
path = f"/v1/jobs/{urllib.parse.quote(job_id)}/logs"
|
|
472
|
+
if cursor:
|
|
473
|
+
path += f"?cursor={int(cursor)}"
|
|
474
|
+
return self._event_stream(path)
|
|
475
|
+
|
|
476
|
+
def exec_session(
|
|
477
|
+
self, sandbox_id: str, command: list[str] | None = None, code: str | None = None, language: str | None = None
|
|
478
|
+
) -> dict[str, Any]:
|
|
479
|
+
payload: dict[str, Any] = {}
|
|
480
|
+
if command:
|
|
481
|
+
payload["command"] = command
|
|
482
|
+
if code:
|
|
483
|
+
payload["code"] = code
|
|
484
|
+
if language:
|
|
485
|
+
payload["language"] = language
|
|
486
|
+
return self._request("POST", f"/v1/sandboxes/{urllib.parse.quote(sandbox_id)}/sessions", payload)
|
|
487
|
+
|
|
488
|
+
def create_session(
|
|
489
|
+
self,
|
|
490
|
+
workspace_id: str | None = None,
|
|
491
|
+
profile: str | None = None,
|
|
492
|
+
hints: list[str] | None = None,
|
|
493
|
+
strict: bool = False,
|
|
494
|
+
description: str | None = None,
|
|
495
|
+
resolution_id: str | None = None,
|
|
496
|
+
state_policy: str | None = None,
|
|
497
|
+
ttl_seconds: int | None = None,
|
|
498
|
+
metadata: dict[str, str] | None = None,
|
|
499
|
+
workspace_retention: str | None = None,
|
|
500
|
+
workspace_ttl_seconds: int | None = None,
|
|
501
|
+
idempotency_key: str | None = None,
|
|
502
|
+
) -> dict[str, Any]:
|
|
503
|
+
payload: dict[str, Any] = {}
|
|
504
|
+
if workspace_id:
|
|
505
|
+
payload["workspace_id"] = workspace_id
|
|
506
|
+
environment = _build_environment(profile, hints, strict=strict, description=description)
|
|
507
|
+
_validate_resolution(environment, resolution_id)
|
|
508
|
+
if environment:
|
|
509
|
+
payload["environment"] = environment
|
|
510
|
+
if resolution_id:
|
|
511
|
+
payload["resolution_id"] = resolution_id
|
|
512
|
+
if state_policy:
|
|
513
|
+
payload["state_policy"] = state_policy
|
|
514
|
+
if ttl_seconds:
|
|
515
|
+
payload["ttl_seconds"] = int(ttl_seconds)
|
|
516
|
+
if metadata:
|
|
517
|
+
payload["metadata"] = metadata
|
|
518
|
+
if workspace_retention:
|
|
519
|
+
payload["workspace_retention"] = workspace_retention
|
|
520
|
+
if workspace_ttl_seconds:
|
|
521
|
+
payload["workspace_ttl_seconds"] = int(workspace_ttl_seconds)
|
|
522
|
+
if idempotency_key:
|
|
523
|
+
payload["idempotency_key"] = idempotency_key
|
|
524
|
+
return self._request("POST", "/v1/sessions", payload)
|
|
525
|
+
|
|
526
|
+
def get_session(self, session_id: str) -> dict[str, Any]:
|
|
527
|
+
return self._request("GET", f"/v1/sessions/{urllib.parse.quote(session_id)}")
|
|
528
|
+
|
|
529
|
+
def lookup_session(self, idempotency_key: str) -> dict[str, Any] | None:
|
|
530
|
+
"""按幂等键查回 Session(会话创建响应丢失后的恢复路径)。
|
|
531
|
+
|
|
532
|
+
``GET /v1/sessions:lookup?idempotency_key=...``:命中返回 Session dict;
|
|
533
|
+
404 返回 None(与 get_exec_by_operation 的 APIError 风格不同——lookup 的
|
|
534
|
+
404 是“未找到”结果,不是未创建过的证明)。
|
|
535
|
+
其余错误仍抛 APIError / TransportError。
|
|
536
|
+
|
|
537
|
+
注意:服务端 404 无法区分“从未创建”与“去重/回执保留期已过”,无法证明
|
|
538
|
+
未执行过;必须核对原身份及保留窗口,不得据此自动重新提交。
|
|
539
|
+
"""
|
|
540
|
+
if (
|
|
541
|
+
not isinstance(idempotency_key, str)
|
|
542
|
+
or not idempotency_key.strip()
|
|
543
|
+
or len(idempotency_key.encode("utf-8")) > 128
|
|
544
|
+
or any(ord(char) < 32 for char in idempotency_key)
|
|
545
|
+
):
|
|
546
|
+
raise ValueError("idempotency_key is required")
|
|
547
|
+
try:
|
|
548
|
+
result = self._request(
|
|
549
|
+
"GET", f"/v1/sessions:lookup?idempotency_key={urllib.parse.quote(str(idempotency_key))}"
|
|
550
|
+
)
|
|
551
|
+
if (
|
|
552
|
+
not isinstance(result, dict)
|
|
553
|
+
or not result.get("session_id")
|
|
554
|
+
or result.get("idempotency_key") != idempotency_key
|
|
555
|
+
):
|
|
556
|
+
raise ProtocolError("session lookup original identity mismatch")
|
|
557
|
+
return result
|
|
558
|
+
except APIError as exc:
|
|
559
|
+
if exc.status_code == 404:
|
|
560
|
+
return None
|
|
561
|
+
raise
|
|
562
|
+
|
|
563
|
+
def delete_session(self, session_id: str) -> dict[str, Any] | None:
|
|
564
|
+
return self._request("DELETE", f"/v1/sessions/{urllib.parse.quote(session_id)}")
|
|
565
|
+
|
|
566
|
+
def suspend_session(self, session_id: str) -> dict[str, Any]:
|
|
567
|
+
return self._request("POST", f"/v1/sessions/{urllib.parse.quote(session_id)}:suspend")
|
|
568
|
+
|
|
569
|
+
def resume_session(self, session_id: str) -> dict[str, Any]:
|
|
570
|
+
# 服务端按 Session 锁串行恢复,重复请求复用已激活的 runtime。
|
|
571
|
+
return self._request("POST", f"/v1/sessions/{urllib.parse.quote(session_id, safe='')}:resume")
|
|
572
|
+
|
|
573
|
+
def renew_session(
|
|
574
|
+
self, session_id: str, extend_seconds: int, timeout: float | None = None, max_retries: int = 1
|
|
575
|
+
) -> dict[str, Any]:
|
|
576
|
+
# renew 是无原生幂等身份的 POST:按写重试纪律单次发送(显式调大 max_retries
|
|
577
|
+
# 也会被钳制);重试节奏由调用方(如会话心跳循环)负责。
|
|
578
|
+
return self._request(
|
|
579
|
+
"POST",
|
|
580
|
+
f"/v1/sessions/{urllib.parse.quote(session_id)}:renew",
|
|
581
|
+
{"extend_seconds": int(extend_seconds)},
|
|
582
|
+
timeout=timeout,
|
|
583
|
+
max_retries=max_retries,
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
def exec_named_session(
|
|
587
|
+
self,
|
|
588
|
+
session_id: str,
|
|
589
|
+
command: list[str] | None = None,
|
|
590
|
+
code: str | None = None,
|
|
591
|
+
language: str | None = None,
|
|
592
|
+
working_dir: str | None = None,
|
|
593
|
+
env: dict[str, str] | None = None,
|
|
594
|
+
timeout_seconds: int | None = None,
|
|
595
|
+
timeout: float | None = None,
|
|
596
|
+
operation_id: str | None = None,
|
|
597
|
+
subprocess_policy: str | None = None,
|
|
598
|
+
output_directory: str | None = None,
|
|
599
|
+
) -> dict[str, Any]:
|
|
600
|
+
"""同步执行(阻塞至回执)。
|
|
601
|
+
|
|
602
|
+
``operation_id`` 是本次执行的原生幂等身份:传输重试始终复用同一个值。
|
|
603
|
+
未显式传入时本地生成 uuid4——进程在拿到回执前崩溃则该身份丢失,无法
|
|
604
|
+
lookup 恢复、重复提交也可能绕过去重。**生产必须显式传入并持久保存
|
|
605
|
+
operation_id**,默认生成仅用于交互式/可丢弃场景。
|
|
606
|
+
"""
|
|
607
|
+
if bool(code) == bool(command):
|
|
608
|
+
raise ValueError("exactly one of code or command is required")
|
|
609
|
+
payload: dict[str, Any] = {"operation_id": operation_id or uuid.uuid4().hex, **output_request(output_directory)}
|
|
610
|
+
if subprocess_policy:
|
|
611
|
+
if subprocess_policy not in {"allow", "deny"}:
|
|
612
|
+
raise ValueError("invalid subprocess_policy")
|
|
613
|
+
payload["subprocess_policy"] = subprocess_policy
|
|
614
|
+
if command:
|
|
615
|
+
payload["command"] = command
|
|
616
|
+
if code:
|
|
617
|
+
payload["code"] = code
|
|
618
|
+
if language:
|
|
619
|
+
payload["language"] = language
|
|
620
|
+
if working_dir:
|
|
621
|
+
payload["working_dir"] = working_dir
|
|
622
|
+
if env:
|
|
623
|
+
payload["env"] = env
|
|
624
|
+
if timeout_seconds:
|
|
625
|
+
payload["timeout_seconds"] = int(timeout_seconds)
|
|
626
|
+
return self._request(
|
|
627
|
+
"POST",
|
|
628
|
+
f"/v1/sessions/{urllib.parse.quote(session_id)}/exec",
|
|
629
|
+
payload,
|
|
630
|
+
timeout=timeout if timeout is not None else max(self.timeout, (timeout_seconds or 180) + 15),
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
def exec_session_async(
|
|
634
|
+
self,
|
|
635
|
+
session_id: str,
|
|
636
|
+
command: list[str] | None = None,
|
|
637
|
+
code: str | None = None,
|
|
638
|
+
language: str | None = None,
|
|
639
|
+
working_dir: str | None = None,
|
|
640
|
+
env: dict[str, str] | None = None,
|
|
641
|
+
timeout_seconds: int | None = None,
|
|
642
|
+
callback_url: str | None = None,
|
|
643
|
+
operation_id: str | None = None,
|
|
644
|
+
subprocess_policy: str | None = None,
|
|
645
|
+
output_directory: str | None = None,
|
|
646
|
+
trusted_governance: dict[str, Any] | None = None,
|
|
647
|
+
) -> dict[str, Any]:
|
|
648
|
+
"""异步执行提交,返回含 exec_id 的回执。
|
|
649
|
+
|
|
650
|
+
``operation_id`` 语义同 exec_named_session:重放复用同一身份;未显式传入时
|
|
651
|
+
本地生成 uuid4,进程崩溃后无法 lookup。**生产必须显式传入并持久保存
|
|
652
|
+
operation_id**,响应丢失时用 get_exec_by_operation 按同一身份查回执。
|
|
653
|
+
"""
|
|
654
|
+
if bool(code) == bool(command):
|
|
655
|
+
raise ValueError("exactly one of code or command is required")
|
|
656
|
+
payload: dict[str, Any] = {"operation_id": operation_id or uuid.uuid4().hex, **output_request(output_directory)}
|
|
657
|
+
if subprocess_policy:
|
|
658
|
+
if subprocess_policy not in {"allow", "deny"}:
|
|
659
|
+
raise ValueError("invalid subprocess_policy")
|
|
660
|
+
payload["subprocess_policy"] = subprocess_policy
|
|
661
|
+
if command:
|
|
662
|
+
payload["command"] = command
|
|
663
|
+
if code:
|
|
664
|
+
payload["code"] = code
|
|
665
|
+
if language:
|
|
666
|
+
payload["language"] = language
|
|
667
|
+
if working_dir:
|
|
668
|
+
payload["working_dir"] = working_dir
|
|
669
|
+
if env:
|
|
670
|
+
payload["env"] = env
|
|
671
|
+
if timeout_seconds:
|
|
672
|
+
payload["timeout_seconds"] = int(timeout_seconds)
|
|
673
|
+
if callback_url:
|
|
674
|
+
payload["callback_url"] = callback_url
|
|
675
|
+
if trusted_governance is not None:
|
|
676
|
+
if not operation_id:
|
|
677
|
+
raise ValueError("trusted governance requires a persisted original operation_id")
|
|
678
|
+
payload["trusted_governance"] = validate_trusted_governance(trusted_governance)
|
|
679
|
+
return self._request("POST", f"/v1/sessions/{urllib.parse.quote(session_id)}/exec:async", payload)
|
|
680
|
+
|
|
681
|
+
def get_exec(self, session_id: str, exec_id: str) -> dict[str, Any]:
|
|
682
|
+
result = self._request(
|
|
683
|
+
"GET",
|
|
684
|
+
f"/v1/sessions/{urllib.parse.quote(session_id, safe='')}/execs/{urllib.parse.quote(exec_id, safe='')}",
|
|
685
|
+
)
|
|
686
|
+
return validate_exec_record(result, session_id, execution=exec_id)
|
|
687
|
+
|
|
688
|
+
def get_exec_by_operation(self, session_id: str, operation_id: str) -> dict[str, Any]:
|
|
689
|
+
result = self._request(
|
|
690
|
+
"GET",
|
|
691
|
+
f"/v1/sessions/{urllib.parse.quote(session_id, safe='')}/execs:lookup"
|
|
692
|
+
f"?operation_id={urllib.parse.quote(operation_id, safe='')}",
|
|
693
|
+
)
|
|
694
|
+
return validate_exec_record(result, session_id, operation=operation_id)
|
|
695
|
+
|
|
696
|
+
def cancel_exec(
|
|
697
|
+
self, session_id: str, exec_id: str, timeout: float | None = None, max_retries: int = 1
|
|
698
|
+
) -> dict[str, Any]:
|
|
699
|
+
"""请求取消执行,返回 ExecRecord 原始回执。
|
|
700
|
+
|
|
701
|
+
取消语义(接受 vs 停止确认):服务端在确认停止后才返回终态 ExecRecord
|
|
702
|
+
(内联等待至多 5 秒),超时未确认停止以错误表达。因此:
|
|
703
|
+
|
|
704
|
+
- 成功返回终态 record = 停止已确认(或执行先完成、以实际终态为准);
|
|
705
|
+
- 抛错(含 "execution stop was not confirmed"、网络失败、客户端超时)
|
|
706
|
+
= 停止未知,调用方不得当作已停止。
|
|
707
|
+
|
|
708
|
+
需要稳定 outcome 分类时用 SandboxSession.cancel_exec_receipt /
|
|
709
|
+
CancelReceipt.from_record;停止未知会以 ExecRecoveryError(phase="cancel")
|
|
710
|
+
抛出。取消不做盲重试(max_retries 默认 1)。
|
|
711
|
+
"""
|
|
712
|
+
result = self._request(
|
|
713
|
+
"POST",
|
|
714
|
+
f"/v1/sessions/{urllib.parse.quote(session_id, safe='')}/execs/"
|
|
715
|
+
f"{urllib.parse.quote(exec_id, safe='')}:cancel",
|
|
716
|
+
timeout=timeout,
|
|
717
|
+
max_retries=max_retries,
|
|
718
|
+
)
|
|
719
|
+
return validate_exec_record(result, session_id, execution=exec_id)
|
|
720
|
+
|
|
721
|
+
def stream_exec_logs(self, session_id: str, exec_id: str, cursor: int | str = 0) -> Iterator[SSEEvent]:
|
|
722
|
+
"""SSE 惰性生成器,逐条产出 SSEEvent。
|
|
723
|
+
|
|
724
|
+
``cursor`` 对外应视为不透明字符串(服务端为整数游标,业务层不得解析),
|
|
725
|
+
仅接受 read_exec_logs 返回的 next_cursor 或 0/None 表示从头读取。
|
|
726
|
+
"""
|
|
727
|
+
path = f"/v1/sessions/{urllib.parse.quote(session_id)}/execs/{urllib.parse.quote(exec_id)}/logs"
|
|
728
|
+
if cursor:
|
|
729
|
+
path += f"?cursor={int(cursor)}"
|
|
730
|
+
return self._event_stream(path)
|
|
731
|
+
|
|
732
|
+
def read_exec_logs(
|
|
733
|
+
self, session_id: str, exec_id: str, *, cursor: int | str | None = None, byte_budget: int, max_events: int
|
|
734
|
+
) -> dict[str, Any]:
|
|
735
|
+
"""按预算读取一页日志,返回 events、next_cursor、exhausted、gap_detected。
|
|
736
|
+
|
|
737
|
+
events 项包含 cursor、stream、data、bytes;next_cursor 是继续读取时原样回传的不透明游标。
|
|
738
|
+
byte_budget 计 data 的 UTF-8 字节数,max_events 计事件数;任一耗尽即返回 exhausted=False。
|
|
739
|
+
至少返回一个事件以免单条超预算时空转,其余超预算事件留待下页,next_cursor 不越过它。
|
|
740
|
+
gap_detected 显式标记执行内事件游标不连续,缺失事件不可补齐。
|
|
741
|
+
exhausted 只表示日志流读尽,与执行终态相互独立;执行状态由 get_exec 查询。
|
|
742
|
+
执行仍在运行且预算未耗尽时会等待新事件;流式消费或自行控制等待请用 stream_exec_logs。
|
|
743
|
+
"""
|
|
744
|
+
if not isinstance(byte_budget, int) or isinstance(byte_budget, bool) or byte_budget < 1:
|
|
745
|
+
raise ValueError("byte_budget must be a positive integer")
|
|
746
|
+
if not isinstance(max_events, int) or isinstance(max_events, bool) or max_events < 1:
|
|
747
|
+
raise ValueError("max_events must be a positive integer")
|
|
748
|
+
start = _opaque_cursor_to_int(cursor)
|
|
749
|
+
events: list[dict[str, Any]] = []
|
|
750
|
+
bytes_read = 0
|
|
751
|
+
last_cursor = start
|
|
752
|
+
expected = start + 1
|
|
753
|
+
gap_detected = False
|
|
754
|
+
exhausted = True
|
|
755
|
+
for ev in self.stream_exec_logs(session_id, exec_id, cursor=start):
|
|
756
|
+
try:
|
|
757
|
+
ev_cursor = _opaque_cursor_to_int(ev.id, name="log event cursor")
|
|
758
|
+
except ValueError as exc:
|
|
759
|
+
# 服务端每条日志事件必须携带游标 id;缺失/非法属协议破坏。
|
|
760
|
+
raise ProtocolError(str(exc)) from exc
|
|
761
|
+
size = len(ev.data.encode("utf-8"))
|
|
762
|
+
if events and (bytes_read + size > byte_budget or len(events) >= max_events):
|
|
763
|
+
# 超预算事件不消费:服务端从 next_cursor 之后继续重放,无丢失。
|
|
764
|
+
exhausted = False
|
|
765
|
+
break
|
|
766
|
+
if ev_cursor != expected:
|
|
767
|
+
gap_detected = True
|
|
768
|
+
expected = ev_cursor + 1
|
|
769
|
+
last_cursor = ev_cursor
|
|
770
|
+
bytes_read += size
|
|
771
|
+
events.append({"cursor": str(ev_cursor), "stream": ev.event, "data": ev.data, "bytes": size})
|
|
772
|
+
return {
|
|
773
|
+
"events": events,
|
|
774
|
+
"next_cursor": str(last_cursor),
|
|
775
|
+
"exhausted": exhausted,
|
|
776
|
+
"gap_detected": gap_detected,
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
def _event_stream(self, path: str) -> Iterator[SSEEvent]:
|
|
780
|
+
response = self._raw_request(
|
|
781
|
+
"GET",
|
|
782
|
+
path,
|
|
783
|
+
extra_headers={"Accept": "text/event-stream"},
|
|
784
|
+
timeout=None,
|
|
785
|
+
)
|
|
786
|
+
try:
|
|
787
|
+
event_id = ""
|
|
788
|
+
event_type = "message"
|
|
789
|
+
data_lines: list[str] = []
|
|
790
|
+
for raw_line in response:
|
|
791
|
+
line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
|
|
792
|
+
if line == "":
|
|
793
|
+
if data_lines:
|
|
794
|
+
yield SSEEvent(event_id, event_type, "\n".join(data_lines))
|
|
795
|
+
event_id, event_type, data_lines = "", "message", []
|
|
796
|
+
elif line.startswith("id:"):
|
|
797
|
+
event_id = line[3:].lstrip()
|
|
798
|
+
elif line.startswith("event:"):
|
|
799
|
+
event_type = line[6:].lstrip()
|
|
800
|
+
elif line.startswith("data:"):
|
|
801
|
+
value = line[5:]
|
|
802
|
+
data_lines.append(value[1:] if value.startswith(" ") else value)
|
|
803
|
+
if data_lines:
|
|
804
|
+
yield SSEEvent(event_id, event_type, "\n".join(data_lines))
|
|
805
|
+
finally:
|
|
806
|
+
response.close()
|
|
807
|
+
|
|
808
|
+
def get_session_context(self, session_id: str) -> dict[str, Any]:
|
|
809
|
+
return self._request("GET", f"/v1/sessions/{urllib.parse.quote(session_id)}/context")
|
|
810
|
+
|
|
811
|
+
def patch_session_context(self, session_id: str, cwd: str | None = None) -> dict[str, Any]:
|
|
812
|
+
payload: dict[str, Any] = {}
|
|
813
|
+
if cwd:
|
|
814
|
+
payload["cwd"] = cwd
|
|
815
|
+
return self._request("PATCH", f"/v1/sessions/{urllib.parse.quote(session_id)}/context", payload)
|
|
816
|
+
|
|
817
|
+
def get_catalog(
|
|
818
|
+
self, capability: str | None = None, tag: str | None = None, limit: int | None = None, offset: int | None = None
|
|
819
|
+
) -> dict[str, Any]:
|
|
820
|
+
params: dict[str, Any] = {}
|
|
821
|
+
if capability:
|
|
822
|
+
params["capability"] = capability
|
|
823
|
+
if tag:
|
|
824
|
+
params["tag"] = tag
|
|
825
|
+
if limit is not None:
|
|
826
|
+
params["limit"] = int(limit)
|
|
827
|
+
if offset is not None:
|
|
828
|
+
params["offset"] = int(offset)
|
|
829
|
+
query = urllib.parse.urlencode(params)
|
|
830
|
+
return self._request("GET", "/v1/environment/catalog" + (f"?{query}" if query else ""))
|
|
831
|
+
|
|
832
|
+
def resolve_environment(
|
|
833
|
+
self,
|
|
834
|
+
profile: str | None = None,
|
|
835
|
+
hints: list[str] | None = None,
|
|
836
|
+
strict: bool = False,
|
|
837
|
+
description: str | None = None,
|
|
838
|
+
ttl_seconds: int | None = None,
|
|
839
|
+
profile_revision: str | None = None,
|
|
840
|
+
include_facts: bool = False,
|
|
841
|
+
) -> dict[str, Any]:
|
|
842
|
+
environment = _build_environment(
|
|
843
|
+
profile, hints, strict=strict, description=description, profile_revision=profile_revision
|
|
844
|
+
)
|
|
845
|
+
if environment is None:
|
|
846
|
+
environment = {"hints": {}}
|
|
847
|
+
payload: dict[str, Any] = {"environment": environment}
|
|
848
|
+
if include_facts:
|
|
849
|
+
payload["include_facts"] = True
|
|
850
|
+
if ttl_seconds is not None:
|
|
851
|
+
if int(ttl_seconds) < 60:
|
|
852
|
+
raise ValueError("ttl_seconds must be at least 60")
|
|
853
|
+
payload["ttl_seconds"] = int(ttl_seconds)
|
|
854
|
+
return self._request("POST", "/v1/environment:resolve", payload)
|
|
855
|
+
|
|
856
|
+
def upload_session_file(
|
|
857
|
+
self, session_id: str, path: str, content: bytes | str, if_match: str | None = None, create_only: bool = False
|
|
858
|
+
) -> dict[str, Any]:
|
|
859
|
+
if isinstance(content, str):
|
|
860
|
+
content = content.encode("utf-8")
|
|
861
|
+
target = f"/v1/sessions/{urllib.parse.quote(session_id)}/files?path={urllib.parse.quote(path, safe='')}"
|
|
862
|
+
extra_headers: dict[str, str] = {}
|
|
863
|
+
if if_match:
|
|
864
|
+
extra_headers["If-Match"] = if_match
|
|
865
|
+
if create_only:
|
|
866
|
+
extra_headers["If-None-Match"] = "*"
|
|
867
|
+
with self._raw_request(
|
|
868
|
+
"PUT",
|
|
869
|
+
target,
|
|
870
|
+
data=content,
|
|
871
|
+
content_type="application/octet-stream",
|
|
872
|
+
extra_headers=extra_headers or None,
|
|
873
|
+
) as response:
|
|
874
|
+
return decode_response_json(response.read().decode("utf-8"))
|
|
875
|
+
|
|
876
|
+
def download_session_file(self, session_id: str, path: str, *, max_bytes: int = 2 * 1024 * 1024) -> bytes:
|
|
877
|
+
"""在读取源头限制文件预算;超限拒绝,不返回不完整的成功内容。"""
|
|
878
|
+
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or not 1 <= max_bytes <= 16 * 1024 * 1024:
|
|
879
|
+
raise ValueError("max_bytes must be between 1 and 16777216")
|
|
880
|
+
target = (
|
|
881
|
+
f"/v1/sessions/{urllib.parse.quote(session_id, safe='')}/files?path={urllib.parse.quote(path, safe='')}"
|
|
882
|
+
)
|
|
883
|
+
with self._raw_request("GET", target) as response:
|
|
884
|
+
content = response.read(max_bytes + 1)
|
|
885
|
+
if len(content) > max_bytes:
|
|
886
|
+
raise ProtocolError("session file exceeds the download byte budget")
|
|
887
|
+
return content
|
|
888
|
+
|
|
889
|
+
def list_session_files(
|
|
890
|
+
self, session_id: str, path: str = ".", recursive: bool = False, limit: int | None = None, offset: int = 0
|
|
891
|
+
) -> dict[str, Any]:
|
|
892
|
+
params: dict[str, Any] = {"path": path, "recursive": str(bool(recursive)).lower(), "offset": int(offset)}
|
|
893
|
+
if limit is not None:
|
|
894
|
+
params["limit"] = int(limit)
|
|
895
|
+
query = urllib.parse.urlencode(params)
|
|
896
|
+
return self._request("GET", f"/v1/sessions/{urllib.parse.quote(session_id)}/files:list?{query}")
|
|
897
|
+
|
|
898
|
+
def remove_session_file(
|
|
899
|
+
self, session_id: str, path: str, recursive: bool = False, if_match: str | None = None
|
|
900
|
+
) -> dict[str, Any] | None:
|
|
901
|
+
params = urllib.parse.urlencode({"path": path, "recursive": str(bool(recursive)).lower()})
|
|
902
|
+
with self._raw_request(
|
|
903
|
+
"DELETE",
|
|
904
|
+
f"/v1/sessions/{urllib.parse.quote(session_id, safe='')}/files?{params}",
|
|
905
|
+
extra_headers={"If-Match": if_match} if if_match else None,
|
|
906
|
+
) as response:
|
|
907
|
+
data = response.read(16385)
|
|
908
|
+
if len(data) > 16384:
|
|
909
|
+
raise ProtocolError("delete receipt exceeds byte budget")
|
|
910
|
+
return decode_response_json(data) if data else None
|
|
911
|
+
|
|
912
|
+
def build_dependencies(
|
|
913
|
+
self,
|
|
914
|
+
*,
|
|
915
|
+
resolution_id: str | None = None,
|
|
916
|
+
environment: dict[str, Any] | None = None,
|
|
917
|
+
language: str | None = None,
|
|
918
|
+
manifest: str | None = None,
|
|
919
|
+
lockfile: str | None = None,
|
|
920
|
+
packages: list[str] | None = None,
|
|
921
|
+
) -> dict[str, Any]:
|
|
922
|
+
if (resolution_id is None) == (environment is None):
|
|
923
|
+
raise ValueError("exactly one of resolution_id or environment is required")
|
|
924
|
+
if environment is not None:
|
|
925
|
+
profile = environment.get("profile") if isinstance(environment, dict) else None
|
|
926
|
+
if (
|
|
927
|
+
not isinstance(profile, dict)
|
|
928
|
+
or not profile.get("name")
|
|
929
|
+
or not profile.get("revision")
|
|
930
|
+
or environment.get("hints") is not None
|
|
931
|
+
):
|
|
932
|
+
raise ValueError("environment must contain an exact profile name and revision")
|
|
933
|
+
payload: dict[str, Any] = {}
|
|
934
|
+
if resolution_id:
|
|
935
|
+
payload["resolution_id"] = resolution_id
|
|
936
|
+
if environment:
|
|
937
|
+
payload["environment"] = environment
|
|
938
|
+
if language:
|
|
939
|
+
payload["language"] = language
|
|
940
|
+
if manifest:
|
|
941
|
+
payload["manifest"] = manifest
|
|
942
|
+
if lockfile:
|
|
943
|
+
payload["lockfile"] = lockfile
|
|
944
|
+
if packages:
|
|
945
|
+
payload["packages"] = packages
|
|
946
|
+
return self._request("POST", "/v1/dependencies:build", payload)
|
|
947
|
+
|
|
948
|
+
def get_dependency_build(self, fingerprint: str) -> dict[str, Any]:
|
|
949
|
+
return self._request("GET", f"/v1/dependencies/{urllib.parse.quote(fingerprint)}")
|
|
950
|
+
|
|
951
|
+
def start_gui(
|
|
952
|
+
self,
|
|
953
|
+
sandbox_id: str,
|
|
954
|
+
kind: str | None = None,
|
|
955
|
+
resolution: str | None = None,
|
|
956
|
+
ttl_seconds: int | None = None,
|
|
957
|
+
metadata: dict[str, str] | None = None,
|
|
958
|
+
) -> dict[str, Any]:
|
|
959
|
+
payload: dict[str, Any] = {}
|
|
960
|
+
if kind:
|
|
961
|
+
payload["kind"] = kind
|
|
962
|
+
if resolution:
|
|
963
|
+
payload["resolution"] = resolution
|
|
964
|
+
if ttl_seconds:
|
|
965
|
+
payload["ttl_seconds"] = ttl_seconds
|
|
966
|
+
if metadata:
|
|
967
|
+
payload["metadata"] = metadata
|
|
968
|
+
return self._request("POST", f"/v1/sandboxes/{urllib.parse.quote(sandbox_id)}/gui:start", payload)
|
|
969
|
+
|
|
970
|
+
def stop_gui(self, sandbox_id: str) -> dict[str, Any]:
|
|
971
|
+
return self._request("POST", f"/v1/sandboxes/{urllib.parse.quote(sandbox_id)}/gui:stop")
|
|
972
|
+
|
|
973
|
+
def get_viewer(self, sandbox_id: str) -> dict[str, Any]:
|
|
974
|
+
return self._request("GET", f"/v1/sandboxes/{urllib.parse.quote(sandbox_id)}/viewer")
|
|
975
|
+
|
|
976
|
+
def wait_viewer(
|
|
977
|
+
self, sandbox_id: str, kind: str | None = None, timeout: float = 30, poll_interval: float = 0.5
|
|
978
|
+
) -> dict[str, Any]:
|
|
979
|
+
deadline = time.monotonic() + timeout
|
|
980
|
+
last_error: BaseException | None = None
|
|
981
|
+
while time.monotonic() < deadline:
|
|
982
|
+
try:
|
|
983
|
+
viewer = self.get_viewer(sandbox_id)
|
|
984
|
+
if viewer and viewer.get("ready") and (not kind or viewer.get("kind") == kind):
|
|
985
|
+
return viewer
|
|
986
|
+
except Exception as exc:
|
|
987
|
+
last_error = exc
|
|
988
|
+
time.sleep(poll_interval)
|
|
989
|
+
raise TimeoutError(f"viewer not ready before timeout; last_error={last_error}")
|
|
990
|
+
|
|
991
|
+
def patch_sandbox(self, sandbox_id: str, metadata: dict[str, str], resource_version: str) -> dict[str, Any]:
|
|
992
|
+
payload = {"metadata": metadata, "resource_version": resource_version}
|
|
993
|
+
return self._request("PATCH", f"/v1/sandboxes/{urllib.parse.quote(sandbox_id)}/metadata", payload)
|