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,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