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:
parent
2de31306b6
commit
3f8293ad24
28 changed files with 3265 additions and 81 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue