"""Core sandbox establishment logic — harness-agnostic.""" from __future__ import annotations from sandboxer.extensions.registry import load_extension, resolve_backend from sandboxer.lifecycle.state_hub import emit_lifecycle_event, event_type_for_state from sandboxer.lifecycle.store import SandboxStore, utcnow from sandboxer.models import ( Reachability, SandboxCreateRequest, SandboxState, SandboxStatus, ) from sandboxer.placement import resolve_host from sandboxer.profiles.loader import load_profile class SandboxManager: def __init__(self, store: SandboxStore | None = None) -> None: self.store = store or SandboxStore() def create(self, request: SandboxCreateRequest, *, host: str | None = None) -> SandboxStatus: profile = load_profile(request.profile) extension = load_extension(profile.extension) backend = resolve_backend(extension) resolved_host = resolve_host(profile, override=host) now = utcnow() status = SandboxStatus( sandbox_id="pending", profile_id=profile.id, extension_id=extension.id, state=SandboxState.REQUESTED, consumer=request.consumer, host=resolved_host, inputs=dict(request.inputs), created_at=now, updated_at=now, ) emit_lifecycle_event(status, event_type=event_type_for_state(status.state)) status.state = SandboxState.PROVISIONING status.updated_at = utcnow() emit_lifecycle_event(status, event_type=event_type_for_state(status.state)) try: handle = backend.provision(profile, request.inputs, resolved_host) status.sandbox_id = handle["sandbox_id"] status.inputs["compose_file"] = handle.get("compose_file", "") status.inputs["ssh_user"] = handle.get("ssh_user", "") status.inputs["compose_cmd"] = handle.get("compose_cmd", "") reach = backend.wait_ready(handle) status.reachability = Reachability(**reach) status.state = SandboxState.READY status.ready_at = utcnow() status.updated_at = status.ready_at self.store.save(status) emit_lifecycle_event(status, event_type=event_type_for_state(status.state)) return status except Exception as exc: status.state = SandboxState.FAILED status.error = str(exc) status.updated_at = utcnow() if status.sandbox_id != "pending": self.store.save(status) emit_lifecycle_event( status, summary=f"Sandbox provision failed: {exc}", event_type=event_type_for_state(status.state), ) raise def get(self, sandbox_id: str) -> SandboxStatus | None: return self.store.get(sandbox_id) def list(self) -> list[SandboxStatus]: return sorted(self.store.list_all(), key=lambda s: s.created_at, reverse=True) def destroy(self, sandbox_id: str) -> SandboxStatus: status = self.store.get(sandbox_id) if not status: raise KeyError(f"Sandbox not found: {sandbox_id}") if status.state == SandboxState.DESTROYED: return status profile = load_profile(status.profile_id) extension = load_extension(profile.extension) backend = resolve_backend(extension) status.state = SandboxState.DESTROYING status.updated_at = utcnow() self.store.save(status) emit_lifecycle_event(status, event_type=event_type_for_state(status.state)) handle = { "sandbox_id": status.sandbox_id, "host": status.host or "", "remote_dir": status.reachability.remote_dir if status.reachability else "", "compose_project": status.reachability.compose_project if status.reachability else "", "compose_file": status.inputs.get("compose_file", ""), "ssh_user": status.inputs.get("ssh_user", ""), "compose_cmd": status.inputs.get("compose_cmd", ""), } backend.teardown(handle) status.state = SandboxState.DESTROYED status.destroyed_at = utcnow() status.updated_at = status.destroyed_at self.store.save(status) emit_lifecycle_event(status, event_type=event_type_for_state(status.state)) return status def recreate(self, sandbox_id: str) -> SandboxStatus: existing = self.store.get(sandbox_id) if not existing: raise KeyError(f"Sandbox not found: {sandbox_id}") request = SandboxCreateRequest( profile=existing.profile_id, inputs=dict(existing.inputs), consumer=existing.consumer, ) if existing.state != SandboxState.DESTROYED: self.destroy(sandbox_id) return self.create(request, host=existing.host)