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 <mdubrinsky@nvidia.com>

* test(python): use real gateway condition values in conditions fixtures

Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com>

---------

Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com>
This commit is contained in:
Max Dubrinsky
2026-10-12 19:05:43 +00:00
committed by GitHub
parent 16c8ca5c43
commit 66696eb1c8
3 changed files with 94 additions and 0 deletions
+2
View File
@@ -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",
+34
View File
@@ -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,
+58
View File
@@ -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)