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

@ -207,6 +207,50 @@ class Pyro:
def close_shell(self, workspace_id: str, shell_id: str) -> dict[str, Any]:
return self._manager.close_shell(workspace_id, shell_id)
def start_service(
self,
workspace_id: str,
service_name: str,
*,
command: str,
cwd: str = "/workspace",
readiness: dict[str, Any] | None = None,
ready_timeout_seconds: int = 30,
ready_interval_ms: int = 500,
) -> dict[str, Any]:
return self._manager.start_service(
workspace_id,
service_name,
command=command,
cwd=cwd,
readiness=readiness,
ready_timeout_seconds=ready_timeout_seconds,
ready_interval_ms=ready_interval_ms,
)
def list_services(self, workspace_id: str) -> dict[str, Any]:
return self._manager.list_services(workspace_id)
def status_service(self, workspace_id: str, service_name: str) -> dict[str, Any]:
return self._manager.status_service(workspace_id, service_name)
def logs_service(
self,
workspace_id: str,
service_name: str,
*,
tail_lines: int = 200,
all: bool = False,
) -> dict[str, Any]:
return self._manager.logs_service(
workspace_id,
service_name,
tail_lines=None if all else tail_lines,
)
def stop_service(self, workspace_id: str, service_name: str) -> dict[str, Any]:
return self._manager.stop_service(workspace_id, service_name)
def delete_workspace(self, workspace_id: str) -> dict[str, Any]:
return self._manager.delete_workspace(workspace_id)
@ -458,6 +502,69 @@ class Pyro:
"""Close a persistent workspace shell."""
return self.close_shell(workspace_id, shell_id)
@server.tool()
async def service_start(
workspace_id: str,
service_name: str,
command: str,
cwd: str = "/workspace",
ready_file: str | None = None,
ready_tcp: str | None = None,
ready_http: str | None = None,
ready_command: str | None = None,
ready_timeout_seconds: int = 30,
ready_interval_ms: int = 500,
) -> dict[str, Any]:
"""Start a named long-running service inside a workspace."""
readiness: dict[str, Any] | None = None
if ready_file is not None:
readiness = {"type": "file", "path": ready_file}
elif ready_tcp is not None:
readiness = {"type": "tcp", "address": ready_tcp}
elif ready_http is not None:
readiness = {"type": "http", "url": ready_http}
elif ready_command is not None:
readiness = {"type": "command", "command": ready_command}
return self.start_service(
workspace_id,
service_name,
command=command,
cwd=cwd,
readiness=readiness,
ready_timeout_seconds=ready_timeout_seconds,
ready_interval_ms=ready_interval_ms,
)
@server.tool()
async def service_list(workspace_id: str) -> dict[str, Any]:
"""List named services in one workspace."""
return self.list_services(workspace_id)
@server.tool()
async def service_status(workspace_id: str, service_name: str) -> dict[str, Any]:
"""Inspect one named workspace service."""
return self.status_service(workspace_id, service_name)
@server.tool()
async def service_logs(
workspace_id: str,
service_name: str,
tail_lines: int = 200,
all: bool = False,
) -> dict[str, Any]:
"""Read persisted stdout/stderr for one workspace service."""
return self.logs_service(
workspace_id,
service_name,
tail_lines=tail_lines,
all=all,
)
@server.tool()
async def service_stop(workspace_id: str, service_name: str) -> dict[str, Any]:
"""Stop one running service in a workspace."""
return self.stop_service(workspace_id, service_name)
@server.tool()
async def workspace_delete(workspace_id: str) -> dict[str, Any]:
"""Delete a persistent workspace and its backing sandbox."""