Add persistent workspace shell sessions

Let agents inhabit a workspace across separate calls instead of only submitting one-shot execs.

Add workspace shell open/read/write/signal/close across the CLI, Python SDK, and MCP server, with persisted shell records, a local PTY-backed mock implementation, and guest-agent support for real Firecracker workspaces.

Mark the 2.5.0 roadmap milestone done, refresh docs/examples and the release metadata, and verify with uv lock, UV_CACHE_DIR=.uv-cache make check, and UV_CACHE_DIR=.uv-cache make dist-check.
This commit is contained in:
Thales Maciel 2026-03-12 02:31:57 -03:00
parent 2de31306b6
commit 3f8293ad24
28 changed files with 3265 additions and 81 deletions

View file

@ -27,8 +27,15 @@ from pyro_mcp.vm_environments import EnvironmentStore, default_cache_dir, get_en
from pyro_mcp.vm_firecracker import build_launch_plan
from pyro_mcp.vm_guest import VsockExecClient
from pyro_mcp.vm_network import NetworkConfig, TapNetworkManager
from pyro_mcp.workspace_shells import (
create_local_shell,
get_local_shell,
remove_local_shell,
shell_signal_names,
)
VmState = Literal["created", "started", "stopped"]
WorkspaceShellState = Literal["running", "stopped"]
DEFAULT_VCPU_COUNT = 1
DEFAULT_MEM_MIB = 1024
@ -36,13 +43,18 @@ DEFAULT_TIMEOUT_SECONDS = 30
DEFAULT_TTL_SECONDS = 600
DEFAULT_ALLOW_HOST_COMPAT = False
WORKSPACE_LAYOUT_VERSION = 2
WORKSPACE_LAYOUT_VERSION = 3
WORKSPACE_DIRNAME = "workspace"
WORKSPACE_COMMANDS_DIRNAME = "commands"
WORKSPACE_SHELLS_DIRNAME = "shells"
WORKSPACE_RUNTIME_DIRNAME = "runtime"
WORKSPACE_GUEST_PATH = "/workspace"
WORKSPACE_GUEST_AGENT_PATH = "/opt/pyro/bin/pyro_guest_agent.py"
WORKSPACE_ARCHIVE_UPLOAD_TIMEOUT_SECONDS = 60
DEFAULT_SHELL_COLS = 120
DEFAULT_SHELL_ROWS = 30
DEFAULT_SHELL_MAX_CHARS = 65536
WORKSPACE_SHELL_SIGNAL_NAMES = shell_signal_names()
WorkspaceSeedMode = Literal["empty", "directory", "tar_archive"]
@ -183,6 +195,58 @@ class WorkspaceRecord:
)
@dataclass
class WorkspaceShellRecord:
"""Persistent shell metadata stored on disk per workspace."""
workspace_id: str
shell_id: str
cwd: str
cols: int
rows: int
state: WorkspaceShellState
started_at: float
ended_at: float | None = None
exit_code: int | None = None
execution_mode: str = "pending"
metadata: dict[str, str] = field(default_factory=dict)
def to_payload(self) -> dict[str, Any]:
return {
"layout_version": WORKSPACE_LAYOUT_VERSION,
"workspace_id": self.workspace_id,
"shell_id": self.shell_id,
"cwd": self.cwd,
"cols": self.cols,
"rows": self.rows,
"state": self.state,
"started_at": self.started_at,
"ended_at": self.ended_at,
"exit_code": self.exit_code,
"execution_mode": self.execution_mode,
"metadata": dict(self.metadata),
}
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> WorkspaceShellRecord:
return cls(
workspace_id=str(payload["workspace_id"]),
shell_id=str(payload["shell_id"]),
cwd=str(payload.get("cwd", WORKSPACE_GUEST_PATH)),
cols=int(payload.get("cols", DEFAULT_SHELL_COLS)),
rows=int(payload.get("rows", DEFAULT_SHELL_ROWS)),
state=cast(WorkspaceShellState, str(payload.get("state", "stopped"))),
started_at=float(payload.get("started_at", 0.0)),
ended_at=(
None if payload.get("ended_at") is None else float(payload.get("ended_at", 0.0))
),
exit_code=(
None if payload.get("exit_code") is None else int(payload.get("exit_code", 0))
),
execution_mode=str(payload.get("execution_mode", "pending")),
metadata=_string_dict(payload.get("metadata")),
)
@dataclass(frozen=True)
class PreparedWorkspaceSeed:
"""Prepared host-side seed archive plus metadata."""
@ -610,6 +674,59 @@ class VmBackend:
) -> dict[str, Any]:
raise NotImplementedError
def open_shell( # pragma: no cover
self,
instance: VmInstance,
*,
workspace_id: str,
shell_id: str,
cwd: str,
cols: int,
rows: int,
) -> dict[str, Any]:
raise NotImplementedError
def read_shell( # pragma: no cover
self,
instance: VmInstance,
*,
workspace_id: str,
shell_id: str,
cursor: int,
max_chars: int,
) -> dict[str, Any]:
raise NotImplementedError
def write_shell( # pragma: no cover
self,
instance: VmInstance,
*,
workspace_id: str,
shell_id: str,
input_text: str,
append_newline: bool,
) -> dict[str, Any]:
raise NotImplementedError
def signal_shell( # pragma: no cover
self,
instance: VmInstance,
*,
workspace_id: str,
shell_id: str,
signal_name: str,
) -> dict[str, Any]:
raise NotImplementedError
def close_shell( # pragma: no cover
self,
instance: VmInstance,
*,
workspace_id: str,
shell_id: str,
) -> dict[str, Any]:
raise NotImplementedError
class MockBackend(VmBackend):
"""Host-process backend used for development and testability."""
@ -651,6 +768,87 @@ class MockBackend(VmBackend):
destination=destination,
)
def open_shell(
self,
instance: VmInstance,
*,
workspace_id: str,
shell_id: str,
cwd: str,
cols: int,
rows: int,
) -> dict[str, Any]:
session = create_local_shell(
workspace_id=workspace_id,
shell_id=shell_id,
cwd=_workspace_host_destination(_instance_workspace_host_dir(instance), cwd),
display_cwd=cwd,
cols=cols,
rows=rows,
)
summary = session.summary()
summary["execution_mode"] = "host_compat"
return summary
def read_shell(
self,
instance: VmInstance,
*,
workspace_id: str,
shell_id: str,
cursor: int,
max_chars: int,
) -> dict[str, Any]:
del instance
session = get_local_shell(workspace_id=workspace_id, shell_id=shell_id)
payload = session.read(cursor=cursor, max_chars=max_chars)
payload["execution_mode"] = "host_compat"
return payload
def write_shell(
self,
instance: VmInstance,
*,
workspace_id: str,
shell_id: str,
input_text: str,
append_newline: bool,
) -> dict[str, Any]:
del instance
session = get_local_shell(workspace_id=workspace_id, shell_id=shell_id)
payload = session.write(input_text, append_newline=append_newline)
payload["execution_mode"] = "host_compat"
return payload
def signal_shell(
self,
instance: VmInstance,
*,
workspace_id: str,
shell_id: str,
signal_name: str,
) -> dict[str, Any]:
del instance
session = get_local_shell(workspace_id=workspace_id, shell_id=shell_id)
payload = session.send_signal(signal_name)
payload["execution_mode"] = "host_compat"
return payload
def close_shell(
self,
instance: VmInstance,
*,
workspace_id: str,
shell_id: str,
) -> dict[str, Any]:
del instance
session = remove_local_shell(workspace_id=workspace_id, shell_id=shell_id)
if session is None:
raise ValueError(f"shell {shell_id!r} does not exist in workspace {workspace_id!r}")
payload = session.close()
payload["execution_mode"] = "host_compat"
return payload
class FirecrackerBackend(VmBackend): # pragma: no cover
"""Host-gated backend that validates Firecracker prerequisites."""
@ -888,6 +1086,144 @@ class FirecrackerBackend(VmBackend): # pragma: no cover
destination=destination,
)
def open_shell(
self,
instance: VmInstance,
*,
workspace_id: str,
shell_id: str,
cwd: str,
cols: int,
rows: int,
) -> dict[str, Any]:
del workspace_id
guest_cid = int(instance.metadata["guest_cid"])
port = int(instance.metadata["guest_exec_port"])
uds_path = instance.metadata.get("guest_exec_uds_path")
response = self._guest_exec_client.open_shell(
guest_cid,
port,
shell_id=shell_id,
cwd=cwd,
cols=cols,
rows=rows,
uds_path=uds_path,
)
return {
"shell_id": response.shell_id or shell_id,
"cwd": response.cwd,
"cols": response.cols,
"rows": response.rows,
"state": response.state,
"started_at": response.started_at,
"ended_at": response.ended_at,
"exit_code": response.exit_code,
"execution_mode": instance.metadata.get("execution_mode", "pending"),
}
def read_shell(
self,
instance: VmInstance,
*,
workspace_id: str,
shell_id: str,
cursor: int,
max_chars: int,
) -> dict[str, Any]:
del workspace_id
guest_cid = int(instance.metadata["guest_cid"])
port = int(instance.metadata["guest_exec_port"])
uds_path = instance.metadata.get("guest_exec_uds_path")
response = self._guest_exec_client.read_shell(
guest_cid,
port,
shell_id=shell_id,
cursor=cursor,
max_chars=max_chars,
uds_path=uds_path,
)
return {
"shell_id": response.shell_id,
"cwd": response.cwd,
"cols": response.cols,
"rows": response.rows,
"state": response.state,
"started_at": response.started_at,
"ended_at": response.ended_at,
"exit_code": response.exit_code,
"cursor": response.cursor,
"next_cursor": response.next_cursor,
"output": response.output,
"truncated": response.truncated,
"execution_mode": instance.metadata.get("execution_mode", "pending"),
}
def write_shell(
self,
instance: VmInstance,
*,
workspace_id: str,
shell_id: str,
input_text: str,
append_newline: bool,
) -> dict[str, Any]:
del workspace_id
guest_cid = int(instance.metadata["guest_cid"])
port = int(instance.metadata["guest_exec_port"])
uds_path = instance.metadata.get("guest_exec_uds_path")
payload = self._guest_exec_client.write_shell(
guest_cid,
port,
shell_id=shell_id,
input_text=input_text,
append_newline=append_newline,
uds_path=uds_path,
)
payload["execution_mode"] = instance.metadata.get("execution_mode", "pending")
return payload
def signal_shell(
self,
instance: VmInstance,
*,
workspace_id: str,
shell_id: str,
signal_name: str,
) -> dict[str, Any]:
del workspace_id
guest_cid = int(instance.metadata["guest_cid"])
port = int(instance.metadata["guest_exec_port"])
uds_path = instance.metadata.get("guest_exec_uds_path")
payload = self._guest_exec_client.signal_shell(
guest_cid,
port,
shell_id=shell_id,
signal_name=signal_name,
uds_path=uds_path,
)
payload["execution_mode"] = instance.metadata.get("execution_mode", "pending")
return payload
def close_shell(
self,
instance: VmInstance,
*,
workspace_id: str,
shell_id: str,
) -> dict[str, Any]:
del workspace_id
guest_cid = int(instance.metadata["guest_cid"])
port = int(instance.metadata["guest_exec_port"])
uds_path = instance.metadata.get("guest_exec_uds_path")
payload = self._guest_exec_client.close_shell(
guest_cid,
port,
shell_id=shell_id,
uds_path=uds_path,
)
payload["execution_mode"] = instance.metadata.get("execution_mode", "pending")
return payload
class VmManager:
"""In-process lifecycle manager for ephemeral VM environments and workspaces."""
@ -1151,9 +1487,11 @@ class VmManager:
runtime_dir = self._workspace_runtime_dir(workspace_id)
host_workspace_dir = self._workspace_host_dir(workspace_id)
commands_dir = self._workspace_commands_dir(workspace_id)
shells_dir = self._workspace_shells_dir(workspace_id)
workspace_dir.mkdir(parents=True, exist_ok=False)
host_workspace_dir.mkdir(parents=True, exist_ok=True)
commands_dir.mkdir(parents=True, exist_ok=True)
shells_dir.mkdir(parents=True, exist_ok=True)
instance = VmInstance(
vm_id=workspace_id,
environment=environment,
@ -1179,11 +1517,8 @@ class VmManager:
f"max active VMs reached ({self._max_active_vms}); delete old VMs first"
)
self._backend.create(instance)
if (
prepared_seed.archive_path is not None
and self._runtime_capabilities.supports_guest_exec
):
self._ensure_workspace_guest_seed_support(instance)
if self._runtime_capabilities.supports_guest_exec:
self._ensure_workspace_guest_agent_support(instance)
with self._lock:
self._start_instance_locked(instance)
self._require_guest_exec_or_opt_in(instance)
@ -1332,6 +1667,208 @@ class VmManager:
"cwd": WORKSPACE_GUEST_PATH,
}
def open_shell(
self,
workspace_id: str,
*,
cwd: str = WORKSPACE_GUEST_PATH,
cols: int = DEFAULT_SHELL_COLS,
rows: int = DEFAULT_SHELL_ROWS,
) -> dict[str, Any]:
if cols <= 0:
raise ValueError("cols must be positive")
if rows <= 0:
raise ValueError("rows must be positive")
normalized_cwd, _ = _normalize_workspace_destination(cwd)
shell_id = uuid.uuid4().hex[:12]
with self._lock:
workspace = self._load_workspace_locked(workspace_id)
instance = self._workspace_instance_for_live_shell_locked(workspace)
payload = self._backend.open_shell(
instance,
workspace_id=workspace_id,
shell_id=shell_id,
cwd=normalized_cwd,
cols=cols,
rows=rows,
)
shell = self._workspace_shell_record_from_payload(
workspace_id=workspace_id,
shell_id=shell_id,
payload=payload,
)
with self._lock:
workspace = self._load_workspace_locked(workspace_id)
workspace.state = instance.state
workspace.firecracker_pid = instance.firecracker_pid
workspace.last_error = instance.last_error
workspace.metadata = dict(instance.metadata)
self._save_workspace_locked(workspace)
self._save_workspace_shell_locked(shell)
return self._serialize_workspace_shell(shell)
def read_shell(
self,
workspace_id: str,
shell_id: str,
*,
cursor: int = 0,
max_chars: int = DEFAULT_SHELL_MAX_CHARS,
) -> dict[str, Any]:
if cursor < 0:
raise ValueError("cursor must not be negative")
if max_chars <= 0:
raise ValueError("max_chars must be positive")
with self._lock:
workspace = self._load_workspace_locked(workspace_id)
instance = self._workspace_instance_for_live_shell_locked(workspace)
shell = self._load_workspace_shell_locked(workspace_id, shell_id)
payload = self._backend.read_shell(
instance,
workspace_id=workspace_id,
shell_id=shell_id,
cursor=cursor,
max_chars=max_chars,
)
updated_shell = self._workspace_shell_record_from_payload(
workspace_id=workspace_id,
shell_id=shell_id,
payload=payload,
metadata=shell.metadata,
)
with self._lock:
workspace = self._load_workspace_locked(workspace_id)
workspace.state = instance.state
workspace.firecracker_pid = instance.firecracker_pid
workspace.last_error = instance.last_error
workspace.metadata = dict(instance.metadata)
self._save_workspace_locked(workspace)
self._save_workspace_shell_locked(updated_shell)
response = self._serialize_workspace_shell(updated_shell)
response.update(
{
"cursor": int(payload.get("cursor", cursor)),
"next_cursor": int(payload.get("next_cursor", cursor)),
"output": str(payload.get("output", "")),
"truncated": bool(payload.get("truncated", False)),
}
)
return response
def write_shell(
self,
workspace_id: str,
shell_id: str,
*,
input_text: str,
append_newline: bool = True,
) -> dict[str, Any]:
with self._lock:
workspace = self._load_workspace_locked(workspace_id)
instance = self._workspace_instance_for_live_shell_locked(workspace)
shell = self._load_workspace_shell_locked(workspace_id, shell_id)
payload = self._backend.write_shell(
instance,
workspace_id=workspace_id,
shell_id=shell_id,
input_text=input_text,
append_newline=append_newline,
)
updated_shell = self._workspace_shell_record_from_payload(
workspace_id=workspace_id,
shell_id=shell_id,
payload=payload,
metadata=shell.metadata,
)
with self._lock:
workspace = self._load_workspace_locked(workspace_id)
workspace.state = instance.state
workspace.firecracker_pid = instance.firecracker_pid
workspace.last_error = instance.last_error
workspace.metadata = dict(instance.metadata)
self._save_workspace_locked(workspace)
self._save_workspace_shell_locked(updated_shell)
response = self._serialize_workspace_shell(updated_shell)
response.update(
{
"input_length": int(payload.get("input_length", len(input_text))),
"append_newline": bool(payload.get("append_newline", append_newline)),
}
)
return response
def signal_shell(
self,
workspace_id: str,
shell_id: str,
*,
signal_name: str = "INT",
) -> dict[str, Any]:
normalized_signal = signal_name.upper()
if normalized_signal not in WORKSPACE_SHELL_SIGNAL_NAMES:
raise ValueError(
f"signal_name must be one of: {', '.join(WORKSPACE_SHELL_SIGNAL_NAMES)}"
)
with self._lock:
workspace = self._load_workspace_locked(workspace_id)
instance = self._workspace_instance_for_live_shell_locked(workspace)
shell = self._load_workspace_shell_locked(workspace_id, shell_id)
payload = self._backend.signal_shell(
instance,
workspace_id=workspace_id,
shell_id=shell_id,
signal_name=normalized_signal,
)
updated_shell = self._workspace_shell_record_from_payload(
workspace_id=workspace_id,
shell_id=shell_id,
payload=payload,
metadata=shell.metadata,
)
with self._lock:
workspace = self._load_workspace_locked(workspace_id)
workspace.state = instance.state
workspace.firecracker_pid = instance.firecracker_pid
workspace.last_error = instance.last_error
workspace.metadata = dict(instance.metadata)
self._save_workspace_locked(workspace)
self._save_workspace_shell_locked(updated_shell)
response = self._serialize_workspace_shell(updated_shell)
response["signal"] = str(payload.get("signal", normalized_signal))
return response
def close_shell(
self,
workspace_id: str,
shell_id: str,
) -> dict[str, Any]:
with self._lock:
workspace = self._load_workspace_locked(workspace_id)
instance = self._workspace_instance_for_live_shell_locked(workspace)
shell = self._load_workspace_shell_locked(workspace_id, shell_id)
payload = self._backend.close_shell(
instance,
workspace_id=workspace_id,
shell_id=shell_id,
)
closed_shell = self._workspace_shell_record_from_payload(
workspace_id=workspace_id,
shell_id=shell_id,
payload=payload,
metadata=shell.metadata,
)
with self._lock:
workspace = self._load_workspace_locked(workspace_id)
workspace.state = instance.state
workspace.firecracker_pid = instance.firecracker_pid
workspace.last_error = instance.last_error
workspace.metadata = dict(instance.metadata)
self._save_workspace_locked(workspace)
self._delete_workspace_shell_locked(workspace_id, shell_id)
response = self._serialize_workspace_shell(closed_shell)
response["closed"] = bool(payload.get("closed", True))
return response
def status_workspace(self, workspace_id: str) -> dict[str, Any]:
with self._lock:
workspace = self._load_workspace_locked(workspace_id)
@ -1364,6 +1901,7 @@ class VmManager:
instance = workspace.to_instance(
workdir=self._workspace_runtime_dir(workspace.workspace_id)
)
self._close_workspace_shells_locked(workspace, instance)
if workspace.state == "started":
self._backend.stop(instance)
workspace.state = "stopped"
@ -1423,6 +1961,20 @@ class VmManager:
"metadata": workspace.metadata,
}
def _serialize_workspace_shell(self, shell: WorkspaceShellRecord) -> dict[str, Any]:
return {
"workspace_id": shell.workspace_id,
"shell_id": shell.shell_id,
"cwd": shell.cwd,
"cols": shell.cols,
"rows": shell.rows,
"state": shell.state,
"started_at": shell.started_at,
"ended_at": shell.ended_at,
"exit_code": shell.exit_code,
"execution_mode": shell.execution_mode,
}
def _require_guest_boot_or_opt_in(self, instance: VmInstance) -> None:
if self._runtime_capabilities.supports_vm_boot or instance.allow_host_compat:
return
@ -1445,6 +1997,19 @@ class VmManager:
"host execution."
)
def _require_workspace_shell_support(self, instance: VmInstance) -> None:
if self._backend_name == "mock":
return
if self._runtime_capabilities.supports_guest_exec:
return
reason = self._runtime_capabilities.reason or (
"runtime does not support guest interactive shell sessions"
)
raise RuntimeError(
"interactive shells require guest execution and are unavailable for this "
f"workspace: {reason}"
)
def _get_instance_locked(self, vm_id: str) -> VmInstance:
try:
return self._instances[vm_id]
@ -1552,14 +2117,14 @@ class VmManager:
bytes_written=bytes_written,
)
def _ensure_workspace_guest_seed_support(self, instance: VmInstance) -> None:
def _ensure_workspace_guest_agent_support(self, instance: VmInstance) -> None:
if self._runtime_paths is None or self._runtime_paths.guest_agent_path is None:
raise RuntimeError(
"runtime bundle does not provide a guest agent for workspace seeding"
"runtime bundle does not provide a guest agent for workspace operations"
)
rootfs_image = instance.metadata.get("rootfs_image")
if rootfs_image is None or rootfs_image == "":
raise RuntimeError("workspace rootfs image is unavailable for guest seeding")
raise RuntimeError("workspace rootfs image is unavailable for guest operations")
_patch_rootfs_guest_agent(Path(rootfs_image), self._runtime_paths.guest_agent_path)
def _workspace_dir(self, workspace_id: str) -> Path:
@ -1574,9 +2139,15 @@ class VmManager:
def _workspace_commands_dir(self, workspace_id: str) -> Path:
return self._workspace_dir(workspace_id) / WORKSPACE_COMMANDS_DIRNAME
def _workspace_shells_dir(self, workspace_id: str) -> Path:
return self._workspace_dir(workspace_id) / WORKSPACE_SHELLS_DIRNAME
def _workspace_metadata_path(self, workspace_id: str) -> Path:
return self._workspace_dir(workspace_id) / "workspace.json"
def _workspace_shell_record_path(self, workspace_id: str, shell_id: str) -> Path:
return self._workspace_shells_dir(workspace_id) / f"{shell_id}.json"
def _count_workspaces_locked(self) -> int:
return sum(1 for _ in self._workspaces_dir.glob("*/workspace.json"))
@ -1609,6 +2180,7 @@ class VmManager:
instance = workspace.to_instance(
workdir=self._workspace_runtime_dir(workspace.workspace_id)
)
self._close_workspace_shells_locked(workspace, instance)
if workspace.state == "started":
self._backend.stop(instance)
workspace.state = "stopped"
@ -1704,3 +2276,97 @@ class VmManager:
entry["stderr"] = stderr
entries.append(entry)
return entries
def _workspace_instance_for_live_shell_locked(self, workspace: WorkspaceRecord) -> VmInstance:
self._ensure_workspace_not_expired_locked(workspace, time.time())
self._refresh_workspace_liveness_locked(workspace)
if workspace.state != "started":
raise RuntimeError(
"workspace "
f"{workspace.workspace_id} must be in 'started' state before shell operations"
)
instance = workspace.to_instance(
workdir=self._workspace_runtime_dir(workspace.workspace_id)
)
self._require_workspace_shell_support(instance)
return instance
def _workspace_shell_record_from_payload(
self,
*,
workspace_id: str,
shell_id: str,
payload: dict[str, Any],
metadata: dict[str, str] | None = None,
) -> WorkspaceShellRecord:
return WorkspaceShellRecord(
workspace_id=workspace_id,
shell_id=str(payload.get("shell_id", shell_id)),
cwd=str(payload.get("cwd", WORKSPACE_GUEST_PATH)),
cols=int(payload.get("cols", DEFAULT_SHELL_COLS)),
rows=int(payload.get("rows", DEFAULT_SHELL_ROWS)),
state=cast(WorkspaceShellState, str(payload.get("state", "stopped"))),
started_at=float(payload.get("started_at", time.time())),
ended_at=(
None if payload.get("ended_at") is None else float(payload.get("ended_at", 0.0))
),
exit_code=(
None if payload.get("exit_code") is None else int(payload.get("exit_code", 0))
),
execution_mode=str(payload.get("execution_mode", "pending")),
metadata=dict(metadata or {}),
)
def _load_workspace_shell_locked(
self,
workspace_id: str,
shell_id: str,
) -> WorkspaceShellRecord:
record_path = self._workspace_shell_record_path(workspace_id, shell_id)
if not record_path.exists():
raise ValueError(f"shell {shell_id!r} does not exist in workspace {workspace_id!r}")
payload = json.loads(record_path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise RuntimeError(f"shell record at {record_path} is invalid")
return WorkspaceShellRecord.from_payload(payload)
def _save_workspace_shell_locked(self, shell: WorkspaceShellRecord) -> None:
record_path = self._workspace_shell_record_path(shell.workspace_id, shell.shell_id)
record_path.parent.mkdir(parents=True, exist_ok=True)
record_path.write_text(
json.dumps(shell.to_payload(), indent=2, sort_keys=True),
encoding="utf-8",
)
def _delete_workspace_shell_locked(self, workspace_id: str, shell_id: str) -> None:
record_path = self._workspace_shell_record_path(workspace_id, shell_id)
if record_path.exists():
record_path.unlink()
def _list_workspace_shells_locked(self, workspace_id: str) -> list[WorkspaceShellRecord]:
shells_dir = self._workspace_shells_dir(workspace_id)
if not shells_dir.exists():
return []
shells: list[WorkspaceShellRecord] = []
for record_path in sorted(shells_dir.glob("*.json")):
payload = json.loads(record_path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
continue
shells.append(WorkspaceShellRecord.from_payload(payload))
return shells
def _close_workspace_shells_locked(
self,
workspace: WorkspaceRecord,
instance: VmInstance,
) -> None:
for shell in self._list_workspace_shells_locked(workspace.workspace_id):
try:
self._backend.close_shell(
instance,
workspace_id=workspace.workspace_id,
shell_id=shell.shell_id,
)
except Exception:
pass
self._delete_workspace_shell_locked(workspace.workspace_id, shell.shell_id)