Add workspace service lifecycle with typed readiness

Make persistent workspaces capable of running long-lived background processes instead of forcing everything through one-shot exec calls.

Add workspace service start/list/status/logs/stop across the CLI, Python SDK, and MCP server, with multiple named services per workspace, typed readiness probes (file, tcp, http, and command), and aggregate service counts on workspace status. Keep service state and logs outside /workspace so diff and export semantics stay workspace-scoped, and extend the guest agent plus backends to persist service records and logs across separate calls.

Update the 2.7.0 docs, examples, changelog, and roadmap milestone to reflect the shipped surface.

Validation: uv lock; UV_CACHE_DIR=.uv-cache make check; UV_CACHE_DIR=.uv-cache make dist-check; real guest-backed Firecracker smoke for workspace create, two service starts, list/status/logs, diff unaffected, stop, and delete.
This commit is contained in:
Thales Maciel 2026-03-12 05:36:28 -03:00
parent 84a7e18d4d
commit f504f0a331
28 changed files with 4098 additions and 124 deletions

View file

@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
import time
from pathlib import Path
from typing import Any, cast
@ -58,6 +57,11 @@ def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None:
assert "shell_write" in tool_names
assert "shell_signal" in tool_names
assert "shell_close" in tool_names
assert "service_start" in tool_names
assert "service_list" in tool_names
assert "service_status" in tool_names
assert "service_logs" in tool_names
assert "service_stop" in tool_names
def test_pyro_vm_run_tool_executes(tmp_path: Path) -> None:
@ -141,16 +145,16 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
diff_payload = pyro.diff_workspace(workspace_id)
export_path = tmp_path / "exported-note.txt"
exported = pyro.export_workspace(workspace_id, "note.txt", output_path=export_path)
opened = pyro.open_shell(workspace_id)
shell_id = str(opened["shell_id"])
written = pyro.write_shell(workspace_id, shell_id, input="pwd")
read = pyro.read_shell(workspace_id, shell_id)
deadline = time.time() + 5
while "/workspace" not in str(read["output"]) and time.time() < deadline:
read = pyro.read_shell(workspace_id, shell_id, cursor=0)
time.sleep(0.05)
signaled = pyro.signal_shell(workspace_id, shell_id)
closed = pyro.close_shell(workspace_id, shell_id)
service = pyro.start_service(
workspace_id,
"app",
command="sh -lc 'touch .ready; while true; do sleep 60; done'",
readiness={"type": "file", "path": ".ready"},
)
services = pyro.list_services(workspace_id)
service_status = pyro.status_service(workspace_id, "app")
service_logs = pyro.logs_service(workspace_id, "app", all=True)
service_stopped = pyro.stop_service(workspace_id, "app")
status = pyro.status_workspace(workspace_id)
logs = pyro.logs_workspace(workspace_id)
deleted = pyro.delete_workspace(workspace_id)
@ -158,13 +162,15 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
assert executed["stdout"] == "ok\n"
assert created["workspace_seed"]["mode"] == "directory"
assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
assert written["input_length"] == 3
assert diff_payload["changed"] is True
assert exported["output_path"] == str(export_path)
assert export_path.read_text(encoding="utf-8") == "ok\n"
assert "/workspace" in read["output"]
assert signaled["signal"] == "INT"
assert closed["closed"] is True
assert service["state"] == "running"
assert services["count"] == 1
assert service_status["state"] == "running"
assert service_logs["tail_lines"] is None
assert service_stopped["state"] == "stopped"
assert status["command_count"] == 1
assert status["service_count"] == 1
assert logs["count"] == 1
assert deleted["deleted"] is True