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,150 @@
1
+ """Common types and constants shared by both sync and async session helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from .errors import ProtocolError
10
+
11
+ SESSION_TTL: int = 300
12
+ JOB_TIMEOUT: int = 60
13
+
14
+ # 内置心跳(类 Eureka Heartbeat)默认参数:
15
+ # RENEW_INTERVAL 必须 < 服务端 lease_timeout / 2,保证单次续期失败仍有重试余量。
16
+ RENEW_INTERVAL: int = 30
17
+ RENEW_EXTEND: int = 90
18
+
19
+
20
+ @dataclass
21
+ class SandboxOptions:
22
+ """Configuration for a session-backed SandboxSession helper."""
23
+
24
+ profile: str | None = None
25
+ hints: list[str] | None = None
26
+ strict: bool = False
27
+ description: str | None = None
28
+ resolution_id: str | None = None
29
+ workspace_id: str | None = None
30
+ # 默认执行环境变量:随每次 exec 请求下发(会话创建协议拒绝持久 env 字段);
31
+ # 与单次 run/run_async 的 env 合并,单次调用优先。
32
+ env: dict[str, str] | None = None
33
+ metadata: dict[str, str] = field(default_factory=dict)
34
+ ttl_seconds: int = SESSION_TTL
35
+ workspace_retention: str | None = None
36
+ workspace_ttl_seconds: int | None = None
37
+ idempotency_key: str | None = None
38
+ heartbeat: bool = True
39
+ renew_interval: int = RENEW_INTERVAL
40
+ renew_extend: int = RENEW_EXTEND
41
+
42
+ def __post_init__(self) -> None:
43
+ if self.profile and self.hints:
44
+ raise ValueError("profile and hints are mutually exclusive")
45
+ if self.resolution_id and (self.profile or self.hints):
46
+ raise ValueError("resolution_id and environment selector are mutually exclusive")
47
+ if self.ttl_seconds <= 0:
48
+ raise ValueError("ttl_seconds must be positive")
49
+ if self.workspace_retention not in (None, "ttl", "explicit_delete"):
50
+ raise ValueError("workspace_retention must be 'ttl' or 'explicit_delete'")
51
+ if self.workspace_retention == "ttl" and not self.workspace_ttl_seconds:
52
+ raise ValueError("workspace_ttl_seconds is required for ttl retention")
53
+ if self.renew_interval < 0 or self.renew_extend < 0:
54
+ raise ValueError("heartbeat intervals cannot be negative")
55
+
56
+
57
+ @dataclass
58
+ class EffectiveEnvironment:
59
+ """Resolved environment information returned by the server.
60
+
61
+ Present in responses to CreateSession, SubmitJob, ExecSession, and Resolve.
62
+ Answers: what environment was selected, why, and whether it was degraded.
63
+ """
64
+
65
+ profile_name: str = ""
66
+ profile_revision: str = ""
67
+ selection_mode: str = "" # "profile" or "auto"
68
+ selection_reason: list[str] = field(default_factory=list)
69
+ capabilities: list[str] = field(default_factory=list)
70
+ degraded: bool = False
71
+
72
+
73
+ @dataclass
74
+ class ExecResult:
75
+ """Execution result from a session or job command."""
76
+
77
+ exit_code: int
78
+ stdout: str
79
+ stderr: str
80
+ error_code: str = ""
81
+ stdout_truncated: bool = False
82
+ stderr_truncated: bool = False
83
+ effective_environment: EffectiveEnvironment | None = None
84
+
85
+ def ok(self) -> bool:
86
+ # 与 Go SDK 对齐:取消/超时等异常终止会置 error_code(如 EXEC_CANCELLED),
87
+ # 即使 exit_code 为 0 也不是成功执行。
88
+ return self.exit_code == 0 and not self.error_code
89
+
90
+ def __repr__(self) -> str:
91
+ preview = self.stdout[:60].replace("\n", "\\n")
92
+ ellipsis = "…" if len(self.stdout) > 60 else ""
93
+ return f"ExecResult(exit_code={self.exit_code}, error_code={self.error_code!r}, stdout={preview!r}{ellipsis})"
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class SSEEvent:
98
+ """One Server-Sent Event from a Job or Session exec log stream."""
99
+
100
+ id: str = ""
101
+ event: str = "message"
102
+ data: str = ""
103
+
104
+ def json(self) -> Any:
105
+ """Decode ``data`` as JSON."""
106
+ return json.loads(self.data)
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class CancelReceipt:
111
+ """取消回执:区分「接受」与「停止确认」。
112
+
113
+ 服务端 :cancel 在确认停止后才返回终态 ExecRecord(5 秒内联等待,超时以错误
114
+ 表达停止未确认);本类型把回执映射为稳定 outcome:
115
+
116
+ - ``"accepted"``:取消已被接受,但回执仍非终态,停止未确认。
117
+ - ``"already_terminal"``:执行先于取消完成,以实际终态为准,迟到取消不覆盖。
118
+ - ``"stop_confirmed"``:取消导致 cancelled/timed_out 终态,停止已确认。
119
+ - ``"stop_unknown"``:终态没有明确物理停止证据;禁止回收或直接重放。
120
+ 传输失败另外抛 ``ExecRecoveryError(phase="cancel")``,进入核对流程。
121
+
122
+ ``stop_confirmed`` 是资源回收安全门禁,只使用服务端明确布尔事实。
123
+ """
124
+
125
+ exec_id: str
126
+ outcome: str
127
+ status: str = ""
128
+ stop_confirmed: bool = False
129
+ record: dict[str, Any] | None = None
130
+
131
+ @classmethod
132
+ def from_record(cls, exec_id: str, record: dict[str, Any] | None) -> CancelReceipt:
133
+ """Map a :cancel ExecRecord response onto the cancel outcome taxonomy."""
134
+ if not isinstance(record, dict) or record.get("exec_id") != exec_id:
135
+ raise ProtocolError("cancel receipt original execution identity mismatch")
136
+ if "stop_confirmed" in record and not isinstance(record["stop_confirmed"], bool):
137
+ raise ProtocolError("cancel receipt physical stop evidence must be boolean")
138
+ status = str((record or {}).get("status") or "")
139
+ stopped = (record or {}).get("stop_confirmed") is True
140
+ if status not in {"queued", "running", "succeeded", "failed", "cancelled", "timed_out", "interrupted"}:
141
+ raise ProtocolError("cancel receipt status is invalid")
142
+ if stopped and status in {"queued", "running"}:
143
+ raise ProtocolError("nonterminal execution cannot confirm physical stop")
144
+ if status in ("cancelled", "timed_out"):
145
+ return cls(exec_id, "stop_confirmed" if stopped else "stop_unknown", status, stopped, record)
146
+ if status in ("succeeded", "failed"):
147
+ return cls(exec_id, "already_terminal" if stopped else "stop_unknown", status, stopped, record)
148
+ if status == "interrupted":
149
+ return cls(exec_id, "already_terminal" if stopped else "stop_unknown", status, stopped, record)
150
+ return cls(exec_id, "accepted", status, False, record)
@@ -0,0 +1,64 @@
1
+ """原工作区准备回执校验;未知结果只能查询,不能隐式重放。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ from .errors import ProtocolError
9
+
10
+
11
+ def preparation_request(operation_id: str, request_digest: str) -> dict[str, str]:
12
+ if not isinstance(operation_id, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", operation_id):
13
+ raise ValueError("invalid preparation operation identity")
14
+ if not isinstance(request_digest, str) or not re.fullmatch(r"[a-f0-9]{64}", request_digest):
15
+ raise ValueError("invalid preparation request digest")
16
+ return {"operation_id": operation_id, "request_digest": request_digest}
17
+
18
+
19
+ def preparation_receipt(value: Any, session_id: str, operation_id: str, request_digest: str) -> dict[str, Any]:
20
+ fields = {"operation_id", "request_digest", "status", "session_id", "workspace_id", "sandbox_id",
21
+ "profile_revision", "tenant_id", "principal_id", "user_id", "facts"}
22
+ if (not isinstance(value, dict) or set(value) - fields or fields - {"facts"} - set(value)
23
+ or value.get("session_id") != session_id or value.get("operation_id") != operation_id
24
+ or value.get("request_digest") != request_digest or value.get("status") not in {"prepared", "unknown"}):
25
+ raise ProtocolError("preparation receipt does not match original request")
26
+ if not isinstance(value["sandbox_id"], str):
27
+ raise ProtocolError("preparation sandbox identity must be a string")
28
+ for key in ("workspace_id", "tenant_id", "principal_id", "user_id", "profile_revision"):
29
+ if not isinstance(value.get(key), str) or not value[key]:
30
+ raise ProtocolError("preparation receipt lacks owner or resource identity")
31
+ if value["status"] == "unknown":
32
+ if "facts" in value:
33
+ raise ProtocolError("unknown preparation cannot contain confirmed facts")
34
+ else:
35
+ facts = value.get("facts")
36
+ fact_fields = {"session_id", "workspace_id", "profile_revision", "os", "arch", "image_digest",
37
+ "runtime_versions", "runtime_executables", "mechanisms", "resource_limits",
38
+ "network_mode", "view_state", "readonly_regions"}
39
+ if (not isinstance(value.get("sandbox_id"), str) or not value["sandbox_id"]
40
+ or not isinstance(value.get("profile_revision"), str) or not value["profile_revision"]
41
+ or not isinstance(facts, dict) or facts.get("session_id") != session_id
42
+ or facts.get("workspace_id") != value["workspace_id"]
43
+ or facts.get("profile_revision") != value["profile_revision"]
44
+ or set(facts) - fact_fields or facts.get("view_state") != "prepared"):
45
+ raise ProtocolError("confirmed preparation lacks bound runtime facts")
46
+ for key in ("os", "arch", "image_digest", "network_mode"):
47
+ if not isinstance(facts.get(key), str) or not facts[key]:
48
+ raise ProtocolError("preparation facts lack required runtime fields")
49
+ for key in ("runtime_versions", "runtime_executables"):
50
+ values = facts.get(key)
51
+ if not isinstance(values, dict) or any(not isinstance(name, str) or not name
52
+ or not isinstance(item, str) or not item for name, item in values.items()):
53
+ raise ProtocolError("preparation runtime facts have invalid values")
54
+ limits = facts.get("resource_limits")
55
+ if not isinstance(limits, dict) or any(not isinstance(name, str) or not name
56
+ or type(item) is not int or not 1 <= item <= 2**53 - 1 for name, item in limits.items()):
57
+ raise ProtocolError("preparation resource facts have invalid values")
58
+ for key in ("mechanisms", "readonly_regions"):
59
+ if key == "readonly_regions" and key not in facts:
60
+ continue
61
+ values = facts.get(key)
62
+ if not isinstance(values, list) or any(not isinstance(item, str) or not item for item in values):
63
+ raise ProtocolError("preparation mechanism facts have invalid values")
64
+ return value
@@ -0,0 +1,180 @@
1
+ """工作区资源、共享存储查询和视图控制;复用 Client 的认证与传输预算。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+ from urllib.parse import quote
8
+
9
+ from .errors import APIError, ProtocolError
10
+ from .execution_governance import maintenance_proof, maintenance_query
11
+ from .json_codec import loads as decode_response_json
12
+ from .view_preparation import preparation_receipt, preparation_request
13
+ from .workspace_purge import purge_receipt, session_ownership
14
+
15
+
16
+ def _binding(payload: dict[str, Any]) -> dict[str, Any]:
17
+ if not isinstance(payload, dict) or set(payload) - {"mode", "storage_ref", "resource_id", "binding_version"}:
18
+ raise ValueError("workspace_binding contains unknown fields")
19
+ mode = payload.get("mode")
20
+ if mode == "isolated":
21
+ if set(payload) != {"mode"}:
22
+ raise ValueError("isolated binding cannot reference shared storage")
23
+ elif mode == "shared":
24
+ if set(payload) != {"mode", "storage_ref", "resource_id", "binding_version"}:
25
+ raise ValueError("shared binding requires resource identity and version")
26
+ for key in ("storage_ref", "resource_id"):
27
+ value = payload[key]
28
+ if not isinstance(value, str) or not value or len(value) > 128 or any(
29
+ not (char.isascii() and (char.isalnum() or char in "-_.")) for char in value
30
+ ) or value in {".", ".."}:
31
+ raise ValueError("shared binding requires opaque resource identity")
32
+ version = payload["binding_version"]
33
+ if isinstance(version, bool) or not isinstance(version, int) or not 0 < version <= 9007199254740991:
34
+ raise ValueError("shared binding version must be a positive safe integer")
35
+ else:
36
+ raise ValueError("invalid workspace binding mode")
37
+ return dict(payload)
38
+
39
+
40
+ class WorkspaceClientMixin:
41
+ def query_workspace_maintenance_proof(self, workspace_id: str, *, context: dict[str, Any],
42
+ operation_id: str, source_identity: str,
43
+ claim_digest: str) -> dict[str, Any]:
44
+ query = maintenance_query(workspace_id, context, operation_id, source_identity, claim_digest)
45
+ result = self._request("POST", f"/v1/workspaces/{quote(workspace_id, safe='')}/maintenance-proof:query", query)
46
+ return maintenance_proof(result, query)
47
+
48
+ def stat_session_file(self, session_id: str, path: str) -> dict[str, Any]:
49
+ target = f"/v1/sessions/{quote(session_id, safe='')}/files:stat?path={quote(path, safe='')}"
50
+ return self._request("GET", target)
51
+
52
+ def mkdir_session_dir(self, session_id: str, path: str) -> dict[str, Any]:
53
+ target = f"/v1/sessions/{quote(session_id, safe='')}/dirs?path={quote(path, safe='')}"
54
+ return self._request("POST", target)
55
+
56
+ def _request(self, *args: Any, **kwargs: Any) -> Any:
57
+ raise NotImplementedError
58
+
59
+ def _raw_request(self, *args: Any, **kwargs: Any) -> Any:
60
+ raise NotImplementedError
61
+
62
+ def set_session_file_executable(self, session_id: str, path: str, executable: bool,
63
+ if_match: str | None = None, expected_executable: bool | None = None) -> dict[str,
64
+ Any]:
65
+ if not isinstance(executable, bool) or (
66
+ expected_executable is not None and not isinstance(expected_executable, bool)
67
+ ):
68
+ raise ValueError("executable permissions must be booleans")
69
+ payload: dict[str, Any] = {"executable": executable}
70
+ if expected_executable is not None:
71
+ payload["expected_executable"] = expected_executable
72
+ target = f"/v1/sessions/{quote(session_id, safe='')}/files:executable?path={quote(path, safe='')}"
73
+ with self._raw_request("PATCH", target, data=json.dumps(payload).encode("utf-8"),
74
+ content_type="application/json",
75
+ extra_headers={"If-Match": if_match} if if_match else None) as response:
76
+ receipt = response.read(16385)
77
+ if len(receipt) > 16384:
78
+ raise ProtocolError("executable permission receipt exceeds byte budget")
79
+ result = decode_response_json(receipt)
80
+ if not isinstance(result, dict) or not isinstance(result.get("executable"), bool):
81
+ raise ProtocolError("executable permission receipt must contain an executable fact")
82
+ return result
83
+
84
+ def create_workspace(self, workspace_id: str | None = None, metadata: dict[str, str] | None = None,
85
+ ttl_seconds: int | None = None, quota_mb: int | None = None,
86
+ workspace_binding: dict[str, Any] | None = None,
87
+ retention_mode: str | None = None) -> dict[str, Any]:
88
+ payload: dict[str, Any] = {}
89
+ if workspace_id:
90
+ payload["workspace_id"] = workspace_id
91
+ if metadata:
92
+ payload["metadata"] = metadata
93
+ if ttl_seconds:
94
+ payload["ttl_seconds"] = int(ttl_seconds)
95
+ if quota_mb:
96
+ payload["quota_mb"] = int(quota_mb)
97
+ if retention_mode is not None:
98
+ if retention_mode not in {"ttl", "explicit_delete"}:
99
+ raise ValueError("invalid workspace retention mode")
100
+ payload["retention_mode"] = retention_mode
101
+ if workspace_binding is not None:
102
+ payload["workspace_binding"] = _binding(workspace_binding)
103
+ return self._request("POST", "/v1/workspaces", payload)
104
+
105
+ def get_workspace(self, workspace_id: str) -> dict[str, Any]:
106
+ return self._request("GET", f"/v1/workspaces/{quote(workspace_id, safe='')}")
107
+
108
+ def delete_workspace(self, workspace_id: str) -> dict[str, Any] | None:
109
+ return self._request("DELETE", f"/v1/workspaces/{quote(workspace_id, safe='')}")
110
+
111
+ def inspect_shared_storage_resource(self, storage_ref: str, resource_id: str) -> dict[str, Any]:
112
+ _binding({"mode": "shared", "storage_ref": storage_ref, "resource_id": resource_id, "binding_version": 1})
113
+ return self._request("GET", f"/v1/storage-resources/{quote(storage_ref, safe='')}/{quote(resource_id,
114
+ safe='')}")
115
+
116
+ def get_workspace_view(self, session_id: str) -> dict[str, Any]:
117
+ return self._request("GET", f"/v1/sessions/{quote(session_id, safe='')}/workspace-view")
118
+
119
+ def get_session_history(self, session_id: str) -> dict[str, Any]:
120
+ return self._request("GET", f"/v1/sessions/{quote(session_id, safe='')}/history")
121
+
122
+ def get_session_ownership(self, session_id: str) -> dict[str, Any]:
123
+ return session_ownership(self._request("GET", f"/v1/sessions/{quote(session_id, safe='')}/ownership"),
124
+ session_id)
125
+
126
+ def purge_session_workspace(self, session_id: str, *, operation_id: str, request_digest: str) -> dict[str, Any]:
127
+ payload = preparation_request(operation_id, request_digest)
128
+ result = self._request("POST", f"/v1/sessions/{quote(session_id, safe='')}/workspace:purge", payload)
129
+ return purge_receipt(result, session_id, operation_id, request_digest)
130
+
131
+ def lookup_workspace_purge(self, session_id: str, *, operation_id: str, request_digest: str) -> dict[str,
132
+ Any] | None:
133
+ preparation_request(operation_id, request_digest)
134
+ target = (f"/v1/sessions/{quote(session_id, safe='')}/workspace/purges/"
135
+ f"{quote(operation_id, safe='')}?request_digest={request_digest}")
136
+ try:
137
+ result = self._request("GET", target)
138
+ except APIError as error:
139
+ if error.status_code == 404:
140
+ return None
141
+ raise
142
+ return purge_receipt(result, session_id, operation_id, request_digest)
143
+
144
+ def lookup_storage_purge(self, workspace_id: str, *, operation_id: str, request_digest: str) -> dict[str,
145
+ Any] | None:
146
+ preparation_request(operation_id, request_digest)
147
+ target = (f"/v1/workspaces/{quote(workspace_id, safe='')}/purges/"
148
+ f"{quote(operation_id, safe='')}?request_digest={request_digest}")
149
+ try:
150
+ result = self._request("GET", target)
151
+ except APIError as error:
152
+ if error.status_code == 404:
153
+ return None
154
+ raise
155
+ receipt = purge_receipt(result, None, operation_id, request_digest)
156
+ if receipt["workspace_id"] != workspace_id:
157
+ raise ProtocolError("original storage purge workspace mismatch")
158
+ return receipt
159
+
160
+ def prepare_workspace_view(self, session_id: str, *, operation_id: str, request_digest: str) -> dict[str, Any]:
161
+ payload = preparation_request(operation_id, request_digest)
162
+ result = self._request("POST", f"/v1/sessions/{quote(session_id, safe='')}/workspace-view?action=prepare",
163
+ payload)
164
+ return preparation_receipt(result, session_id, operation_id, request_digest)
165
+
166
+ def lookup_workspace_preparation(self, session_id: str, *, operation_id: str, request_digest: str) -> dict[str,
167
+ Any] | None:
168
+ preparation_request(operation_id, request_digest)
169
+ target = (f"/v1/sessions/{quote(session_id, safe='')}/workspace-view/preparations/"
170
+ f"{quote(operation_id, safe='')}?request_digest={request_digest}")
171
+ try:
172
+ result = self._request("GET", target)
173
+ except APIError as error:
174
+ if error.status_code == 404:
175
+ return None
176
+ raise
177
+ return preparation_receipt(result, session_id, operation_id, request_digest)
178
+
179
+ def seal_workspace_view(self, session_id: str) -> dict[str, Any]:
180
+ return self._request("POST", f"/v1/sessions/{quote(session_id, safe='')}/workspace-view?action=seal")
@@ -0,0 +1,154 @@
1
+ """可信父任务的持续存储保活;不续期或恢复计算实例。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ from datetime import UTC, datetime, timedelta
9
+ from typing import Any
10
+ from urllib.parse import quote
11
+
12
+ from .errors import ProtocolError
13
+
14
+
15
+ def _operation_id(value: str) -> str:
16
+ if not isinstance(value, str) or not value or len(value) > 128 or value in {".", ".."} or any(
17
+ not (char.isascii() and (char.isalnum() or char in "_.-")) for char in value
18
+ ):
19
+ raise ValueError("operation_id must be an opaque identifier")
20
+ return value
21
+
22
+
23
+ def _integer(value: int, name: str, minimum: int, maximum: int) -> int:
24
+ if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
25
+ raise ValueError(f"{name} is outside the integer budget")
26
+ return value
27
+
28
+
29
+ def _timestamp(value: Any) -> datetime:
30
+ if not isinstance(value, str) or not re.fullmatch(
31
+ r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})", value):
32
+ raise ValueError("lifecycle time must be an aware microsecond RFC3339 timestamp")
33
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
34
+ if parsed.tzinfo is None:
35
+ raise ValueError("lifecycle time must be timezone-aware")
36
+ return parsed.astimezone(UTC)
37
+
38
+
39
+ def workspace_lifecycle_request_digest(payload: dict[str, Any]) -> str:
40
+ """固定六字段规范化摘要,供独立消费者核对原持久意图。"""
41
+ fields = {"expected_revision", "hold_seconds", "operation_id", "retention_seconds", "state", "terminal_at"}
42
+ if not isinstance(payload, dict) or set(payload) - fields or not {"expected_revision", "operation_id",
43
+ "state"} <= set(payload):
44
+ raise ValueError("invalid lifecycle request fields")
45
+ _operation_id(payload["operation_id"])
46
+ _integer(payload["expected_revision"], "expected_revision", 0, 9007199254740990)
47
+ hold = _integer(payload.get("hold_seconds", 0), "hold_seconds", 0, 86400)
48
+ retention = _integer(payload.get("retention_seconds", 0), "retention_seconds", 0, 30 * 86400)
49
+ terminal = payload.get("terminal_at")
50
+ if not isinstance(payload["state"], str):
51
+ raise ValueError("invalid lifecycle state")
52
+ if payload["state"] == "terminal":
53
+ if hold or not retention or terminal is None:
54
+ raise ValueError("invalid terminal lifecycle")
55
+ elif payload["state"] not in {"active", "paused"} or terminal is not None or retention:
56
+ raise ValueError("invalid nonterminal lifecycle")
57
+ if terminal is not None:
58
+ parsed = _timestamp(terminal)
59
+ terminal = parsed.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z")
60
+ canonical = {
61
+ "expected_revision": payload["expected_revision"], "hold_seconds": payload.get("hold_seconds", 0),
62
+ "operation_id": payload["operation_id"], "retention_seconds": payload.get("retention_seconds", 0),
63
+ "state": payload["state"], "terminal_at": terminal,
64
+ }
65
+ return hashlib.sha256(json.dumps(canonical, sort_keys=True, ensure_ascii=False, separators=(",",
66
+ ":")).encode("utf-8")).hexdigest()
67
+
68
+
69
+ def _receipt(value: Any, workspace_id: str, operation_id: str) -> dict[str, Any]:
70
+ keys = {"operation_id", "workspace_id", "revision", "state", "deadline", "terminal_at", "request_digest"}
71
+ if not isinstance(value, dict) or set(value) - keys or not keys - {"terminal_at"} <= set(value):
72
+ raise ProtocolError("invalid lifecycle receipt fields")
73
+ if value["workspace_id"] != workspace_id or value["operation_id"] != operation_id:
74
+ raise ProtocolError("lifecycle receipt identity mismatch")
75
+ revision = value["revision"]
76
+ if isinstance(revision, bool) or not isinstance(revision, int) or not 1 <= revision <= 9007199254740991:
77
+ raise ProtocolError("invalid lifecycle revision")
78
+ digest = value["request_digest"]
79
+ if not isinstance(digest, str) or len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest):
80
+ raise ProtocolError("invalid lifecycle request digest")
81
+ if not isinstance(value["state"], str) or value["state"] not in {"active", "paused", "terminal"}:
82
+ raise ProtocolError("invalid lifecycle state")
83
+ try:
84
+ deadline = _timestamp(value["deadline"])
85
+ if deadline.tzinfo is None:
86
+ raise ValueError
87
+ except (ValueError, TypeError, AttributeError) as error:
88
+ raise ProtocolError("invalid lifecycle deadline") from error
89
+ if (value["state"] == "terminal") != (value.get("terminal_at") is not None):
90
+ raise ProtocolError("lifecycle terminal identity mismatch")
91
+ if value["state"] == "terminal":
92
+ try:
93
+ terminal = _timestamp(value["terminal_at"])
94
+ if terminal.tzinfo is None or terminal > deadline:
95
+ raise ValueError
96
+ except (ValueError, TypeError, AttributeError) as error:
97
+ raise ProtocolError("invalid lifecycle terminal time") from error
98
+ return value
99
+
100
+
101
+ class WorkspaceLifecycleClientMixin:
102
+ def _request(self, *args: Any, **kwargs: Any) -> Any:
103
+ raise NotImplementedError
104
+
105
+ def control_workspace_lifecycle(
106
+ self, workspace_id: str, operation_id: str, expected_revision: int, state: str,
107
+ *, hold_seconds: int = 0, terminal_at: datetime | None = None, retention_seconds: int = 0,
108
+ ) -> dict[str, Any]:
109
+ _operation_id(operation_id)
110
+ _integer(expected_revision, "expected_revision", 0, 9007199254740990)
111
+ _integer(hold_seconds, "hold_seconds", 0, 86400)
112
+ _integer(retention_seconds, "retention_seconds", 0, 30 * 86400)
113
+ payload: dict[str, Any] = {
114
+ "operation_id": operation_id, "expected_revision": expected_revision, "state": state,
115
+ }
116
+ if state in {"active", "paused"}:
117
+ if terminal_at is not None or retention_seconds:
118
+ raise ValueError("non-terminal lifecycle cannot carry terminal retention")
119
+ payload["hold_seconds"] = hold_seconds
120
+ elif state == "terminal":
121
+ if hold_seconds or retention_seconds == 0 or not isinstance(terminal_at,
122
+ datetime) or terminal_at.tzinfo is None:
123
+ raise ValueError("terminal lifecycle requires an aware terminal_at and bounded retention")
124
+ payload["terminal_at"] = terminal_at.astimezone(UTC).isoformat().replace("+00:00", "Z")
125
+ payload["retention_seconds"] = retention_seconds
126
+ else:
127
+ raise ValueError("invalid lifecycle state")
128
+ # 传输层变更请求仅发一次;未知副作用必须改用原 operation_id 查询。
129
+ result = self._request("POST", f"/v1/workspaces/{quote(workspace_id, safe='')}/lifecycle", payload)
130
+ result = _receipt(result, workspace_id, operation_id)
131
+ digest_ok = result["request_digest"] == workspace_lifecycle_request_digest(payload)
132
+ revision_ok = result["revision"] == expected_revision + 1
133
+ if not digest_ok or not revision_ok or result["state"] != state:
134
+ raise ProtocolError("lifecycle receipt differs from original intent")
135
+ if state == "terminal":
136
+ assert terminal_at is not None
137
+ if (_timestamp(result["terminal_at"]) != terminal_at
138
+ or _timestamp(result["deadline"]) != terminal_at + timedelta(seconds=retention_seconds)):
139
+ raise ProtocolError("lifecycle receipt differs from parent terminal time or retention")
140
+ return result
141
+
142
+ def lookup_workspace_lifecycle(self, workspace_id: str, operation_id: str) -> dict[str, Any]:
143
+ _operation_id(operation_id)
144
+ result = self._request(
145
+ "GET", f"/v1/workspaces/{quote(workspace_id, safe='')}/lifecycle:lookup?operation_id={quote(operation_id,
146
+ safe='')}",
147
+ )
148
+ if not isinstance(result, dict) or set(result) != {"state", "receipt"}:
149
+ raise ProtocolError("invalid lifecycle lookup fields")
150
+ if result["state"] == "found":
151
+ _receipt(result["receipt"], workspace_id, operation_id)
152
+ elif result["state"] not in {"unknown", "history_expired"} or result["receipt"] is not None:
153
+ raise ProtocolError("invalid lifecycle lookup state")
154
+ return result
@@ -0,0 +1,26 @@
1
+ """原删除回执只承认完整身份,不把 404 或目录缺失当作成功。"""
2
+
3
+ from typing import Any
4
+
5
+ from .errors import ProtocolError
6
+
7
+
8
+ def session_ownership(value: Any, session_id: str) -> dict[str, Any]:
9
+ fields = {"session_id", "workspace_id", "tenant_id", "principal_id", "user_id", "profile_revision"}
10
+ if (not isinstance(value, dict) or set(value) != fields
11
+ or any(not isinstance(value[key], str) or not value[key] for key in fields)
12
+ or value["session_id"] != session_id):
13
+ raise ProtocolError("original session ownership identity mismatch")
14
+ return value
15
+
16
+
17
+ def purge_receipt(value: Any, session_id: str | None, operation_id: str, request_digest: str) -> dict[str, Any]:
18
+ fields = {"operation_id", "request_digest", "status", "session_id", "workspace_id",
19
+ "profile_revision", "tenant_id", "principal_id", "user_id"}
20
+ if (not isinstance(value, dict) or set(value) != fields
21
+ or any(not isinstance(value[key], str) or not value[key] for key in fields)
22
+ or value["status"] not in {"unknown", "purged"}
23
+ or (session_id is not None and value["session_id"] != session_id) or value["operation_id"] != operation_id
24
+ or value["request_digest"] != request_digest):
25
+ raise ProtocolError("original workspace purge receipt does not match the request")
26
+ return value