From 66696eb1c8b411616dfee866f10c44550679fd1f Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Mon, 12 Oct 2026 19:05:43 +0000 Subject: [PATCH] feat(python): expose sandbox status conditions in the SDK (#4436) * feat(python): expose sandbox status conditions in the SDK Add SandboxConditionRef and surface status.conditions on SandboxStatusRef and SandboxRef. Callers need condition reasons and transition times to time terminal transitions and explain failures that happen before the main process starts, without reading the raw proto. Signed-off-by: Max Dubrinsky * test(python): use real gateway condition values in conditions fixtures Signed-off-by: Max Dubrinsky --------- Signed-off-by: Max Dubrinsky --- python/openshell/__init__.py | 2 ++ python/openshell/sandbox.py | 34 +++++++++++++++++++ python/openshell/sandbox_test.py | 58 ++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+) diff --git a/python/openshell/__init__.py b/python/openshell/__init__.py index 1b2f27667..c99c3b48e 100644 --- a/python/openshell/__init__.py +++ b/python/openshell/__init__.py @@ -16,6 +16,7 @@ from .sandbox import ( Pager, Sandbox, SandboxClient, + SandboxConditionRef, SandboxError, SandboxRef, SandboxSession, @@ -51,6 +52,7 @@ __all__ = [ "ProviderCredentialValue", "Sandbox", "SandboxClient", + "SandboxConditionRef", "SandboxError", "SandboxRef", "SandboxSession", diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index 20ce99263..19a7ba3b8 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -452,6 +452,15 @@ class ClientCredentialsAuth: return value +@dataclass(frozen=True) +class SandboxConditionRef: + type: str + status: str + reason: str = "" + message: str = "" + transition_time_ms: int | None = None + + @dataclass(frozen=True) class SandboxStatusRef: phase: int @@ -460,6 +469,7 @@ class SandboxStatusRef: restart_count: int = 0 next_restart_at_ms: int | None = None main_process_started_at_ms: int | None = None + conditions: tuple[SandboxConditionRef, ...] = () class ServiceAuthorizationMode(IntEnum): @@ -538,6 +548,10 @@ class SandboxRef: def current_policy_version(self) -> int: return self.status.current_policy_version + @property + def conditions(self) -> tuple[SandboxConditionRef, ...]: + return self.status.conditions + @dataclass(frozen=True) class ExecChunk: @@ -1772,6 +1786,25 @@ def _serialize_python_callable( return base64.b64encode(payload).decode("ascii") +def _sandbox_conditions( + status: openshell_pb2.SandboxStatus | None, +) -> tuple[SandboxConditionRef, ...]: + if status is None: + return () + return tuple( + SandboxConditionRef( + type=condition.type, + status=condition.status, + reason=condition.reason, + message=condition.message, + transition_time_ms=condition.transition_time.ToMilliseconds() + if condition.HasField("transition_time") + else None, + ) + for condition in status.conditions + ) + + def _sandbox_ref( sandbox: openshell_pb2.Sandbox, service_urls: Mapping[str, str] | None = None, @@ -1802,6 +1835,7 @@ def _sandbox_ref( main_process_started_at_ms=status.main_process_started_time.ToMilliseconds() if status is not None and status.HasField("main_process_started_time") else None, + conditions=_sandbox_conditions(status), ), labels=sandbox.metadata.labels if sandbox.metadata else {}, created_from_workload_template=provenance, diff --git a/python/openshell/sandbox_test.py b/python/openshell/sandbox_test.py index 40fc7c205..019d3cc67 100644 --- a/python/openshell/sandbox_test.py +++ b/python/openshell/sandbox_test.py @@ -28,6 +28,7 @@ from openshell.sandbox import ( Pager, Sandbox, SandboxClient, + SandboxConditionRef, SandboxError, SandboxRef, SandboxStatusRef, @@ -2750,6 +2751,63 @@ def test_sandbox_ref_includes_main_process_result() -> None: assert status.main_process_started_at_ms == 1_699_999_000_000 +def test_sandbox_ref_includes_conditions() -> None: + proto = _make_sandbox_proto("sandbox-1", "job-1") + + ready = proto.status.conditions.add() + ready.type = "Ready" + ready.status = "True" + ready.reason = "BackendReady" + ready.message = "Container is running" + ready.transition_time.FromMilliseconds(1_700_000_000_000) + + failed = proto.status.conditions.add() + failed.type = "Ready" + failed.status = "Unknown" + failed.reason = "InspectFailed" + failed.message = "Container inspect failed: connection refused" + + ref = _sandbox_ref(proto) + + assert ref.status.conditions == ( + SandboxConditionRef( + type="Ready", + status="True", + reason="BackendReady", + message="Container is running", + transition_time_ms=1_700_000_000_000, + ), + SandboxConditionRef( + type="Ready", + status="Unknown", + reason="InspectFailed", + message="Container inspect failed: connection refused", + transition_time_ms=None, + ), + ) + # Surfaces the same conditions through SandboxRef directly. + assert ref.conditions == ref.status.conditions + + +def test_sandbox_ref_conditions_default_to_empty() -> None: + ref = _sandbox_ref(_make_sandbox_proto("sandbox-1", "job-1")) + + assert ref.status.conditions == () + assert ref.conditions == () + + +def test_direct_sandbox_ref_construction_defaults_conditions() -> None: + ref = SandboxRef( + id="sandbox-1", + name="job-1", + workspace="default", + status=SandboxStatusRef(phase=2, current_policy_version=0), + ) + + assert ref.status.conditions == () + assert ref.conditions == () + + def test_returned_labels_are_immutable() -> None: proto = _make_sandbox_proto("sandbox-1", "job-1", {"aiq": "deep-research"}) ref = _sandbox_ref(proto)