diff --git a/python/openshell/__init__.py b/python/openshell/__init__.py index 1b2f276673..c99c3b48e0 100644 --- a/python/openshell/__init__.py +++ b/python/openshell/__init__.py @@ -16,6 +16,7 @@ Pager, Sandbox, SandboxClient, + SandboxConditionRef, SandboxError, SandboxRef, SandboxSession, @@ -51,6 +52,7 @@ "ProviderCredentialValue", "Sandbox", "SandboxClient", + "SandboxConditionRef", "SandboxError", "SandboxRef", "SandboxSession", diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index 20ce99263e..19a7ba3b84 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -452,6 +452,15 @@ def _resolve_secret(self) -> str: 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 @@ def phase(self) -> int: 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 40fc7c205f..019d3cc674 100644 --- a/python/openshell/sandbox_test.py +++ b/python/openshell/sandbox_test.py @@ -28,6 +28,7 @@ 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)