diff --git a/CHANGELOG.md b/CHANGELOG.md index f8ffbcf..b813849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable user-visible changes to `pyro-mcp` are documented here. +## 2.7.0 + +- Added first-class workspace services across the CLI, Python SDK, and MCP server with + `pyro workspace service *`, `Pyro.start_service()` / `list_services()` / `status_service()` / + `logs_service()` / `stop_service()`, and the matching `service_*` MCP tools. +- Added typed readiness probes for workspace services with file, TCP, HTTP, and command checks so + long-running processes can be started and inspected without relying on shell-fragile flows. +- Kept service state and logs outside `/workspace`, and surfaced aggregate service counts from + `workspace status` without polluting workspace diff or export semantics. + ## 2.6.0 - Added explicit host-out workspace operations across the CLI, Python SDK, and MCP server with diff --git a/README.md b/README.md index 7f4f812..ba89cc6 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ It exposes the same runtime in three public forms: - First run transcript: [docs/first-run.md](docs/first-run.md) - Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif) - PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/) -- What's new in 2.6.0: [CHANGELOG.md#260](CHANGELOG.md#260) +- What's new in 2.7.0: [CHANGELOG.md#270](CHANGELOG.md#270) - Host requirements: [docs/host-requirements.md](docs/host-requirements.md) - Integration targets: [docs/integrations.md](docs/integrations.md) - Public contract: [docs/public-contract.md](docs/public-contract.md) @@ -57,7 +57,7 @@ What success looks like: ```bash Platform: linux-x86_64 Runtime: PASS -Catalog version: 2.6.0 +Catalog version: 2.7.0 ... [pull] phase=install environment=debian:12 [pull] phase=ready environment=debian:12 @@ -81,6 +81,7 @@ After the quickstart works: - diff the live workspace against its create-time baseline with `uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID` - export a changed file or directory with `uvx --from pyro-mcp pyro workspace export WORKSPACE_ID note.txt --output ./note.txt` - open a persistent interactive shell with `uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID` +- start long-running workspace services with `uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done'` - move to Python or MCP via [docs/integrations.md](docs/integrations.md) ## Supported Hosts @@ -134,7 +135,7 @@ uvx --from pyro-mcp pyro env list Expected output: ```bash -Catalog version: 2.6.0 +Catalog version: 2.7.0 debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. @@ -218,6 +219,13 @@ pyro workspace shell open WORKSPACE_ID pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd' pyro workspace shell read WORKSPACE_ID SHELL_ID pyro workspace shell close WORKSPACE_ID SHELL_ID +pyro workspace service start WORKSPACE_ID web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done' +pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done' +pyro workspace service list WORKSPACE_ID +pyro workspace service status WORKSPACE_ID web +pyro workspace service logs WORKSPACE_ID web --tail-lines 50 +pyro workspace service stop WORKSPACE_ID web +pyro workspace service stop WORKSPACE_ID worker pyro workspace logs WORKSPACE_ID pyro workspace delete WORKSPACE_ID ``` @@ -226,12 +234,16 @@ Persistent workspaces start in `/workspace` and keep command history until you d machine consumption, add `--json` and read the returned `workspace_id`. Use `--seed-path` when you want the workspace to start from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive instead of an empty workspace. Use `pyro workspace sync push` when you want to import -later host-side changes into a started workspace. Sync is non-atomic in `2.6.0`; if it fails +later host-side changes into a started workspace. Sync is non-atomic in `2.7.0`; if it fails partway through, delete and recreate the workspace from its seed. Use `pyro workspace diff` to compare the live `/workspace` tree to its immutable create-time baseline, and `pyro workspace export` to copy one changed file or directory back to the host. Use `pyro workspace exec` for one-shot non-interactive commands inside a live workspace, and `pyro workspace shell *` when you need a -persistent PTY session that keeps interactive shell state between calls. +persistent PTY session that keeps interactive shell state between calls. Use +`pyro workspace service *` when the workspace needs one or more long-running background processes. +Typed readiness checks prefer `--ready-file`, `--ready-tcp`, or `--ready-http`; keep +`--ready-command` as the escape hatch. Service metadata and logs live outside `/workspace`, so the +internal service state does not appear in `pyro workspace diff` or `pyro workspace export`. ## Public Interfaces diff --git a/docs/first-run.md b/docs/first-run.md index ed2a0a7..c76c45f 100644 --- a/docs/first-run.md +++ b/docs/first-run.md @@ -22,7 +22,7 @@ Networking: tun=yes ip_forward=yes ```bash $ uvx --from pyro-mcp pyro env list -Catalog version: 2.6.0 +Catalog version: 2.7.0 debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. @@ -75,6 +75,7 @@ $ uvx --from pyro-mcp pyro workspace sync push WORKSPACE_ID ./changes $ uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID $ uvx --from pyro-mcp pyro workspace export WORKSPACE_ID note.txt --output ./note.txt $ uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID +$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done' $ uvx --from pyro-mcp pyro mcp serve ``` @@ -118,16 +119,52 @@ $ uvx --from pyro-mcp pyro workspace shell write WORKSPACE_ID SHELL_ID --input ' $ uvx --from pyro-mcp pyro workspace shell read WORKSPACE_ID SHELL_ID /workspace [workspace-shell-read] workspace_id=... shell_id=... state=running cursor=0 next_cursor=... truncated=False execution_mode=guest_vsock + +$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done' +[workspace-service-start] workspace_id=... service=web state=running cwd=/workspace ready_type=file execution_mode=guest_vsock + +$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done' +[workspace-service-start] workspace_id=... service=worker state=running cwd=/workspace ready_type=file execution_mode=guest_vsock + +$ uvx --from pyro-mcp pyro workspace service list WORKSPACE_ID +Workspace: ... +Services: 2 total, 2 running +- web [running] cwd=/workspace readiness=file +- worker [running] cwd=/workspace readiness=file + +$ uvx --from pyro-mcp pyro workspace service status WORKSPACE_ID web +Workspace: ... +Service: web +State: running +Command: sh -lc 'touch .web-ready && while true; do sleep 60; done' +Cwd: /workspace +Readiness: file /workspace/.web-ready +Execution mode: guest_vsock + +$ uvx --from pyro-mcp pyro workspace service logs WORKSPACE_ID web --tail-lines 50 +Workspace: ... +Service: web +State: running +... + +$ uvx --from pyro-mcp pyro workspace service stop WORKSPACE_ID web +[workspace-service-stop] workspace_id=... service=web state=stopped execution_mode=guest_vsock + +$ uvx --from pyro-mcp pyro workspace service stop WORKSPACE_ID worker +[workspace-service-stop] workspace_id=... service=worker state=stopped execution_mode=guest_vsock ``` Use `--seed-path` when the workspace should start from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive instead of an empty `/workspace`. Use `pyro workspace sync push` when you need to import later host-side changes into a started -workspace. Sync is non-atomic in `2.6.0`; if it fails partway through, delete and recreate the +workspace. Sync is non-atomic in `2.7.0`; if it fails partway through, delete and recreate the workspace. Use `pyro workspace diff` to compare the current `/workspace` tree to its immutable create-time baseline, and `pyro workspace export` to copy one changed file or directory back to the host. Use `pyro workspace exec` for one-shot commands and `pyro workspace shell *` when you -need a persistent interactive PTY session in that same workspace. +need a persistent interactive PTY session in that same workspace. Use `pyro workspace service *` +when the workspace needs long-running background processes with typed readiness checks. Internal +service state and logs stay outside `/workspace`, so service runtime data does not appear in +workspace diff or export results. Example output: diff --git a/docs/install.md b/docs/install.md index 847291b..fc4d5c7 100644 --- a/docs/install.md +++ b/docs/install.md @@ -83,7 +83,7 @@ uvx --from pyro-mcp pyro env list Expected output: ```bash -Catalog version: 2.6.0 +Catalog version: 2.7.0 debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. @@ -179,6 +179,7 @@ After the CLI path works, you can move on to: - baseline diff: `pyro workspace diff WORKSPACE_ID` - host export: `pyro workspace export WORKSPACE_ID note.txt --output ./note.txt` - interactive shells: `pyro workspace shell open WORKSPACE_ID` +- long-running services: `pyro workspace service start WORKSPACE_ID app --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done'` - MCP: `pyro mcp serve` - Python SDK: `from pyro_mcp import Pyro` - Demos: `pyro demo` or `pyro demo --network` @@ -197,6 +198,13 @@ pyro workspace shell open WORKSPACE_ID pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd' pyro workspace shell read WORKSPACE_ID SHELL_ID pyro workspace shell close WORKSPACE_ID SHELL_ID +pyro workspace service start WORKSPACE_ID web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done' +pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done' +pyro workspace service list WORKSPACE_ID +pyro workspace service status WORKSPACE_ID web +pyro workspace service logs WORKSPACE_ID web --tail-lines 50 +pyro workspace service stop WORKSPACE_ID web +pyro workspace service stop WORKSPACE_ID worker pyro workspace logs WORKSPACE_ID pyro workspace delete WORKSPACE_ID ``` @@ -205,11 +213,14 @@ Workspace commands default to the persistent `/workspace` directory inside the g the identifier programmatically, use `--json` and read the `workspace_id` field. Use `--seed-path` when the workspace should start from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive. Use `pyro workspace sync push` for later host-side changes to a started workspace. Sync -is non-atomic in `2.6.0`; if it fails partway through, delete and recreate the workspace from its +is non-atomic in `2.7.0`; if it fails partway through, delete and recreate the workspace from its seed. Use `pyro workspace diff` to compare the current workspace tree to its immutable create-time baseline, and `pyro workspace export` to copy one changed file or directory back to the host. Use `pyro workspace exec` for one-shot commands and `pyro workspace shell *` when you need an -interactive PTY that survives across separate calls. +interactive PTY that survives across separate calls. Use `pyro workspace service *` when the +workspace needs long-running background processes with typed readiness probes. Service metadata and +logs stay outside `/workspace`, so the service runtime itself does not show up in workspace diff or +export results. ## Contributor Clone diff --git a/docs/integrations.md b/docs/integrations.md index e66791e..9c0f5ac 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -32,6 +32,7 @@ Recommended surface: - `vm_run` - `workspace_create(seed_path=...)` + `workspace_sync_push` + `workspace_exec` when the agent needs persistent workspace state - `workspace_diff` + `workspace_export` when the agent needs explicit baseline comparison or host-out file transfer +- `start_service` / `list_services` / `status_service` / `logs_service` / `stop_service` when the agent needs long-running processes inside that workspace - `open_shell` / `read_shell` / `write_shell` when the agent needs an interactive PTY inside that workspace Canonical example: @@ -69,6 +70,7 @@ Recommended default: - `Pyro.run_in_vm(...)` - `Pyro.create_workspace(seed_path=...)` + `Pyro.push_workspace_sync(...)` + `Pyro.exec_workspace(...)` when repeated workspace commands are required - `Pyro.diff_workspace(...)` + `Pyro.export_workspace(...)` when the agent needs baseline comparison or host-out file transfer +- `Pyro.start_service(...)` + `Pyro.list_services(...)` + `Pyro.logs_service(...)` when the agent needs long-running background processes in one workspace - `Pyro.open_shell(...)` + `Pyro.write_shell(...)` + `Pyro.read_shell(...)` when the agent needs an interactive PTY inside the workspace Lifecycle note: @@ -83,6 +85,8 @@ Lifecycle note: - use `diff_workspace(...)` when the agent needs a structured comparison against the immutable create-time baseline - use `export_workspace(...)` when the agent needs one file or directory copied back to the host +- use `start_service(...)` when the agent needs long-running processes and typed readiness inside + one workspace - use `open_shell(...)` when the agent needs interactive shell state instead of one-shot execs Examples: diff --git a/docs/public-contract.md b/docs/public-contract.md index f62ce2e..8f4d5af 100644 --- a/docs/public-contract.md +++ b/docs/public-contract.md @@ -24,6 +24,11 @@ Top-level commands: - `pyro workspace exec` - `pyro workspace export` - `pyro workspace diff` +- `pyro workspace service start` +- `pyro workspace service list` +- `pyro workspace service status` +- `pyro workspace service logs` +- `pyro workspace service stop` - `pyro workspace shell open` - `pyro workspace shell read` - `pyro workspace shell write` @@ -58,10 +63,12 @@ Behavioral guarantees: - `pyro workspace sync push WORKSPACE_ID SOURCE_PATH [--dest WORKSPACE_PATH]` imports later host-side directory or archive content into a started workspace. - `pyro workspace export WORKSPACE_ID PATH --output HOST_PATH` exports one file or directory from `/workspace` back to the host. - `pyro workspace diff WORKSPACE_ID` compares the current `/workspace` tree to the immutable create-time baseline. +- `pyro workspace service *` manages long-running named services inside one started workspace with typed readiness probes. - `pyro workspace exec` runs in the persistent `/workspace` for that workspace and does not auto-clean. - `pyro workspace shell *` manages persistent PTY sessions inside a started workspace. - `pyro workspace logs` returns persisted command history for that workspace until `pyro workspace delete`. - Workspace create/status results expose `workspace_seed` metadata describing how `/workspace` was initialized. +- `pyro workspace status` includes aggregate `service_count` and `running_service_count` fields. ## Python SDK Contract @@ -82,6 +89,11 @@ Supported public entrypoints: - `Pyro.push_workspace_sync(workspace_id, source_path, *, dest="/workspace")` - `Pyro.export_workspace(workspace_id, path, *, output_path)` - `Pyro.diff_workspace(workspace_id)` +- `Pyro.start_service(workspace_id, service_name, *, command, cwd="/workspace", readiness=None, ready_timeout_seconds=30, ready_interval_ms=500)` +- `Pyro.list_services(workspace_id)` +- `Pyro.status_service(workspace_id, service_name)` +- `Pyro.logs_service(workspace_id, service_name, *, tail_lines=200, all=False)` +- `Pyro.stop_service(workspace_id, service_name)` - `Pyro.open_shell(workspace_id, *, cwd="/workspace", cols=120, rows=30)` - `Pyro.read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536)` - `Pyro.write_shell(workspace_id, shell_id, *, input, append_newline=True)` @@ -112,6 +124,11 @@ Stable public method names: - `push_workspace_sync(workspace_id, source_path, *, dest="/workspace")` - `export_workspace(workspace_id, path, *, output_path)` - `diff_workspace(workspace_id)` +- `start_service(workspace_id, service_name, *, command, cwd="/workspace", readiness=None, ready_timeout_seconds=30, ready_interval_ms=500)` +- `list_services(workspace_id)` +- `status_service(workspace_id, service_name)` +- `logs_service(workspace_id, service_name, *, tail_lines=200, all=False)` +- `stop_service(workspace_id, service_name)` - `open_shell(workspace_id, *, cwd="/workspace", cols=120, rows=30)` - `read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536)` - `write_shell(workspace_id, shell_id, *, input, append_newline=True)` @@ -140,6 +157,8 @@ Behavioral defaults: - `Pyro.push_workspace_sync(...)` imports later host-side directory or archive content into a started workspace. - `Pyro.export_workspace(...)` exports one file or directory from `/workspace` to an explicit host path. - `Pyro.diff_workspace(...)` compares the current `/workspace` tree to the immutable create-time baseline. +- `Pyro.start_service(...)` starts one named long-running process in a started workspace and waits for its typed readiness probe when configured. +- `Pyro.list_services(...)`, `Pyro.status_service(...)`, `Pyro.logs_service(...)`, and `Pyro.stop_service(...)` manage those persisted workspace services. - `Pyro.exec_vm(...)` runs one command and auto-cleans that VM after the exec completes. - `Pyro.exec_workspace(...)` runs one command in the persistent workspace and leaves it alive. - `Pyro.open_shell(...)` opens a persistent PTY shell attached to one started workspace. @@ -171,6 +190,11 @@ Persistent workspace tools: - `workspace_exec` - `workspace_export` - `workspace_diff` +- `service_start` +- `service_list` +- `service_status` +- `service_logs` +- `service_stop` - `shell_open` - `shell_read` - `shell_write` @@ -190,6 +214,7 @@ Behavioral defaults: - `workspace_sync_push` imports later host-side directory or archive content into a started workspace, with an optional `dest` under `/workspace`. - `workspace_export` exports one file or directory from `/workspace` to an explicit host path. - `workspace_diff` compares the current `/workspace` tree to the immutable create-time baseline. +- `service_start`, `service_list`, `service_status`, `service_logs`, and `service_stop` manage persistent named services inside a started workspace. - `vm_exec` runs one command and auto-cleans that VM after the exec completes. - `workspace_exec` runs one command in a persistent `/workspace` and leaves the workspace alive. - `shell_open`, `shell_read`, `shell_write`, `shell_signal`, and `shell_close` manage persistent PTY shells inside a started workspace. diff --git a/docs/roadmap/task-workspace-ga.md b/docs/roadmap/task-workspace-ga.md index 631f2af..91702dc 100644 --- a/docs/roadmap/task-workspace-ga.md +++ b/docs/roadmap/task-workspace-ga.md @@ -2,13 +2,14 @@ This roadmap turns the agent-workspace vision into release-sized milestones. -Current baseline is `2.6.0`: +Current baseline is `2.7.0`: - workspace persistence exists and the public surface is now workspace-first - host crossing currently covers create-time seeding, later sync push, and explicit export - persistent PTY shell sessions exist alongside one-shot `workspace exec` - immutable create-time baselines now power whole-workspace diff -- no service, snapshot, reset, or secrets contract exists yet +- multi-service lifecycle exists with typed readiness and aggregate workspace status counts +- no snapshot, reset, or secrets contract exists yet Locked roadmap decisions: @@ -30,7 +31,7 @@ also expected to update: 1. [`2.4.0` Workspace Contract Pivot](task-workspace-ga/2.4.0-workspace-contract-pivot.md) - Done 2. [`2.5.0` PTY Shell Sessions](task-workspace-ga/2.5.0-pty-shell-sessions.md) - Done 3. [`2.6.0` Structured Export And Baseline Diff](task-workspace-ga/2.6.0-structured-export-and-baseline-diff.md) - Done -4. [`2.7.0` Service Lifecycle And Typed Readiness](task-workspace-ga/2.7.0-service-lifecycle-and-typed-readiness.md) +4. [`2.7.0` Service Lifecycle And Typed Readiness](task-workspace-ga/2.7.0-service-lifecycle-and-typed-readiness.md) - Done 5. [`2.8.0` Named Snapshots And Reset](task-workspace-ga/2.8.0-named-snapshots-and-reset.md) 6. [`2.9.0` Secrets](task-workspace-ga/2.9.0-secrets.md) 7. [`2.10.0` Network Policy And Host Port Publication](task-workspace-ga/2.10.0-network-policy-and-host-port-publication.md) diff --git a/docs/roadmap/task-workspace-ga/2.7.0-service-lifecycle-and-typed-readiness.md b/docs/roadmap/task-workspace-ga/2.7.0-service-lifecycle-and-typed-readiness.md index d606401..6064fe4 100644 --- a/docs/roadmap/task-workspace-ga/2.7.0-service-lifecycle-and-typed-readiness.md +++ b/docs/roadmap/task-workspace-ga/2.7.0-service-lifecycle-and-typed-readiness.md @@ -1,5 +1,7 @@ # `2.7.0` Service Lifecycle And Typed Readiness +Status: Done + ## Goal Make app-style workspaces practical by adding first-class services and typed diff --git a/examples/python_workspace.py b/examples/python_workspace.py index 141fc8f..f6393a8 100644 --- a/examples/python_workspace.py +++ b/examples/python_workspace.py @@ -26,6 +26,19 @@ def main() -> None: exported_path = Path(export_dir, "note.txt") pyro.export_workspace(workspace_id, "note.txt", output_path=exported_path) print(exported_path.read_text(encoding="utf-8"), end="") + pyro.start_service( + workspace_id, + "web", + command="touch .web-ready && while true; do sleep 60; done", + readiness={"type": "file", "path": ".web-ready"}, + ) + services = pyro.list_services(workspace_id) + print(f"services={services['count']} running={services['running_count']}") + service_status = pyro.status_service(workspace_id, "web") + print(f"service_state={service_status['state']} ready_at={service_status['ready_at']}") + service_logs = pyro.logs_service(workspace_id, "web", tail_lines=20) + print(f"service_stdout_len={len(service_logs['stdout'])}") + pyro.stop_service(workspace_id, "web") logs = pyro.logs_workspace(workspace_id) print(f"workspace_id={workspace_id} command_count={logs['count']}") finally: diff --git a/pyproject.toml b/pyproject.toml index e88ae09..6d5b25c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyro-mcp" -version = "2.6.0" +version = "2.7.0" description = "Ephemeral Firecracker sandboxes with curated environments, persistent workspaces, and MCP tools." readme = "README.md" license = { file = "LICENSE" } diff --git a/runtime_sources/linux-x86_64/guest/pyro_guest_agent.py b/runtime_sources/linux-x86_64/guest/pyro_guest_agent.py index 91a9103..739bc67 100644 --- a/runtime_sources/linux-x86_64/guest/pyro_guest_agent.py +++ b/runtime_sources/linux-x86_64/guest/pyro_guest_agent.py @@ -8,7 +8,8 @@ import fcntl import io import json import os -import pty +import re +import shlex import signal import socket import struct @@ -18,6 +19,8 @@ import tempfile import termios import threading import time +import urllib.error +import urllib.request from pathlib import Path, PurePosixPath from typing import Any @@ -25,6 +28,8 @@ PORT = 5005 BUFFER_SIZE = 65536 WORKSPACE_ROOT = PurePosixPath("/workspace") SHELL_ROOT = Path("/run/pyro-shells") +SERVICE_ROOT = Path("/run/pyro-services") +SERVICE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") SHELL_SIGNAL_MAP = { "HUP": signal.SIGHUP, "INT": signal.SIGINT, @@ -105,6 +110,35 @@ def _normalize_shell_cwd(cwd: str) -> tuple[str, Path]: return str(normalized), host_path +def _normalize_service_name(service_name: str) -> str: + normalized = service_name.strip() + if normalized == "": + raise RuntimeError("service_name is required") + if SERVICE_NAME_RE.fullmatch(normalized) is None: + raise RuntimeError("service_name is invalid") + return normalized + + +def _service_stdout_path(service_name: str) -> Path: + return SERVICE_ROOT / f"{service_name}.stdout" + + +def _service_stderr_path(service_name: str) -> Path: + return SERVICE_ROOT / f"{service_name}.stderr" + + +def _service_status_path(service_name: str) -> Path: + return SERVICE_ROOT / f"{service_name}.status" + + +def _service_runner_path(service_name: str) -> Path: + return SERVICE_ROOT / f"{service_name}.runner.sh" + + +def _service_metadata_path(service_name: str) -> Path: + return SERVICE_ROOT / f"{service_name}.json" + + def _validate_symlink_target(member_path: PurePosixPath, link_target: str) -> None: target = link_target.strip() if target == "": @@ -286,7 +320,7 @@ class GuestShellSession: self._log_path = SHELL_ROOT / f"{shell_id}.log" self._master_fd: int | None = None - master_fd, slave_fd = pty.openpty() + master_fd, slave_fd = os.openpty() try: _set_pty_size(slave_fd, rows, cols) env = os.environ.copy() @@ -512,6 +546,268 @@ def _remove_shell(shell_id: str) -> GuestShellSession: raise RuntimeError(f"shell {shell_id!r} does not exist") from exc +def _read_service_metadata(service_name: str) -> dict[str, Any]: + metadata_path = _service_metadata_path(service_name) + if not metadata_path.exists(): + raise RuntimeError(f"service {service_name!r} does not exist") + payload = json.loads(metadata_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise RuntimeError(f"service record for {service_name!r} is invalid") + return payload + + +def _write_service_metadata(service_name: str, payload: dict[str, Any]) -> None: + _service_metadata_path(service_name).write_text( + json.dumps(payload, indent=2, sort_keys=True), + encoding="utf-8", + ) + + +def _service_exit_code(service_name: str) -> int | None: + status_path = _service_status_path(service_name) + if not status_path.exists(): + return None + raw_value = status_path.read_text(encoding="utf-8", errors="ignore").strip() + if raw_value == "": + return None + return int(raw_value) + + +def _service_pid_running(pid: int | None) -> bool: + if pid is None: + return False + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _tail_service_text(path: Path, *, tail_lines: int | None) -> tuple[str, bool]: + if not path.exists(): + return "", False + text = path.read_text(encoding="utf-8", errors="replace") + if tail_lines is None: + return text, False + lines = text.splitlines(keepends=True) + if len(lines) <= tail_lines: + return text, False + return "".join(lines[-tail_lines:]), True + + +def _stop_service_process(pid: int) -> tuple[bool, bool]: + try: + os.killpg(pid, signal.SIGTERM) + except ProcessLookupError: + return False, False + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if not _service_pid_running(pid): + return True, False + time.sleep(0.1) + try: + os.killpg(pid, signal.SIGKILL) + except ProcessLookupError: + return True, False + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if not _service_pid_running(pid): + return True, True + time.sleep(0.1) + return True, True + + +def _refresh_service_payload(service_name: str, payload: dict[str, Any]) -> dict[str, Any]: + if str(payload.get("state", "stopped")) != "running": + return payload + pid = payload.get("pid") + normalized_pid = None if pid is None else int(pid) + if _service_pid_running(normalized_pid): + return payload + refreshed = dict(payload) + refreshed["state"] = "exited" + refreshed["ended_at"] = refreshed.get("ended_at") or time.time() + refreshed["exit_code"] = _service_exit_code(service_name) + _write_service_metadata(service_name, refreshed) + return refreshed + + +def _run_readiness_probe(readiness: dict[str, Any] | None, *, cwd: Path) -> bool: + if readiness is None: + return True + readiness_type = str(readiness["type"]) + if readiness_type == "file": + _, ready_path = _normalize_destination(str(readiness["path"])) + return ready_path.exists() + if readiness_type == "tcp": + host, raw_port = str(readiness["address"]).rsplit(":", 1) + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(1) + try: + sock.connect((host, int(raw_port))) + except OSError: + return False + return True + if readiness_type == "http": + request = urllib.request.Request(str(readiness["url"]), method="GET") + try: + with urllib.request.urlopen(request, timeout=2) as response: # noqa: S310 + return 200 <= int(response.status) < 400 + except (urllib.error.URLError, TimeoutError, ValueError): + return False + if readiness_type == "command": + proc = subprocess.run( # noqa: S603 + ["/bin/sh", "-lc", str(readiness["command"])], + cwd=str(cwd), + text=True, + capture_output=True, + timeout=10, + check=False, + ) + return proc.returncode == 0 + raise RuntimeError(f"unsupported readiness type: {readiness_type}") + + +def _start_service( + *, + service_name: str, + command: str, + cwd_text: str, + readiness: dict[str, Any] | None, + ready_timeout_seconds: int, + ready_interval_ms: int, +) -> dict[str, Any]: + normalized_service_name = _normalize_service_name(service_name) + normalized_cwd, cwd_path = _normalize_shell_cwd(cwd_text) + existing = None + metadata_path = _service_metadata_path(normalized_service_name) + if metadata_path.exists(): + existing = _refresh_service_payload( + normalized_service_name, + _read_service_metadata(normalized_service_name), + ) + if existing is not None and str(existing.get("state", "stopped")) == "running": + raise RuntimeError(f"service {normalized_service_name!r} is already running") + SERVICE_ROOT.mkdir(parents=True, exist_ok=True) + stdout_path = _service_stdout_path(normalized_service_name) + stderr_path = _service_stderr_path(normalized_service_name) + status_path = _service_status_path(normalized_service_name) + runner_path = _service_runner_path(normalized_service_name) + stdout_path.write_text("", encoding="utf-8") + stderr_path.write_text("", encoding="utf-8") + status_path.unlink(missing_ok=True) + runner_path.write_text( + "\n".join( + [ + "#!/bin/sh", + "set +e", + f"cd {shlex.quote(str(cwd_path))}", + ( + f"/bin/sh -lc {shlex.quote(command)}" + f" >> {shlex.quote(str(stdout_path))}" + f" 2>> {shlex.quote(str(stderr_path))}" + ), + "status=$?", + f"printf '%s' \"$status\" > {shlex.quote(str(status_path))}", + "exit \"$status\"", + ] + ) + + "\n", + encoding="utf-8", + ) + runner_path.chmod(0o700) + process = subprocess.Popen( # noqa: S603 + [str(runner_path)], + cwd=str(cwd_path), + text=True, + start_new_session=True, + ) + payload: dict[str, Any] = { + "service_name": normalized_service_name, + "command": command, + "cwd": normalized_cwd, + "state": "running", + "started_at": time.time(), + "readiness": readiness, + "ready_at": None, + "ended_at": None, + "exit_code": None, + "pid": process.pid, + "stop_reason": None, + } + _write_service_metadata(normalized_service_name, payload) + deadline = time.monotonic() + ready_timeout_seconds + while True: + payload = _refresh_service_payload(normalized_service_name, payload) + if str(payload.get("state", "stopped")) != "running": + payload["state"] = "failed" + payload["stop_reason"] = "process_exited_before_ready" + payload["ended_at"] = payload.get("ended_at") or time.time() + _write_service_metadata(normalized_service_name, payload) + return payload + if _run_readiness_probe(readiness, cwd=cwd_path): + payload["ready_at"] = time.time() + _write_service_metadata(normalized_service_name, payload) + return payload + if time.monotonic() >= deadline: + _stop_service_process(process.pid) + payload = _refresh_service_payload(normalized_service_name, payload) + payload["state"] = "failed" + payload["stop_reason"] = "readiness_timeout" + payload["ended_at"] = payload.get("ended_at") or time.time() + _write_service_metadata(normalized_service_name, payload) + return payload + time.sleep(max(ready_interval_ms, 1) / 1000) + + +def _status_service(service_name: str) -> dict[str, Any]: + normalized_service_name = _normalize_service_name(service_name) + return _refresh_service_payload( + normalized_service_name, + _read_service_metadata(normalized_service_name), + ) + + +def _logs_service(service_name: str, *, tail_lines: int | None) -> dict[str, Any]: + normalized_service_name = _normalize_service_name(service_name) + payload = _status_service(normalized_service_name) + stdout, stdout_truncated = _tail_service_text( + _service_stdout_path(normalized_service_name), + tail_lines=tail_lines, + ) + stderr, stderr_truncated = _tail_service_text( + _service_stderr_path(normalized_service_name), + tail_lines=tail_lines, + ) + payload.update( + { + "stdout": stdout, + "stderr": stderr, + "tail_lines": tail_lines, + "truncated": stdout_truncated or stderr_truncated, + } + ) + return payload + + +def _stop_service(service_name: str) -> dict[str, Any]: + normalized_service_name = _normalize_service_name(service_name) + payload = _status_service(normalized_service_name) + pid = payload.get("pid") + if pid is None: + return payload + if str(payload.get("state", "stopped")) == "running": + _, killed = _stop_service_process(int(pid)) + payload = _status_service(normalized_service_name) + payload["state"] = "stopped" + payload["stop_reason"] = "sigkill" if killed else "sigterm" + payload["ended_at"] = payload.get("ended_at") or time.time() + _write_service_metadata(normalized_service_name, payload) + return payload + + def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]: action = str(request.get("action", "exec")) if action == "extract_archive": @@ -564,6 +860,31 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]: if shell_id == "": raise RuntimeError("shell_id is required") return _remove_shell(shell_id).close() + if action == "start_service": + service_name = str(request.get("service_name", "")).strip() + command = str(request.get("command", "")) + cwd_text = str(request.get("cwd", "/workspace")) + readiness = request.get("readiness") + readiness_payload = dict(readiness) if isinstance(readiness, dict) else None + return _start_service( + service_name=service_name, + command=command, + cwd_text=cwd_text, + readiness=readiness_payload, + ready_timeout_seconds=int(request.get("ready_timeout_seconds", 30)), + ready_interval_ms=int(request.get("ready_interval_ms", 500)), + ) + if action == "status_service": + service_name = str(request.get("service_name", "")).strip() + return _status_service(service_name) + if action == "logs_service": + service_name = str(request.get("service_name", "")).strip() + tail_lines = request.get("tail_lines") + normalized_tail_lines = None if tail_lines is None else int(tail_lines) + return _logs_service(service_name, tail_lines=normalized_tail_lines) + if action == "stop_service": + service_name = str(request.get("service_name", "")).strip() + return _stop_service(service_name) command = str(request.get("command", "")) timeout_seconds = int(request.get("timeout_seconds", 30)) return _run_command(command, timeout_seconds) @@ -571,6 +892,7 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]: def main() -> None: SHELL_ROOT.mkdir(parents=True, exist_ok=True) + SERVICE_ROOT.mkdir(parents=True, exist_ok=True) family = getattr(socket, "AF_VSOCK", None) if family is None: raise SystemExit("AF_VSOCK is unavailable") diff --git a/src/pyro_mcp/api.py b/src/pyro_mcp/api.py index 0653759..a364c78 100644 --- a/src/pyro_mcp/api.py +++ b/src/pyro_mcp/api.py @@ -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.""" diff --git a/src/pyro_mcp/cli.py b/src/pyro_mcp/cli.py index f0e5aa3..9e7a319 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -17,6 +17,9 @@ from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report from pyro_mcp.vm_environments import DEFAULT_CATALOG_VERSION from pyro_mcp.vm_manager import ( DEFAULT_MEM_MIB, + DEFAULT_SERVICE_LOG_TAIL_LINES, + DEFAULT_SERVICE_READY_INTERVAL_MS, + DEFAULT_SERVICE_READY_TIMEOUT_SECONDS, DEFAULT_VCPU_COUNT, WORKSPACE_GUEST_PATH, WORKSPACE_SHELL_SIGNAL_NAMES, @@ -171,6 +174,11 @@ def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> N f"{int(payload.get('mem_mib', 0))} MiB" ) print(f"Command count: {int(payload.get('command_count', 0))}") + print( + "Services: " + f"{int(payload.get('running_service_count', 0))}/" + f"{int(payload.get('service_count', 0))} running" + ) last_command = payload.get("last_command") if isinstance(last_command, dict): print( @@ -304,6 +312,42 @@ def _print_workspace_shell_read_human(payload: dict[str, Any]) -> None: ) +def _print_workspace_service_summary_human(payload: dict[str, Any], *, prefix: str) -> None: + print( + f"[{prefix}] " + f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " + f"service_name={str(payload.get('service_name', 'unknown'))} " + f"state={str(payload.get('state', 'unknown'))} " + f"cwd={str(payload.get('cwd', WORKSPACE_GUEST_PATH))} " + f"execution_mode={str(payload.get('execution_mode', 'unknown'))}", + file=sys.stderr, + flush=True, + ) + + +def _print_workspace_service_list_human(payload: dict[str, Any]) -> None: + services = payload.get("services") + if not isinstance(services, list) or not services: + print("No workspace services found.") + return + for service in services: + if not isinstance(service, dict): + continue + print( + f"{str(service.get('service_name', 'unknown'))} " + f"[{str(service.get('state', 'unknown'))}] " + f"cwd={str(service.get('cwd', WORKSPACE_GUEST_PATH))}" + ) + + +def _print_workspace_service_logs_human(payload: dict[str, Any]) -> None: + stdout = str(payload.get("stdout", "")) + stderr = str(payload.get("stderr", "")) + _write_stream(stdout, stream=sys.stdout) + _write_stream(stderr, stream=sys.stderr) + _print_workspace_service_summary_human(payload, prefix="workspace-service-logs") + + class _HelpFormatter( argparse.RawDescriptionHelpFormatter, argparse.ArgumentDefaultsHelpFormatter, @@ -339,6 +383,8 @@ def _build_parser() -> argparse.ArgumentParser: pyro workspace diff WORKSPACE_ID pyro workspace export WORKSPACE_ID note.txt --output ./note.txt pyro workspace shell open WORKSPACE_ID + pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \ + sh -lc 'touch .ready && while true; do sleep 60; done' Use `pyro mcp serve` only after the CLI validation path works. """ @@ -549,6 +595,8 @@ def _build_parser() -> argparse.ArgumentParser: pyro workspace diff WORKSPACE_ID pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt pyro workspace shell open WORKSPACE_ID + pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \ + sh -lc 'touch .ready && while true; do sleep 60; done' pyro workspace logs WORKSPACE_ID """ ), @@ -570,6 +618,8 @@ def _build_parser() -> argparse.ArgumentParser: pyro workspace create debian:12 --seed-path ./repo pyro workspace sync push WORKSPACE_ID ./changes pyro workspace diff WORKSPACE_ID + pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \ + sh -lc 'touch .ready && while true; do sleep 60; done' """ ), formatter_class=_HelpFormatter, @@ -943,6 +993,160 @@ def _build_parser() -> argparse.ArgumentParser: action="store_true", help="Print structured JSON instead of human-readable output.", ) + workspace_service_parser = workspace_subparsers.add_parser( + "service", + help="Manage long-running services inside a workspace.", + description=( + "Start, inspect, and stop named long-running services inside one started workspace." + ), + epilog=dedent( + """ + Examples: + pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \ + sh -lc 'touch .ready && while true; do sleep 60; done' + pyro workspace service list WORKSPACE_ID + pyro workspace service status WORKSPACE_ID app + pyro workspace service logs WORKSPACE_ID app --tail-lines 50 + pyro workspace service stop WORKSPACE_ID app + + Use `--ready-file` by default in the curated Debian environments. `--ready-command` + remains available as an escape hatch. + """ + ), + formatter_class=_HelpFormatter, + ) + workspace_service_subparsers = workspace_service_parser.add_subparsers( + dest="workspace_service_command", + required=True, + metavar="SERVICE", + ) + workspace_service_start_parser = workspace_service_subparsers.add_parser( + "start", + help="Start one named long-running service.", + description="Start a named service inside a started workspace with optional readiness.", + epilog=dedent( + """ + Examples: + pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \ + sh -lc 'touch .ready && while true; do sleep 60; done' + pyro workspace service start WORKSPACE_ID app --ready-command 'test -f .ready' -- \ + sh -lc 'touch .ready && while true; do sleep 60; done' + """ + ), + formatter_class=_HelpFormatter, + ) + workspace_service_start_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") + workspace_service_start_parser.add_argument("service_name", metavar="SERVICE_NAME") + workspace_service_start_parser.add_argument( + "--cwd", + default=WORKSPACE_GUEST_PATH, + help="Service working directory. Relative values resolve inside `/workspace`.", + ) + workspace_service_start_parser.add_argument( + "--ready-file", + help="Mark the service ready once this workspace path exists.", + ) + workspace_service_start_parser.add_argument( + "--ready-tcp", + help="Mark the service ready once this HOST:PORT accepts guest-local TCP connections.", + ) + workspace_service_start_parser.add_argument( + "--ready-http", + help="Mark the service ready once this guest-local URL returns 2xx or 3xx.", + ) + workspace_service_start_parser.add_argument( + "--ready-command", + help="Escape hatch readiness probe command. Use typed readiness when possible.", + ) + workspace_service_start_parser.add_argument( + "--ready-timeout-seconds", + type=int, + default=DEFAULT_SERVICE_READY_TIMEOUT_SECONDS, + help="Maximum time to wait for readiness before failing the service start.", + ) + workspace_service_start_parser.add_argument( + "--ready-interval-ms", + type=int, + default=DEFAULT_SERVICE_READY_INTERVAL_MS, + help="Polling interval between readiness checks.", + ) + workspace_service_start_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_service_start_parser.add_argument( + "command_args", + nargs="*", + metavar="ARG", + help="Service command and arguments. Prefix them with `--`.", + ) + workspace_service_list_parser = workspace_service_subparsers.add_parser( + "list", + help="List named services in one workspace.", + description="List named services and their current states for one workspace.", + epilog="Example:\n pyro workspace service list WORKSPACE_ID", + formatter_class=_HelpFormatter, + ) + workspace_service_list_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") + workspace_service_list_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_service_status_parser = workspace_service_subparsers.add_parser( + "status", + help="Inspect one service.", + description="Show state and readiness metadata for one named workspace service.", + epilog="Example:\n pyro workspace service status WORKSPACE_ID app", + formatter_class=_HelpFormatter, + ) + workspace_service_status_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") + workspace_service_status_parser.add_argument("service_name", metavar="SERVICE_NAME") + workspace_service_status_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_service_logs_parser = workspace_service_subparsers.add_parser( + "logs", + help="Read persisted service stdout and stderr.", + description="Read service stdout and stderr without using `workspace logs`.", + epilog="Example:\n pyro workspace service logs WORKSPACE_ID app --tail-lines 50", + formatter_class=_HelpFormatter, + ) + workspace_service_logs_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") + workspace_service_logs_parser.add_argument("service_name", metavar="SERVICE_NAME") + workspace_service_logs_parser.add_argument( + "--tail-lines", + type=int, + default=DEFAULT_SERVICE_LOG_TAIL_LINES, + help="Maximum number of trailing lines to return from each service log stream.", + ) + workspace_service_logs_parser.add_argument( + "--all", + action="store_true", + help="Return full stdout and stderr instead of tailing them.", + ) + workspace_service_logs_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_service_stop_parser = workspace_service_subparsers.add_parser( + "stop", + help="Stop one running service.", + description="Stop one named workspace service with TERM then KILL fallback.", + epilog="Example:\n pyro workspace service stop WORKSPACE_ID app", + formatter_class=_HelpFormatter, + ) + workspace_service_stop_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") + workspace_service_stop_parser.add_argument("service_name", metavar="SERVICE_NAME") + workspace_service_stop_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) workspace_status_parser = workspace_subparsers.add_parser( "status", help="Inspect one workspace.", @@ -1372,6 +1576,128 @@ def main() -> None: else: _print_workspace_shell_summary_human(payload, prefix="workspace-shell-close") return + if args.workspace_command == "service": + if args.workspace_service_command == "start": + readiness_count = sum( + value is not None + for value in ( + args.ready_file, + args.ready_tcp, + args.ready_http, + args.ready_command, + ) + ) + if readiness_count > 1: + error = ( + "choose at most one of --ready-file, --ready-tcp, " + "--ready-http, or --ready-command" + ) + if bool(args.json): + _print_json({"ok": False, "error": error}) + else: + print(f"[error] {error}", file=sys.stderr, flush=True) + raise SystemExit(1) + readiness: dict[str, Any] | None = None + if args.ready_file is not None: + readiness = {"type": "file", "path": args.ready_file} + elif args.ready_tcp is not None: + readiness = {"type": "tcp", "address": args.ready_tcp} + elif args.ready_http is not None: + readiness = {"type": "http", "url": args.ready_http} + elif args.ready_command is not None: + readiness = {"type": "command", "command": args.ready_command} + command = _require_command(args.command_args) + try: + payload = pyro.start_service( + args.workspace_id, + args.service_name, + command=command, + cwd=args.cwd, + readiness=readiness, + ready_timeout_seconds=args.ready_timeout_seconds, + ready_interval_ms=args.ready_interval_ms, + ) + except Exception as exc: # noqa: BLE001 + if bool(args.json): + _print_json({"ok": False, "error": str(exc)}) + else: + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + if bool(args.json): + _print_json(payload) + else: + _print_workspace_service_summary_human( + payload, + prefix="workspace-service-start", + ) + return + if args.workspace_service_command == "list": + try: + payload = pyro.list_services(args.workspace_id) + except Exception as exc: # noqa: BLE001 + if bool(args.json): + _print_json({"ok": False, "error": str(exc)}) + else: + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + if bool(args.json): + _print_json(payload) + else: + _print_workspace_service_list_human(payload) + return + if args.workspace_service_command == "status": + try: + payload = pyro.status_service(args.workspace_id, args.service_name) + except Exception as exc: # noqa: BLE001 + if bool(args.json): + _print_json({"ok": False, "error": str(exc)}) + else: + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + if bool(args.json): + _print_json(payload) + else: + _print_workspace_service_summary_human( + payload, + prefix="workspace-service-status", + ) + return + if args.workspace_service_command == "logs": + try: + payload = pyro.logs_service( + args.workspace_id, + args.service_name, + tail_lines=args.tail_lines, + all=bool(args.all), + ) + except Exception as exc: # noqa: BLE001 + if bool(args.json): + _print_json({"ok": False, "error": str(exc)}) + else: + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + if bool(args.json): + _print_json(payload) + else: + _print_workspace_service_logs_human(payload) + return + if args.workspace_service_command == "stop": + try: + payload = pyro.stop_service(args.workspace_id, args.service_name) + except Exception as exc: # noqa: BLE001 + if bool(args.json): + _print_json({"ok": False, "error": str(exc)}) + else: + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + if bool(args.json): + _print_json(payload) + else: + _print_workspace_service_summary_human( + payload, + prefix="workspace-service-stop", + ) + return if args.workspace_command == "status": payload = pyro.status_workspace(args.workspace_id) if bool(args.json): diff --git a/src/pyro_mcp/contract.py b/src/pyro_mcp/contract.py index 0d68527..9a16cc3 100644 --- a/src/pyro_mcp/contract.py +++ b/src/pyro_mcp/contract.py @@ -12,10 +12,12 @@ PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = ( "exec", "export", "logs", + "service", "shell", "status", "sync", ) +PUBLIC_CLI_WORKSPACE_SERVICE_SUBCOMMANDS = ("list", "logs", "start", "status", "stop") PUBLIC_CLI_WORKSPACE_SHELL_SUBCOMMANDS = ("close", "open", "read", "signal", "write") PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS = ("push",) PUBLIC_CLI_WORKSPACE_CREATE_FLAGS = ( @@ -29,6 +31,20 @@ PUBLIC_CLI_WORKSPACE_CREATE_FLAGS = ( ) PUBLIC_CLI_WORKSPACE_DIFF_FLAGS = ("--json",) PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS = ("--output", "--json") +PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS = ("--json",) +PUBLIC_CLI_WORKSPACE_SERVICE_LOGS_FLAGS = ("--tail-lines", "--all", "--json") +PUBLIC_CLI_WORKSPACE_SERVICE_START_FLAGS = ( + "--cwd", + "--ready-file", + "--ready-tcp", + "--ready-http", + "--ready-command", + "--ready-timeout-seconds", + "--ready-interval-ms", + "--json", +) +PUBLIC_CLI_WORKSPACE_SERVICE_STATUS_FLAGS = ("--json",) +PUBLIC_CLI_WORKSPACE_SERVICE_STOP_FLAGS = ("--json",) PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS = ("--cwd", "--cols", "--rows", "--json") PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS = ("--cursor", "--max-chars", "--json") PUBLIC_CLI_WORKSPACE_SHELL_WRITE_FLAGS = ("--input", "--no-newline", "--json") @@ -58,6 +74,8 @@ PUBLIC_SDK_METHODS = ( "export_workspace", "inspect_environment", "list_environments", + "list_services", + "logs_service", "logs_workspace", "network_info_vm", "open_shell", @@ -68,14 +86,22 @@ PUBLIC_SDK_METHODS = ( "reap_expired", "run_in_vm", "signal_shell", + "start_service", "start_vm", + "status_service", "status_vm", "status_workspace", + "stop_service", "stop_vm", "write_shell", ) PUBLIC_MCP_TOOLS = ( + "service_list", + "service_logs", + "service_start", + "service_status", + "service_stop", "shell_close", "shell_open", "shell_read", diff --git a/src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro_guest_agent.py b/src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro_guest_agent.py index 91a9103..739bc67 100755 --- a/src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro_guest_agent.py +++ b/src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro_guest_agent.py @@ -8,7 +8,8 @@ import fcntl import io import json import os -import pty +import re +import shlex import signal import socket import struct @@ -18,6 +19,8 @@ import tempfile import termios import threading import time +import urllib.error +import urllib.request from pathlib import Path, PurePosixPath from typing import Any @@ -25,6 +28,8 @@ PORT = 5005 BUFFER_SIZE = 65536 WORKSPACE_ROOT = PurePosixPath("/workspace") SHELL_ROOT = Path("/run/pyro-shells") +SERVICE_ROOT = Path("/run/pyro-services") +SERVICE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") SHELL_SIGNAL_MAP = { "HUP": signal.SIGHUP, "INT": signal.SIGINT, @@ -105,6 +110,35 @@ def _normalize_shell_cwd(cwd: str) -> tuple[str, Path]: return str(normalized), host_path +def _normalize_service_name(service_name: str) -> str: + normalized = service_name.strip() + if normalized == "": + raise RuntimeError("service_name is required") + if SERVICE_NAME_RE.fullmatch(normalized) is None: + raise RuntimeError("service_name is invalid") + return normalized + + +def _service_stdout_path(service_name: str) -> Path: + return SERVICE_ROOT / f"{service_name}.stdout" + + +def _service_stderr_path(service_name: str) -> Path: + return SERVICE_ROOT / f"{service_name}.stderr" + + +def _service_status_path(service_name: str) -> Path: + return SERVICE_ROOT / f"{service_name}.status" + + +def _service_runner_path(service_name: str) -> Path: + return SERVICE_ROOT / f"{service_name}.runner.sh" + + +def _service_metadata_path(service_name: str) -> Path: + return SERVICE_ROOT / f"{service_name}.json" + + def _validate_symlink_target(member_path: PurePosixPath, link_target: str) -> None: target = link_target.strip() if target == "": @@ -286,7 +320,7 @@ class GuestShellSession: self._log_path = SHELL_ROOT / f"{shell_id}.log" self._master_fd: int | None = None - master_fd, slave_fd = pty.openpty() + master_fd, slave_fd = os.openpty() try: _set_pty_size(slave_fd, rows, cols) env = os.environ.copy() @@ -512,6 +546,268 @@ def _remove_shell(shell_id: str) -> GuestShellSession: raise RuntimeError(f"shell {shell_id!r} does not exist") from exc +def _read_service_metadata(service_name: str) -> dict[str, Any]: + metadata_path = _service_metadata_path(service_name) + if not metadata_path.exists(): + raise RuntimeError(f"service {service_name!r} does not exist") + payload = json.loads(metadata_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise RuntimeError(f"service record for {service_name!r} is invalid") + return payload + + +def _write_service_metadata(service_name: str, payload: dict[str, Any]) -> None: + _service_metadata_path(service_name).write_text( + json.dumps(payload, indent=2, sort_keys=True), + encoding="utf-8", + ) + + +def _service_exit_code(service_name: str) -> int | None: + status_path = _service_status_path(service_name) + if not status_path.exists(): + return None + raw_value = status_path.read_text(encoding="utf-8", errors="ignore").strip() + if raw_value == "": + return None + return int(raw_value) + + +def _service_pid_running(pid: int | None) -> bool: + if pid is None: + return False + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _tail_service_text(path: Path, *, tail_lines: int | None) -> tuple[str, bool]: + if not path.exists(): + return "", False + text = path.read_text(encoding="utf-8", errors="replace") + if tail_lines is None: + return text, False + lines = text.splitlines(keepends=True) + if len(lines) <= tail_lines: + return text, False + return "".join(lines[-tail_lines:]), True + + +def _stop_service_process(pid: int) -> tuple[bool, bool]: + try: + os.killpg(pid, signal.SIGTERM) + except ProcessLookupError: + return False, False + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if not _service_pid_running(pid): + return True, False + time.sleep(0.1) + try: + os.killpg(pid, signal.SIGKILL) + except ProcessLookupError: + return True, False + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if not _service_pid_running(pid): + return True, True + time.sleep(0.1) + return True, True + + +def _refresh_service_payload(service_name: str, payload: dict[str, Any]) -> dict[str, Any]: + if str(payload.get("state", "stopped")) != "running": + return payload + pid = payload.get("pid") + normalized_pid = None if pid is None else int(pid) + if _service_pid_running(normalized_pid): + return payload + refreshed = dict(payload) + refreshed["state"] = "exited" + refreshed["ended_at"] = refreshed.get("ended_at") or time.time() + refreshed["exit_code"] = _service_exit_code(service_name) + _write_service_metadata(service_name, refreshed) + return refreshed + + +def _run_readiness_probe(readiness: dict[str, Any] | None, *, cwd: Path) -> bool: + if readiness is None: + return True + readiness_type = str(readiness["type"]) + if readiness_type == "file": + _, ready_path = _normalize_destination(str(readiness["path"])) + return ready_path.exists() + if readiness_type == "tcp": + host, raw_port = str(readiness["address"]).rsplit(":", 1) + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(1) + try: + sock.connect((host, int(raw_port))) + except OSError: + return False + return True + if readiness_type == "http": + request = urllib.request.Request(str(readiness["url"]), method="GET") + try: + with urllib.request.urlopen(request, timeout=2) as response: # noqa: S310 + return 200 <= int(response.status) < 400 + except (urllib.error.URLError, TimeoutError, ValueError): + return False + if readiness_type == "command": + proc = subprocess.run( # noqa: S603 + ["/bin/sh", "-lc", str(readiness["command"])], + cwd=str(cwd), + text=True, + capture_output=True, + timeout=10, + check=False, + ) + return proc.returncode == 0 + raise RuntimeError(f"unsupported readiness type: {readiness_type}") + + +def _start_service( + *, + service_name: str, + command: str, + cwd_text: str, + readiness: dict[str, Any] | None, + ready_timeout_seconds: int, + ready_interval_ms: int, +) -> dict[str, Any]: + normalized_service_name = _normalize_service_name(service_name) + normalized_cwd, cwd_path = _normalize_shell_cwd(cwd_text) + existing = None + metadata_path = _service_metadata_path(normalized_service_name) + if metadata_path.exists(): + existing = _refresh_service_payload( + normalized_service_name, + _read_service_metadata(normalized_service_name), + ) + if existing is not None and str(existing.get("state", "stopped")) == "running": + raise RuntimeError(f"service {normalized_service_name!r} is already running") + SERVICE_ROOT.mkdir(parents=True, exist_ok=True) + stdout_path = _service_stdout_path(normalized_service_name) + stderr_path = _service_stderr_path(normalized_service_name) + status_path = _service_status_path(normalized_service_name) + runner_path = _service_runner_path(normalized_service_name) + stdout_path.write_text("", encoding="utf-8") + stderr_path.write_text("", encoding="utf-8") + status_path.unlink(missing_ok=True) + runner_path.write_text( + "\n".join( + [ + "#!/bin/sh", + "set +e", + f"cd {shlex.quote(str(cwd_path))}", + ( + f"/bin/sh -lc {shlex.quote(command)}" + f" >> {shlex.quote(str(stdout_path))}" + f" 2>> {shlex.quote(str(stderr_path))}" + ), + "status=$?", + f"printf '%s' \"$status\" > {shlex.quote(str(status_path))}", + "exit \"$status\"", + ] + ) + + "\n", + encoding="utf-8", + ) + runner_path.chmod(0o700) + process = subprocess.Popen( # noqa: S603 + [str(runner_path)], + cwd=str(cwd_path), + text=True, + start_new_session=True, + ) + payload: dict[str, Any] = { + "service_name": normalized_service_name, + "command": command, + "cwd": normalized_cwd, + "state": "running", + "started_at": time.time(), + "readiness": readiness, + "ready_at": None, + "ended_at": None, + "exit_code": None, + "pid": process.pid, + "stop_reason": None, + } + _write_service_metadata(normalized_service_name, payload) + deadline = time.monotonic() + ready_timeout_seconds + while True: + payload = _refresh_service_payload(normalized_service_name, payload) + if str(payload.get("state", "stopped")) != "running": + payload["state"] = "failed" + payload["stop_reason"] = "process_exited_before_ready" + payload["ended_at"] = payload.get("ended_at") or time.time() + _write_service_metadata(normalized_service_name, payload) + return payload + if _run_readiness_probe(readiness, cwd=cwd_path): + payload["ready_at"] = time.time() + _write_service_metadata(normalized_service_name, payload) + return payload + if time.monotonic() >= deadline: + _stop_service_process(process.pid) + payload = _refresh_service_payload(normalized_service_name, payload) + payload["state"] = "failed" + payload["stop_reason"] = "readiness_timeout" + payload["ended_at"] = payload.get("ended_at") or time.time() + _write_service_metadata(normalized_service_name, payload) + return payload + time.sleep(max(ready_interval_ms, 1) / 1000) + + +def _status_service(service_name: str) -> dict[str, Any]: + normalized_service_name = _normalize_service_name(service_name) + return _refresh_service_payload( + normalized_service_name, + _read_service_metadata(normalized_service_name), + ) + + +def _logs_service(service_name: str, *, tail_lines: int | None) -> dict[str, Any]: + normalized_service_name = _normalize_service_name(service_name) + payload = _status_service(normalized_service_name) + stdout, stdout_truncated = _tail_service_text( + _service_stdout_path(normalized_service_name), + tail_lines=tail_lines, + ) + stderr, stderr_truncated = _tail_service_text( + _service_stderr_path(normalized_service_name), + tail_lines=tail_lines, + ) + payload.update( + { + "stdout": stdout, + "stderr": stderr, + "tail_lines": tail_lines, + "truncated": stdout_truncated or stderr_truncated, + } + ) + return payload + + +def _stop_service(service_name: str) -> dict[str, Any]: + normalized_service_name = _normalize_service_name(service_name) + payload = _status_service(normalized_service_name) + pid = payload.get("pid") + if pid is None: + return payload + if str(payload.get("state", "stopped")) == "running": + _, killed = _stop_service_process(int(pid)) + payload = _status_service(normalized_service_name) + payload["state"] = "stopped" + payload["stop_reason"] = "sigkill" if killed else "sigterm" + payload["ended_at"] = payload.get("ended_at") or time.time() + _write_service_metadata(normalized_service_name, payload) + return payload + + def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]: action = str(request.get("action", "exec")) if action == "extract_archive": @@ -564,6 +860,31 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]: if shell_id == "": raise RuntimeError("shell_id is required") return _remove_shell(shell_id).close() + if action == "start_service": + service_name = str(request.get("service_name", "")).strip() + command = str(request.get("command", "")) + cwd_text = str(request.get("cwd", "/workspace")) + readiness = request.get("readiness") + readiness_payload = dict(readiness) if isinstance(readiness, dict) else None + return _start_service( + service_name=service_name, + command=command, + cwd_text=cwd_text, + readiness=readiness_payload, + ready_timeout_seconds=int(request.get("ready_timeout_seconds", 30)), + ready_interval_ms=int(request.get("ready_interval_ms", 500)), + ) + if action == "status_service": + service_name = str(request.get("service_name", "")).strip() + return _status_service(service_name) + if action == "logs_service": + service_name = str(request.get("service_name", "")).strip() + tail_lines = request.get("tail_lines") + normalized_tail_lines = None if tail_lines is None else int(tail_lines) + return _logs_service(service_name, tail_lines=normalized_tail_lines) + if action == "stop_service": + service_name = str(request.get("service_name", "")).strip() + return _stop_service(service_name) command = str(request.get("command", "")) timeout_seconds = int(request.get("timeout_seconds", 30)) return _run_command(command, timeout_seconds) @@ -571,6 +892,7 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]: def main() -> None: SHELL_ROOT.mkdir(parents=True, exist_ok=True) + SERVICE_ROOT.mkdir(parents=True, exist_ok=True) family = getattr(socket, "AF_VSOCK", None) if family is None: raise SystemExit("AF_VSOCK is unavailable") diff --git a/src/pyro_mcp/runtime_bundle/linux-x86_64/manifest.json b/src/pyro_mcp/runtime_bundle/linux-x86_64/manifest.json index d58fbbf..65229d9 100644 --- a/src/pyro_mcp/runtime_bundle/linux-x86_64/manifest.json +++ b/src/pyro_mcp/runtime_bundle/linux-x86_64/manifest.json @@ -25,7 +25,7 @@ "guest": { "agent": { "path": "guest/pyro_guest_agent.py", - "sha256": "4118589ccd8f4ac8200d9cedf25d13ff515d77c28094bbbdb208310247688b40" + "sha256": "58dd2e09d05538228540d8c667b1acb42c2e6c579f7883b70d483072570f2499" } }, "platform": "linux-x86_64", diff --git a/src/pyro_mcp/vm_environments.py b/src/pyro_mcp/vm_environments.py index 8f07ca6..2a8ce4c 100644 --- a/src/pyro_mcp/vm_environments.py +++ b/src/pyro_mcp/vm_environments.py @@ -19,7 +19,7 @@ from typing import Any from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths DEFAULT_ENVIRONMENT_VERSION = "1.0.0" -DEFAULT_CATALOG_VERSION = "2.6.0" +DEFAULT_CATALOG_VERSION = "2.7.0" OCI_MANIFEST_ACCEPT = ", ".join( ( "application/vnd.oci.image.index.v1+json", diff --git a/src/pyro_mcp/vm_guest.py b/src/pyro_mcp/vm_guest.py index c9a8db8..253bc98 100644 --- a/src/pyro_mcp/vm_guest.py +++ b/src/pyro_mcp/vm_guest.py @@ -325,6 +325,102 @@ class VsockExecClient: self._shell_summary_from_payload(payload) return payload + def start_service( + self, + guest_cid: int, + port: int, + *, + service_name: str, + command: str, + cwd: str, + readiness: dict[str, Any] | None, + ready_timeout_seconds: int, + ready_interval_ms: int, + timeout_seconds: int = 60, + uds_path: str | None = None, + ) -> dict[str, Any]: + return self._request_json( + guest_cid, + port, + { + "action": "start_service", + "service_name": service_name, + "command": command, + "cwd": cwd, + "readiness": readiness, + "ready_timeout_seconds": ready_timeout_seconds, + "ready_interval_ms": ready_interval_ms, + }, + timeout_seconds=timeout_seconds, + uds_path=uds_path, + error_message="guest service start response must be a JSON object", + ) + + def status_service( + self, + guest_cid: int, + port: int, + *, + service_name: str, + timeout_seconds: int = 30, + uds_path: str | None = None, + ) -> dict[str, Any]: + return self._request_json( + guest_cid, + port, + { + "action": "status_service", + "service_name": service_name, + }, + timeout_seconds=timeout_seconds, + uds_path=uds_path, + error_message="guest service status response must be a JSON object", + ) + + def logs_service( + self, + guest_cid: int, + port: int, + *, + service_name: str, + tail_lines: int | None, + timeout_seconds: int = 30, + uds_path: str | None = None, + ) -> dict[str, Any]: + return self._request_json( + guest_cid, + port, + { + "action": "logs_service", + "service_name": service_name, + "tail_lines": tail_lines, + }, + timeout_seconds=timeout_seconds, + uds_path=uds_path, + error_message="guest service logs response must be a JSON object", + ) + + def stop_service( + self, + guest_cid: int, + port: int, + *, + service_name: str, + timeout_seconds: int = 30, + uds_path: str | None = None, + ) -> dict[str, Any]: + return self._request_json( + guest_cid, + port, + { + "action": "stop_service", + "service_name": service_name, + }, + timeout_seconds=timeout_seconds, + uds_path=uds_path, + error_message="guest service stop response must be a JSON object", + ) + def _request_json( self, guest_cid: int, diff --git a/src/pyro_mcp/vm_manager.py b/src/pyro_mcp/vm_manager.py index 225ab2b..713769f 100644 --- a/src/pyro_mcp/vm_manager.py +++ b/src/pyro_mcp/vm_manager.py @@ -5,14 +5,18 @@ from __future__ import annotations import difflib import json import os +import re import shlex import shutil import signal +import socket import subprocess import tarfile import tempfile import threading import time +import urllib.error +import urllib.request import uuid from dataclasses import dataclass, field from pathlib import Path, PurePosixPath @@ -37,6 +41,7 @@ from pyro_mcp.workspace_shells import ( VmState = Literal["created", "started", "stopped"] WorkspaceShellState = Literal["running", "stopped"] +WorkspaceServiceState = Literal["running", "exited", "stopped", "failed"] DEFAULT_VCPU_COUNT = 1 DEFAULT_MEM_MIB = 1024 @@ -50,6 +55,7 @@ WORKSPACE_BASELINE_ARCHIVE_NAME = "workspace.tar" WORKSPACE_DIRNAME = "workspace" WORKSPACE_COMMANDS_DIRNAME = "commands" WORKSPACE_SHELLS_DIRNAME = "shells" +WORKSPACE_SERVICES_DIRNAME = "services" WORKSPACE_RUNTIME_DIRNAME = "runtime" WORKSPACE_GUEST_PATH = "/workspace" WORKSPACE_GUEST_AGENT_PATH = "/opt/pyro/bin/pyro_guest_agent.py" @@ -57,10 +63,15 @@ WORKSPACE_ARCHIVE_UPLOAD_TIMEOUT_SECONDS = 60 DEFAULT_SHELL_COLS = 120 DEFAULT_SHELL_ROWS = 30 DEFAULT_SHELL_MAX_CHARS = 65536 +DEFAULT_SERVICE_READY_TIMEOUT_SECONDS = 30 +DEFAULT_SERVICE_READY_INTERVAL_MS = 500 +DEFAULT_SERVICE_LOG_TAIL_LINES = 200 WORKSPACE_SHELL_SIGNAL_NAMES = shell_signal_names() +WORKSPACE_SERVICE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") WorkspaceSeedMode = Literal["empty", "directory", "tar_archive"] WorkspaceArtifactType = Literal["file", "directory", "symlink"] +WorkspaceServiceReadinessType = Literal["file", "tcp", "http", "command"] @dataclass @@ -251,6 +262,75 @@ class WorkspaceShellRecord: metadata=_string_dict(payload.get("metadata")), ) + +@dataclass +class WorkspaceServiceRecord: + """Persistent service metadata stored on disk per workspace.""" + + workspace_id: str + service_name: str + command: str + cwd: str + state: WorkspaceServiceState + started_at: float + readiness: dict[str, Any] | None = None + ready_at: float | None = None + ended_at: float | None = None + exit_code: int | None = None + pid: int | None = None + execution_mode: str = "pending" + stop_reason: str | None = None + 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, + "service_name": self.service_name, + "command": self.command, + "cwd": self.cwd, + "state": self.state, + "started_at": self.started_at, + "readiness": self.readiness, + "ready_at": self.ready_at, + "ended_at": self.ended_at, + "exit_code": self.exit_code, + "pid": self.pid, + "execution_mode": self.execution_mode, + "stop_reason": self.stop_reason, + "metadata": dict(self.metadata), + } + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> WorkspaceServiceRecord: + readiness_payload = payload.get("readiness") + readiness = None + if isinstance(readiness_payload, dict): + readiness = dict(readiness_payload) + return cls( + workspace_id=str(payload["workspace_id"]), + service_name=str(payload["service_name"]), + command=str(payload.get("command", "")), + cwd=str(payload.get("cwd", WORKSPACE_GUEST_PATH)), + state=cast(WorkspaceServiceState, str(payload.get("state", "stopped"))), + started_at=float(payload.get("started_at", 0.0)), + readiness=readiness, + ready_at=( + None if payload.get("ready_at") is None else float(payload.get("ready_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)) + ), + pid=None if payload.get("pid") is None else int(payload.get("pid", 0)), + execution_mode=str(payload.get("execution_mode", "pending")), + stop_reason=_optional_str(payload.get("stop_reason")), + metadata=_string_dict(payload.get("metadata")), + ) + + @dataclass(frozen=True) class PreparedWorkspaceSeed: """Prepared host-side seed archive plus metadata.""" @@ -772,6 +852,299 @@ def _extract_workspace_export_archive( } +def _normalize_workspace_service_name(service_name: str) -> str: + normalized = service_name.strip() + if normalized == "": + raise ValueError("service_name must not be empty") + if WORKSPACE_SERVICE_NAME_RE.fullmatch(normalized) is None: + raise ValueError( + "service_name must match " + r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" + ) + return normalized + + +def _normalize_workspace_service_readiness( + readiness: dict[str, Any] | None, +) -> dict[str, Any] | None: + if readiness is None: + return None + readiness_type = str(readiness.get("type", "")).strip().lower() + if readiness_type not in {"file", "tcp", "http", "command"}: + raise ValueError("readiness.type must be one of: file, tcp, http, command") + if readiness_type == "file": + path = str(readiness.get("path", "")).strip() + if path == "": + raise ValueError("readiness.path is required for file readiness") + normalized_path, _ = _normalize_workspace_destination(path) + return {"type": "file", "path": normalized_path} + if readiness_type == "tcp": + address = str(readiness.get("address", "")).strip() + if ":" not in address: + raise ValueError("readiness.address must be in HOST:PORT format") + host, raw_port = address.rsplit(":", 1) + host = host.strip() + if host == "": + raise ValueError("readiness.address host must not be empty") + try: + port = int(raw_port) + except ValueError as exc: + raise ValueError("readiness.address port must be an integer") from exc + if port <= 0 or port > 65535: + raise ValueError("readiness.address port must be between 1 and 65535") + return {"type": "tcp", "address": f"{host}:{port}"} + if readiness_type == "http": + url = str(readiness.get("url", "")).strip() + if url == "": + raise ValueError("readiness.url is required for http readiness") + return {"type": "http", "url": url} + command = str(readiness.get("command", "")).strip() + if command == "": + raise ValueError("readiness.command is required for command readiness") + return {"type": "command", "command": command} + + +def _workspace_service_status_path(services_dir: Path, service_name: str) -> Path: + return services_dir / f"{service_name}.status" + + +def _workspace_service_stdout_path(services_dir: Path, service_name: str) -> Path: + return services_dir / f"{service_name}.stdout" + + +def _workspace_service_stderr_path(services_dir: Path, service_name: str) -> Path: + return services_dir / f"{service_name}.stderr" + + +def _workspace_service_runner_path(services_dir: Path, service_name: str) -> Path: + return services_dir / f"{service_name}.runner.sh" + + +def _read_service_exit_code(status_path: Path) -> int | None: + if not status_path.exists(): + return None + raw_value = status_path.read_text(encoding="utf-8", errors="ignore").strip() + if raw_value == "": + return None + return int(raw_value) + + +def _tail_text(path: Path, *, tail_lines: int | None) -> tuple[str, bool]: + if not path.exists(): + return "", False + text = path.read_text(encoding="utf-8", errors="replace") + if tail_lines is None: + return text, False + lines = text.splitlines(keepends=True) + if len(lines) <= tail_lines: + return text, False + return "".join(lines[-tail_lines:]), True + + +def _stop_process_group(pid: int, *, wait_seconds: int = 5) -> tuple[bool, bool]: + try: + os.killpg(pid, signal.SIGTERM) + except ProcessLookupError: + return False, False + deadline = time.monotonic() + wait_seconds + while time.monotonic() < deadline: + if not _pid_is_running(pid): + return True, False + time.sleep(0.1) + try: + os.killpg(pid, signal.SIGKILL) + except ProcessLookupError: + return True, False + deadline = time.monotonic() + wait_seconds + while time.monotonic() < deadline: + if not _pid_is_running(pid): + return True, True + time.sleep(0.1) + return True, True + + +def _run_service_probe_command(cwd: Path, command: str) -> int: + proc = subprocess.run( # noqa: S603 + ["bash", "-lc", command], # noqa: S607 + cwd=cwd, + env={"PATH": os.environ.get("PATH", ""), "HOME": str(cwd)}, + text=True, + capture_output=True, + timeout=10, + check=False, + ) + return proc.returncode + + +def _service_ready_on_host( + *, + readiness: dict[str, Any] | None, + workspace_dir: Path, + cwd: Path, +) -> bool: + if readiness is None: + return True + readiness_type = str(readiness["type"]) + if readiness_type == "file": + ready_path = _workspace_host_destination(workspace_dir, str(readiness["path"])) + return ready_path.exists() + if readiness_type == "tcp": + host, raw_port = str(readiness["address"]).rsplit(":", 1) + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(1) + try: + sock.connect((host, int(raw_port))) + except OSError: + return False + return True + if readiness_type == "http": + request = urllib.request.Request(str(readiness["url"]), method="GET") + try: + with urllib.request.urlopen(request, timeout=2) as response: # noqa: S310 + return 200 <= int(response.status) < 400 + except (urllib.error.URLError, TimeoutError, ValueError): + return False + if readiness_type == "command": + try: + return _run_service_probe_command(cwd, str(readiness["command"])) == 0 + except (OSError, subprocess.TimeoutExpired): + return False + raise RuntimeError(f"unsupported readiness type: {readiness_type}") + + +def _refresh_local_service_record( + service: WorkspaceServiceRecord, + *, + services_dir: Path, +) -> WorkspaceServiceRecord: + if service.state != "running" or service.pid is None: + return service + if _pid_is_running(service.pid): + return service + refreshed = WorkspaceServiceRecord( + workspace_id=service.workspace_id, + service_name=service.service_name, + command=service.command, + cwd=service.cwd, + state="exited", + started_at=service.started_at, + readiness=dict(service.readiness) if service.readiness is not None else None, + ready_at=service.ready_at, + ended_at=service.ended_at or time.time(), + exit_code=_read_service_exit_code( + _workspace_service_status_path(services_dir, service.service_name) + ), + pid=service.pid, + execution_mode=service.execution_mode, + stop_reason=service.stop_reason, + metadata=dict(service.metadata), + ) + return refreshed + + +def _start_local_service( + *, + services_dir: Path, + workspace_dir: Path, + workspace_id: str, + service_name: str, + command: str, + cwd_text: str, + readiness: dict[str, Any] | None, + ready_timeout_seconds: int, + ready_interval_ms: int, +) -> WorkspaceServiceRecord: + services_dir.mkdir(parents=True, exist_ok=True) + cwd = _workspace_host_destination(workspace_dir, cwd_text) + cwd.mkdir(parents=True, exist_ok=True) + stdout_path = _workspace_service_stdout_path(services_dir, service_name) + stderr_path = _workspace_service_stderr_path(services_dir, service_name) + status_path = _workspace_service_status_path(services_dir, service_name) + runner_path = _workspace_service_runner_path(services_dir, service_name) + stdout_path.write_text("", encoding="utf-8") + stderr_path.write_text("", encoding="utf-8") + status_path.unlink(missing_ok=True) + runner_path.write_text( + "\n".join( + [ + "#!/bin/sh", + "set +e", + f"cd {shlex.quote(str(cwd))}", + ( + f"/bin/sh -lc {shlex.quote(command)}" + f" >> {shlex.quote(str(stdout_path))}" + f" 2>> {shlex.quote(str(stderr_path))}" + ), + "status=$?", + f"printf '%s' \"$status\" > {shlex.quote(str(status_path))}", + "exit \"$status\"", + ] + ) + + "\n", + encoding="utf-8", + ) + runner_path.chmod(0o700) + process = subprocess.Popen( # noqa: S603 + [str(runner_path)], + cwd=str(cwd), + text=True, + start_new_session=True, + ) + started_at = time.time() + service = WorkspaceServiceRecord( + workspace_id=workspace_id, + service_name=service_name, + command=command, + cwd=cwd_text, + state="running", + started_at=started_at, + readiness=dict(readiness) if readiness is not None else None, + ready_at=None, + ended_at=None, + exit_code=None, + pid=process.pid, + execution_mode="host_compat", + stop_reason=None, + ) + deadline = time.monotonic() + ready_timeout_seconds + while True: + service = _refresh_local_service_record(service, services_dir=services_dir) + if service.state != "running": + service.state = "failed" + service.stop_reason = "process_exited_before_ready" + if service.ended_at is None: + service.ended_at = time.time() + return service + if _service_ready_on_host(readiness=readiness, workspace_dir=workspace_dir, cwd=cwd): + service.ready_at = time.time() + return service + if time.monotonic() >= deadline: + _stop_process_group(process.pid) + service = _refresh_local_service_record(service, services_dir=services_dir) + service.state = "failed" + service.stop_reason = "readiness_timeout" + if service.ended_at is None: + service.ended_at = time.time() + return service + time.sleep(max(ready_interval_ms, 1) / 1000) + + +def _stop_local_service( + service: WorkspaceServiceRecord, + *, + services_dir: Path, +) -> WorkspaceServiceRecord: + if service.pid is None: + return service + stopped, killed = _stop_process_group(service.pid) + refreshed = _refresh_local_service_record(service, services_dir=services_dir) + if stopped: + refreshed.state = "stopped" + refreshed.stop_reason = "sigkill" if killed else "sigterm" + refreshed.ended_at = refreshed.ended_at or time.time() + return refreshed + + def _instance_workspace_host_dir(instance: VmInstance) -> Path: raw_value = instance.metadata.get("workspace_host_dir") if raw_value is None or raw_value == "": @@ -1131,6 +1504,48 @@ class VmBackend: ) -> dict[str, Any]: raise NotImplementedError + def start_service( # pragma: no cover + self, + instance: VmInstance, + *, + workspace_id: str, + service_name: str, + command: str, + cwd: str, + readiness: dict[str, Any] | None, + ready_timeout_seconds: int, + ready_interval_ms: int, + ) -> dict[str, Any]: + raise NotImplementedError + + def status_service( # pragma: no cover + self, + instance: VmInstance, + *, + workspace_id: str, + service_name: str, + ) -> dict[str, Any]: + raise NotImplementedError + + def logs_service( # pragma: no cover + self, + instance: VmInstance, + *, + workspace_id: str, + service_name: str, + tail_lines: int | None, + ) -> dict[str, Any]: + raise NotImplementedError + + def stop_service( # pragma: no cover + self, + instance: VmInstance, + *, + workspace_id: str, + service_name: str, + ) -> dict[str, Any]: + raise NotImplementedError + class MockBackend(VmBackend): """Host-process backend used for development and testability.""" @@ -1273,6 +1688,108 @@ class MockBackend(VmBackend): payload["execution_mode"] = "host_compat" return payload + def start_service( + self, + instance: VmInstance, + *, + workspace_id: str, + service_name: str, + command: str, + cwd: str, + readiness: dict[str, Any] | None, + ready_timeout_seconds: int, + ready_interval_ms: int, + ) -> dict[str, Any]: + services_dir = instance.workdir.parent / WORKSPACE_SERVICES_DIRNAME + service = _start_local_service( + services_dir=services_dir, + workspace_dir=_instance_workspace_host_dir(instance), + workspace_id=workspace_id, + service_name=service_name, + command=command, + cwd_text=cwd, + readiness=readiness, + ready_timeout_seconds=ready_timeout_seconds, + ready_interval_ms=ready_interval_ms, + ) + return service.to_payload() + + def status_service( + self, + instance: VmInstance, + *, + workspace_id: str, + service_name: str, + ) -> dict[str, Any]: + services_dir = instance.workdir.parent / WORKSPACE_SERVICES_DIRNAME + service = self._load_workspace_service(services_dir, workspace_id, service_name) + refreshed = _refresh_local_service_record( + service, + services_dir=services_dir, + ) + return refreshed.to_payload() + + def logs_service( + self, + instance: VmInstance, + *, + workspace_id: str, + service_name: str, + tail_lines: int | None, + ) -> dict[str, Any]: + services_dir = instance.workdir.parent / WORKSPACE_SERVICES_DIRNAME + service = self._load_workspace_service(services_dir, workspace_id, service_name) + refreshed = _refresh_local_service_record(service, services_dir=services_dir) + payload = refreshed.to_payload() + stdout, stdout_truncated = _tail_text( + _workspace_service_stdout_path(services_dir, service_name), + tail_lines=tail_lines, + ) + stderr, stderr_truncated = _tail_text( + _workspace_service_stderr_path(services_dir, service_name), + tail_lines=tail_lines, + ) + payload.update( + { + "stdout": stdout, + "stderr": stderr, + "tail_lines": tail_lines, + "truncated": stdout_truncated or stderr_truncated, + } + ) + return payload + + def stop_service( + self, + instance: VmInstance, + *, + workspace_id: str, + service_name: str, + ) -> dict[str, Any]: + services_dir = instance.workdir.parent / WORKSPACE_SERVICES_DIRNAME + service = self._load_workspace_service(services_dir, workspace_id, service_name) + stopped = _stop_local_service( + service, + services_dir=services_dir, + ) + return stopped.to_payload() + + def _load_workspace_service( + self, + services_dir: Path, + workspace_id: str, + service_name: str, + ) -> WorkspaceServiceRecord: + record_path = services_dir / f"{service_name}.json" + if not record_path.exists(): + raise ValueError( + f"service {service_name!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"service record at {record_path} is invalid") + return WorkspaceServiceRecord.from_payload(payload) + class FirecrackerBackend(VmBackend): # pragma: no cover """Host-gated backend that validates Firecracker prerequisites.""" @@ -1697,6 +2214,173 @@ class FirecrackerBackend(VmBackend): # pragma: no cover payload["execution_mode"] = instance.metadata.get("execution_mode", "pending") return payload + def start_service( + self, + instance: VmInstance, + *, + workspace_id: str, + service_name: str, + command: str, + cwd: str, + readiness: dict[str, Any] | None, + ready_timeout_seconds: int, + ready_interval_ms: int, + ) -> dict[str, Any]: + if self._runtime_capabilities.supports_guest_exec: + 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.start_service( + guest_cid, + port, + service_name=service_name, + command=command, + cwd=cwd, + readiness=readiness, + ready_timeout_seconds=ready_timeout_seconds, + ready_interval_ms=ready_interval_ms, + uds_path=uds_path, + ) + payload["execution_mode"] = instance.metadata.get("execution_mode", "pending") + return payload + if not instance.allow_host_compat: + raise RuntimeError("services require guest execution or explicit host compatibility") + service = _start_local_service( + services_dir=instance.workdir.parent / WORKSPACE_SERVICES_DIRNAME, + workspace_dir=_instance_workspace_host_dir(instance), + workspace_id=workspace_id, + service_name=service_name, + command=command, + cwd_text=cwd, + readiness=readiness, + ready_timeout_seconds=ready_timeout_seconds, + ready_interval_ms=ready_interval_ms, + ) + return service.to_payload() + + def status_service( + self, + instance: VmInstance, + *, + workspace_id: str, + service_name: str, + ) -> dict[str, Any]: + if self._runtime_capabilities.supports_guest_exec: + 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.status_service( + guest_cid, + port, + service_name=service_name, + uds_path=uds_path, + ) + payload["execution_mode"] = instance.metadata.get("execution_mode", "pending") + return payload + if not instance.allow_host_compat: + raise RuntimeError("services require guest execution or explicit host compatibility") + services_dir = instance.workdir.parent / WORKSPACE_SERVICES_DIRNAME + record_path = services_dir / f"{service_name}.json" + if not record_path.exists(): + raise ValueError( + f"service {service_name!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"service record at {record_path} is invalid") + service = WorkspaceServiceRecord.from_payload(payload) + refreshed = _refresh_local_service_record(service, services_dir=services_dir) + return refreshed.to_payload() + + def logs_service( + self, + instance: VmInstance, + *, + workspace_id: str, + service_name: str, + tail_lines: int | None, + ) -> dict[str, Any]: + if self._runtime_capabilities.supports_guest_exec: + 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.logs_service( + guest_cid, + port, + service_name=service_name, + tail_lines=tail_lines, + uds_path=uds_path, + ) + payload["execution_mode"] = instance.metadata.get("execution_mode", "pending") + return payload + if not instance.allow_host_compat: + raise RuntimeError("services require guest execution or explicit host compatibility") + services_dir = instance.workdir.parent / WORKSPACE_SERVICES_DIRNAME + record_path = services_dir / f"{service_name}.json" + if not record_path.exists(): + raise ValueError( + f"service {service_name!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"service record at {record_path} is invalid") + service = _refresh_local_service_record( + WorkspaceServiceRecord.from_payload(payload), + services_dir=services_dir, + ) + response = service.to_payload() + stdout, stdout_truncated = _tail_text( + _workspace_service_stdout_path(services_dir, service_name), + tail_lines=tail_lines, + ) + stderr, stderr_truncated = _tail_text( + _workspace_service_stderr_path(services_dir, service_name), + tail_lines=tail_lines, + ) + response.update( + { + "stdout": stdout, + "stderr": stderr, + "tail_lines": tail_lines, + "truncated": stdout_truncated or stderr_truncated, + } + ) + return response + + def stop_service( + self, + instance: VmInstance, + *, + workspace_id: str, + service_name: str, + ) -> dict[str, Any]: + if self._runtime_capabilities.supports_guest_exec: + 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.stop_service( + guest_cid, + port, + service_name=service_name, + uds_path=uds_path, + ) + payload["execution_mode"] = instance.metadata.get("execution_mode", "pending") + return payload + if not instance.allow_host_compat: + raise RuntimeError("services require guest execution or explicit host compatibility") + services_dir = instance.workdir.parent / WORKSPACE_SERVICES_DIRNAME + record_path = services_dir / f"{service_name}.json" + if not record_path.exists(): + raise ValueError( + f"service {service_name!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"service record at {record_path} is invalid") + service = WorkspaceServiceRecord.from_payload(payload) + stopped = _stop_local_service(service, services_dir=services_dir) + return stopped.to_payload() + class VmManager: """In-process lifecycle manager for ephemeral VM environments and workspaces.""" @@ -1961,11 +2645,13 @@ class VmManager: 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) + services_dir = self._workspace_services_dir(workspace_id) baseline_archive_path = self._workspace_baseline_archive_path(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) + services_dir.mkdir(parents=True, exist_ok=True) _persist_workspace_baseline( prepared_seed, baseline_archive_path=baseline_archive_path, @@ -2430,11 +3116,174 @@ class VmManager: response["closed"] = bool(payload.get("closed", True)) return response + def start_service( + self, + workspace_id: str, + service_name: str, + *, + command: str, + cwd: str = WORKSPACE_GUEST_PATH, + readiness: dict[str, Any] | None = None, + ready_timeout_seconds: int = DEFAULT_SERVICE_READY_TIMEOUT_SECONDS, + ready_interval_ms: int = DEFAULT_SERVICE_READY_INTERVAL_MS, + ) -> dict[str, Any]: + normalized_service_name = _normalize_workspace_service_name(service_name) + normalized_cwd, _ = _normalize_workspace_destination(cwd) + normalized_readiness = _normalize_workspace_service_readiness(readiness) + if ready_timeout_seconds <= 0: + raise ValueError("ready_timeout_seconds must be positive") + if ready_interval_ms <= 0: + raise ValueError("ready_interval_ms must be positive") + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + instance = self._workspace_instance_for_live_service_locked(workspace) + existing = self._load_workspace_service_locked_optional( + workspace_id, + normalized_service_name, + ) + if existing is not None: + existing = self._refresh_workspace_service_locked( + workspace, + instance, + existing, + ) + if existing.state == "running": + raise RuntimeError( + f"service {normalized_service_name!r} is already running in " + f"workspace {workspace_id!r}" + ) + self._delete_workspace_service_artifacts_locked( + workspace_id, + normalized_service_name, + ) + payload = self._backend.start_service( + instance, + workspace_id=workspace_id, + service_name=normalized_service_name, + command=command, + cwd=normalized_cwd, + readiness=normalized_readiness, + ready_timeout_seconds=ready_timeout_seconds, + ready_interval_ms=ready_interval_ms, + ) + service = self._workspace_service_record_from_payload( + workspace_id=workspace_id, + service_name=normalized_service_name, + 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_service_locked(service) + return self._serialize_workspace_service(service) + + def list_services(self, workspace_id: str) -> dict[str, Any]: + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + instance = self._workspace_instance_for_live_service_locked(workspace) + services = self._refresh_workspace_services_locked(workspace, instance) + self._save_workspace_locked(workspace) + serialized = [self._serialize_workspace_service(service) for service in services] + return { + "workspace_id": workspace_id, + "count": len(serialized), + "running_count": sum(1 for item in serialized if item["state"] == "running"), + "services": serialized, + } + + def status_service(self, workspace_id: str, service_name: str) -> dict[str, Any]: + normalized_service_name = _normalize_workspace_service_name(service_name) + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + instance = self._workspace_instance_for_live_service_locked(workspace) + service = self._load_workspace_service_locked(workspace_id, normalized_service_name) + service = self._refresh_workspace_service_locked(workspace, instance, service) + self._save_workspace_locked(workspace) + self._save_workspace_service_locked(service) + return self._serialize_workspace_service(service) + + def logs_service( + self, + workspace_id: str, + service_name: str, + *, + tail_lines: int | None = DEFAULT_SERVICE_LOG_TAIL_LINES, + ) -> dict[str, Any]: + normalized_service_name = _normalize_workspace_service_name(service_name) + if tail_lines is not None and tail_lines <= 0: + raise ValueError("tail_lines must be positive") + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + instance = self._workspace_instance_for_live_service_locked(workspace) + service = self._load_workspace_service_locked(workspace_id, normalized_service_name) + payload = self._backend.logs_service( + instance, + workspace_id=workspace_id, + service_name=normalized_service_name, + tail_lines=tail_lines, + ) + service = self._workspace_service_record_from_payload( + workspace_id=workspace_id, + service_name=normalized_service_name, + payload=payload, + metadata=service.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_service_locked(service) + response = self._serialize_workspace_service(service) + response.update( + { + "stdout": str(payload.get("stdout", "")), + "stderr": str(payload.get("stderr", "")), + "tail_lines": tail_lines, + "truncated": bool(payload.get("truncated", False)), + } + ) + return response + + def stop_service(self, workspace_id: str, service_name: str) -> dict[str, Any]: + normalized_service_name = _normalize_workspace_service_name(service_name) + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + instance = self._workspace_instance_for_live_service_locked(workspace) + service = self._load_workspace_service_locked(workspace_id, normalized_service_name) + payload = self._backend.stop_service( + instance, + workspace_id=workspace_id, + service_name=normalized_service_name, + ) + service = self._workspace_service_record_from_payload( + workspace_id=workspace_id, + service_name=normalized_service_name, + payload=payload, + metadata=service.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_service_locked(service) + return self._serialize_workspace_service(service) + def status_workspace(self, workspace_id: str) -> dict[str, Any]: with self._lock: workspace = self._load_workspace_locked(workspace_id) self._ensure_workspace_not_expired_locked(workspace, time.time()) self._refresh_workspace_liveness_locked(workspace) + self._refresh_workspace_service_counts_locked(workspace) self._save_workspace_locked(workspace) return self._serialize_workspace(workspace) @@ -2462,6 +3311,7 @@ class VmManager: instance = workspace.to_instance( workdir=self._workspace_runtime_dir(workspace.workspace_id) ) + self._stop_workspace_services_locked(workspace, instance) self._close_workspace_shells_locked(workspace, instance) if workspace.state == "started": self._backend.stop(instance) @@ -2500,6 +3350,9 @@ class VmManager: } def _serialize_workspace(self, workspace: WorkspaceRecord) -> dict[str, Any]: + service_count, running_service_count = self._workspace_service_counts_locked( + workspace.workspace_id + ) return { "workspace_id": workspace.workspace_id, "environment": workspace.environment, @@ -2519,6 +3372,8 @@ class VmManager: "workspace_seed": _workspace_seed_dict(workspace.workspace_seed), "command_count": workspace.command_count, "last_command": workspace.last_command, + "service_count": service_count, + "running_service_count": running_service_count, "metadata": workspace.metadata, } @@ -2536,6 +3391,23 @@ class VmManager: "execution_mode": shell.execution_mode, } + def _serialize_workspace_service(self, service: WorkspaceServiceRecord) -> dict[str, Any]: + return { + "workspace_id": service.workspace_id, + "service_name": service.service_name, + "state": service.state, + "command": service.command, + "cwd": service.cwd, + "started_at": service.started_at, + "ended_at": service.ended_at, + "exit_code": service.exit_code, + "pid": service.pid, + "execution_mode": service.execution_mode, + "readiness": dict(service.readiness) if service.readiness is not None else None, + "ready_at": service.ready_at, + "stop_reason": service.stop_reason, + } + def _require_guest_boot_or_opt_in(self, instance: VmInstance) -> None: if self._runtime_capabilities.supports_vm_boot or instance.allow_host_compat: return @@ -2571,6 +3443,20 @@ class VmManager: f"workspace: {reason}" ) + def _require_workspace_service_support(self, instance: VmInstance) -> None: + if self._backend_name == "mock": + return + if self._runtime_capabilities.supports_guest_exec or instance.allow_host_compat: + return + reason = self._runtime_capabilities.reason or ( + "runtime does not support guest-backed or host-compatible service execution" + ) + raise RuntimeError( + "workspace services are unavailable for this workspace: " + f"{reason}. Recreate the workspace with --allow-host-compat to opt into " + "host compatibility when guest execution is unavailable." + ) + def _get_instance_locked(self, vm_id: str) -> VmInstance: try: return self._instances[vm_id] @@ -2709,12 +3595,18 @@ class VmManager: def _workspace_shells_dir(self, workspace_id: str) -> Path: return self._workspace_dir(workspace_id) / WORKSPACE_SHELLS_DIRNAME + def _workspace_services_dir(self, workspace_id: str) -> Path: + return self._workspace_dir(workspace_id) / WORKSPACE_SERVICES_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 _workspace_service_record_path(self, workspace_id: str, service_name: str) -> Path: + return self._workspace_services_dir(workspace_id) / f"{service_name}.json" + def _count_workspaces_locked(self) -> int: return sum(1 for _ in self._workspaces_dir.glob("*/workspace.json")) @@ -2852,6 +3744,14 @@ class VmManager: self._require_workspace_shell_support(instance) return instance + def _workspace_instance_for_live_service_locked(self, workspace: WorkspaceRecord) -> VmInstance: + instance = self._workspace_instance_for_live_operation_locked( + workspace, + operation_name="service operations", + ) + self._require_workspace_service_support(instance) + return instance + def _workspace_instance_for_live_operation_locked( self, workspace: WorkspaceRecord, @@ -2896,6 +3796,39 @@ class VmManager: metadata=dict(metadata or {}), ) + def _workspace_service_record_from_payload( + self, + *, + workspace_id: str, + service_name: str, + payload: dict[str, Any], + metadata: dict[str, str] | None = None, + ) -> WorkspaceServiceRecord: + readiness_payload = payload.get("readiness") + readiness = dict(readiness_payload) if isinstance(readiness_payload, dict) else None + return WorkspaceServiceRecord( + workspace_id=workspace_id, + service_name=str(payload.get("service_name", service_name)), + command=str(payload.get("command", "")), + cwd=str(payload.get("cwd", WORKSPACE_GUEST_PATH)), + state=cast(WorkspaceServiceState, str(payload.get("state", "stopped"))), + started_at=float(payload.get("started_at", time.time())), + readiness=readiness, + ready_at=( + None if payload.get("ready_at") is None else float(payload.get("ready_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)) + ), + pid=None if payload.get("pid") is None else int(payload.get("pid", 0)), + execution_mode=str(payload.get("execution_mode", "pending")), + stop_reason=_optional_str(payload.get("stop_reason")), + metadata=dict(metadata or {}), + ) + def _load_workspace_shell_locked( self, workspace_id: str, @@ -2909,6 +3842,73 @@ class VmManager: raise RuntimeError(f"shell record at {record_path} is invalid") return WorkspaceShellRecord.from_payload(payload) + def _workspace_service_counts_locked(self, workspace_id: str) -> tuple[int, int]: + services = self._list_workspace_services_locked(workspace_id) + return len(services), sum(1 for service in services if service.state == "running") + + def _load_workspace_service_locked( + self, + workspace_id: str, + service_name: str, + ) -> WorkspaceServiceRecord: + record_path = self._workspace_service_record_path(workspace_id, service_name) + if not record_path.exists(): + raise ValueError( + f"service {service_name!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"service record at {record_path} is invalid") + return WorkspaceServiceRecord.from_payload(payload) + + def _load_workspace_service_locked_optional( + self, + workspace_id: str, + service_name: str, + ) -> WorkspaceServiceRecord | None: + record_path = self._workspace_service_record_path(workspace_id, service_name) + if not record_path.exists(): + return None + payload = json.loads(record_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise RuntimeError(f"service record at {record_path} is invalid") + return WorkspaceServiceRecord.from_payload(payload) + + def _save_workspace_service_locked(self, service: WorkspaceServiceRecord) -> None: + record_path = self._workspace_service_record_path( + service.workspace_id, + service.service_name, + ) + record_path.parent.mkdir(parents=True, exist_ok=True) + record_path.write_text( + json.dumps(service.to_payload(), indent=2, sort_keys=True), + encoding="utf-8", + ) + + def _delete_workspace_service_artifacts_locked( + self, + workspace_id: str, + service_name: str, + ) -> None: + self._workspace_service_record_path(workspace_id, service_name).unlink(missing_ok=True) + services_dir = self._workspace_services_dir(workspace_id) + _workspace_service_stdout_path(services_dir, service_name).unlink(missing_ok=True) + _workspace_service_stderr_path(services_dir, service_name).unlink(missing_ok=True) + _workspace_service_status_path(services_dir, service_name).unlink(missing_ok=True) + _workspace_service_runner_path(services_dir, service_name).unlink(missing_ok=True) + + def _list_workspace_services_locked(self, workspace_id: str) -> list[WorkspaceServiceRecord]: + services_dir = self._workspace_services_dir(workspace_id) + if not services_dir.exists(): + return [] + services: list[WorkspaceServiceRecord] = [] + for record_path in sorted(services_dir.glob("*.json")): + payload = json.loads(record_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + continue + services.append(WorkspaceServiceRecord.from_payload(payload)) + return services + 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) @@ -2949,3 +3949,83 @@ class VmManager: except Exception: pass self._delete_workspace_shell_locked(workspace.workspace_id, shell.shell_id) + + def _refresh_workspace_service_locked( + self, + workspace: WorkspaceRecord, + instance: VmInstance, + service: WorkspaceServiceRecord, + ) -> WorkspaceServiceRecord: + payload = self._backend.status_service( + instance, + workspace_id=workspace.workspace_id, + service_name=service.service_name, + ) + refreshed = self._workspace_service_record_from_payload( + workspace_id=workspace.workspace_id, + service_name=service.service_name, + payload=payload, + metadata=service.metadata, + ) + self._save_workspace_service_locked(refreshed) + return refreshed + + def _refresh_workspace_services_locked( + self, + workspace: WorkspaceRecord, + instance: VmInstance, + ) -> list[WorkspaceServiceRecord]: + services = self._list_workspace_services_locked(workspace.workspace_id) + refreshed: list[WorkspaceServiceRecord] = [] + for service in services: + refreshed.append(self._refresh_workspace_service_locked(workspace, instance, service)) + return refreshed + + def _refresh_workspace_service_counts_locked(self, workspace: WorkspaceRecord) -> None: + services = self._list_workspace_services_locked(workspace.workspace_id) + if not services: + return + if workspace.state != "started": + changed = False + for service in services: + if service.state == "running": + service.state = "stopped" + service.stop_reason = "workspace_stopped" + service.ended_at = service.ended_at or time.time() + self._save_workspace_service_locked(service) + changed = True + if changed: + return + return + instance = workspace.to_instance( + workdir=self._workspace_runtime_dir(workspace.workspace_id) + ) + self._require_workspace_service_support(instance) + self._refresh_workspace_services_locked(workspace, instance) + + def _stop_workspace_services_locked( + self, + workspace: WorkspaceRecord, + instance: VmInstance, + ) -> None: + for service in self._list_workspace_services_locked(workspace.workspace_id): + if workspace.state == "started": + try: + payload = self._backend.stop_service( + instance, + workspace_id=workspace.workspace_id, + service_name=service.service_name, + ) + stopped = self._workspace_service_record_from_payload( + workspace_id=workspace.workspace_id, + service_name=service.service_name, + payload=payload, + metadata=service.metadata, + ) + self._save_workspace_service_locked(stopped) + except Exception: + pass + self._delete_workspace_service_artifacts_locked( + workspace.workspace_id, + service.service_name, + ) diff --git a/src/pyro_mcp/workspace_shells.py b/src/pyro_mcp/workspace_shells.py index 0a30d06..d082777 100644 --- a/src/pyro_mcp/workspace_shells.py +++ b/src/pyro_mcp/workspace_shells.py @@ -5,7 +5,6 @@ from __future__ import annotations import codecs import fcntl import os -import pty import shlex import signal import struct @@ -14,7 +13,7 @@ import termios import threading import time from pathlib import Path -from typing import Literal +from typing import IO, Literal ShellState = Literal["running", "stopped"] @@ -59,41 +58,60 @@ class LocalShellSession: self._lock = threading.RLock() self._output = "" self._master_fd: int | None = None + self._input_pipe: IO[bytes] | None = None + self._output_pipe: IO[bytes] | None = None self._reader: threading.Thread | None = None self._waiter: threading.Thread | None = None self._decoder = codecs.getincrementaldecoder("utf-8")("replace") + env = os.environ.copy() + env.update( + { + "TERM": env.get("TERM", "xterm-256color"), + "PS1": "pyro$ ", + "PROMPT_COMMAND": "", + } + ) - master_fd, slave_fd = pty.openpty() + process: subprocess.Popen[bytes] try: - _set_pty_size(slave_fd, rows, cols) - env = os.environ.copy() - env.update( - { - "TERM": env.get("TERM", "xterm-256color"), - "PS1": "pyro$ ", - "PROMPT_COMMAND": "", - } - ) + master_fd, slave_fd = os.openpty() + except OSError: process = subprocess.Popen( # noqa: S603 - ["/bin/bash", "--noprofile", "--norc", "-i"], - stdin=slave_fd, - stdout=slave_fd, - stderr=slave_fd, + ["/bin/bash", "--noprofile", "--norc"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, cwd=str(cwd), env=env, text=False, close_fds=True, preexec_fn=os.setsid, ) - except Exception: - os.close(master_fd) - raise - finally: - os.close(slave_fd) + self._input_pipe = process.stdin + self._output_pipe = process.stdout + else: + try: + _set_pty_size(slave_fd, rows, cols) + process = subprocess.Popen( # noqa: S603 + ["/bin/bash", "--noprofile", "--norc", "-i"], + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + cwd=str(cwd), + env=env, + text=False, + close_fds=True, + preexec_fn=os.setsid, + ) + except Exception: + os.close(master_fd) + raise + finally: + os.close(slave_fd) + self._master_fd = master_fd self._process = process self.pid = process.pid - self._master_fd = master_fd self._reader = threading.Thread(target=self._reader_loop, daemon=True) self._waiter = threading.Thread(target=self._waiter_loop, daemon=True) self._reader.start() @@ -136,11 +154,16 @@ class LocalShellSession: if self.state != "running": raise RuntimeError(f"shell {self.shell_id} is not running") master_fd = self._master_fd - if master_fd is None: - raise RuntimeError(f"shell {self.shell_id} transport is unavailable") + input_pipe = self._input_pipe payload = text + ("\n" if append_newline else "") try: - os.write(master_fd, payload.encode("utf-8")) + if master_fd is not None: + os.write(master_fd, payload.encode("utf-8")) + else: + if input_pipe is None: + raise RuntimeError(f"shell {self.shell_id} transport is unavailable") + input_pipe.write(payload.encode("utf-8")) + input_pipe.flush() except OSError as exc: self._refresh_process_state() raise RuntimeError(f"failed to write to shell {self.shell_id}: {exc}") from exc @@ -195,11 +218,17 @@ class LocalShellSession: def _reader_loop(self) -> None: master_fd = self._master_fd - if master_fd is None: + output_pipe = self._output_pipe + if master_fd is None and output_pipe is None: return while True: try: - chunk = os.read(master_fd, 65536) + if master_fd is not None: + chunk = os.read(master_fd, 65536) + else: + if output_pipe is None: + break + chunk = os.read(output_pipe.fileno(), 65536) except OSError: break if chunk == b"": @@ -234,6 +263,14 @@ class LocalShellSession: with self._lock: master_fd = self._master_fd self._master_fd = None + input_pipe = self._input_pipe + self._input_pipe = None + output_pipe = self._output_pipe + self._output_pipe = None + if input_pipe is not None: + input_pipe.close() + if output_pipe is not None: + output_pipe.close() if master_fd is None: return try: diff --git a/tests/test_api.py b/tests/test_api.py index 0a0e847..65762ec 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index a34babe..2fb8f35 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -114,6 +114,27 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None: assert "pyro workspace shell open WORKSPACE_ID" in workspace_shell_help assert "Use `workspace exec` for one-shot commands." in workspace_shell_help + workspace_service_help = _subparser_choice( + _subparser_choice(parser, "workspace"), + "service", + ).format_help() + assert "pyro workspace service start WORKSPACE_ID app" in workspace_service_help + assert "Use `--ready-file` by default" in workspace_service_help + + workspace_service_start_help = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "service"), "start" + ).format_help() + assert "--ready-file" in workspace_service_start_help + assert "--ready-tcp" in workspace_service_start_help + assert "--ready-http" in workspace_service_start_help + assert "--ready-command" in workspace_service_start_help + + workspace_service_logs_help = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "service"), "logs" + ).format_help() + assert "--tail-lines" in workspace_service_logs_help + assert "--all" in workspace_service_logs_help + workspace_shell_open_help = _subparser_choice( _subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "open" ).format_help() @@ -825,6 +846,303 @@ def test_cli_workspace_status_and_delete_print_json( assert deleted["deleted"] is True +def test_cli_workspace_status_prints_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def status_workspace(self, workspace_id: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + return { + "workspace_id": workspace_id, + "environment": "debian:12", + "state": "started", + "workspace_path": "/workspace", + "execution_mode": "guest_vsock", + "vcpu_count": 1, + "mem_mib": 1024, + "command_count": 0, + "last_command": None, + "service_count": 1, + "running_service_count": 1, + } + + class StatusParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="status", + workspace_id="workspace-123", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StatusParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = capsys.readouterr().out + assert "Workspace ID: workspace-123" in output + assert "Services: 1/1" in output + + +def test_cli_workspace_logs_prints_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def logs_workspace(self, workspace_id: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + return {"workspace_id": workspace_id, "count": 0, "entries": []} + + class LogsParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="logs", + workspace_id="workspace-123", + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: LogsParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + payload = json.loads(capsys.readouterr().out) + assert payload["count"] == 0 + + +def test_cli_workspace_delete_prints_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def delete_workspace(self, workspace_id: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + return {"workspace_id": workspace_id, "deleted": True} + + class DeleteParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="delete", + workspace_id="workspace-123", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: DeleteParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + assert "Deleted workspace: workspace-123" in capsys.readouterr().out + + +def test_cli_workspace_exec_prints_json_and_exits_nonzero( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def exec_workspace( + self, workspace_id: str, *, command: str, timeout_seconds: int + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert command == "false" + assert timeout_seconds == 30 + return { + "workspace_id": workspace_id, + "sequence": 1, + "cwd": "/workspace", + "execution_mode": "guest_vsock", + "exit_code": 2, + "duration_ms": 5, + "stdout": "", + "stderr": "boom\n", + } + + class ExecParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="exec", + workspace_id="workspace-123", + timeout_seconds=30, + json=True, + command_args=["--", "false"], + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: ExecParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + with pytest.raises(SystemExit, match="2"): + cli.main() + payload = json.loads(capsys.readouterr().out) + assert payload["exit_code"] == 2 + + +def test_cli_workspace_exec_prints_human_error( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def exec_workspace( + self, workspace_id: str, *, command: str, timeout_seconds: int + ) -> dict[str, Any]: + del workspace_id, command, timeout_seconds + raise RuntimeError("exec boom") + + class ExecParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="exec", + workspace_id="workspace-123", + timeout_seconds=30, + json=False, + command_args=["--", "cat", "note.txt"], + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: ExecParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + with pytest.raises(SystemExit, match="1"): + cli.main() + assert "[error] exec boom" in capsys.readouterr().err + + +def test_cli_workspace_export_and_diff_print_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def export_workspace( + self, workspace_id: str, path: str, *, output_path: str + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert path == "note.txt" + assert output_path == "./note.txt" + return { + "workspace_id": workspace_id, + "workspace_path": "/workspace/note.txt", + "output_path": "/tmp/note.txt", + "artifact_type": "file", + "entry_count": 1, + "bytes_written": 6, + "execution_mode": "guest_vsock", + } + + def diff_workspace(self, workspace_id: str) -> dict[str, Any]: + return { + "workspace_id": workspace_id, + "changed": False, + "summary": { + "total": 0, + "added": 0, + "modified": 0, + "deleted": 0, + "type_changed": 0, + "text_patched": 0, + "non_text": 0, + }, + "entries": [], + "patch": "", + } + + class ExportParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="export", + workspace_id="workspace-123", + path="note.txt", + output="./note.txt", + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: ExportParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + export_payload = json.loads(capsys.readouterr().out) + assert export_payload["artifact_type"] == "file" + + class DiffParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="diff", + workspace_id="workspace-123", + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: DiffParser()) + cli.main() + diff_payload = json.loads(capsys.readouterr().out) + assert diff_payload["changed"] is False + + +@pytest.mark.parametrize( + ("command_name", "json_mode", "method_name"), + [ + ("list", True, "list_services"), + ("list", False, "list_services"), + ("status", True, "status_service"), + ("status", False, "status_service"), + ("logs", True, "logs_service"), + ("logs", False, "logs_service"), + ("stop", True, "stop_service"), + ("stop", False, "stop_service"), + ], +) +def test_cli_workspace_service_error_paths( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + command_name: str, + json_mode: bool, + method_name: str, +) -> None: + class StubPyro: + def list_services(self, workspace_id: str) -> dict[str, Any]: + del workspace_id + raise RuntimeError("service branch boom") + + def status_service(self, workspace_id: str, service_name: str) -> dict[str, Any]: + del workspace_id, service_name + raise RuntimeError("service branch boom") + + def logs_service( + self, + workspace_id: str, + service_name: str, + *, + tail_lines: int | None, + all: bool, + ) -> dict[str, Any]: + del workspace_id, service_name, tail_lines, all + raise RuntimeError("service branch boom") + + def stop_service(self, workspace_id: str, service_name: str) -> dict[str, Any]: + del workspace_id, service_name + raise RuntimeError("service branch boom") + + class Parser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command=command_name, + workspace_id="workspace-123", + service_name="app", + tail_lines=50, + all=False, + json=json_mode, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: Parser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + with pytest.raises(SystemExit, match="1"): + cli.main() + captured = capsys.readouterr() + if json_mode: + payload = json.loads(captured.out) + assert payload["error"] == "service branch boom" + else: + assert "[error] service branch boom" in captured.err + assert hasattr(StubPyro, method_name) + + def test_cli_workspace_shell_open_and_read_human( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], @@ -1045,6 +1363,742 @@ def test_cli_workspace_shell_write_signal_close_json( assert closed["closed"] is True +def test_cli_workspace_shell_open_and_read_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def open_shell( + self, + workspace_id: str, + *, + cwd: str, + cols: int, + rows: int, + ) -> dict[str, Any]: + return { + "workspace_id": workspace_id, + "shell_id": "shell-123", + "state": "running", + "cwd": cwd, + "cols": cols, + "rows": rows, + "started_at": 1.0, + "ended_at": None, + "exit_code": None, + "execution_mode": "guest_vsock", + } + + def read_shell( + self, + workspace_id: str, + shell_id: str, + *, + cursor: int, + max_chars: int, + ) -> dict[str, Any]: + return { + "workspace_id": workspace_id, + "shell_id": shell_id, + "state": "running", + "cwd": "/workspace", + "cols": 120, + "rows": 30, + "started_at": 1.0, + "ended_at": None, + "exit_code": None, + "execution_mode": "guest_vsock", + "cursor": cursor, + "next_cursor": max_chars, + "output": "pyro$ pwd\n", + "truncated": False, + } + + class OpenParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="shell", + workspace_shell_command="open", + workspace_id="workspace-123", + cwd="/workspace", + cols=120, + rows=30, + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: OpenParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + opened = json.loads(capsys.readouterr().out) + assert opened["shell_id"] == "shell-123" + + class ReadParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="shell", + workspace_shell_command="read", + workspace_id="workspace-123", + shell_id="shell-123", + cursor=0, + max_chars=1024, + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: ReadParser()) + cli.main() + payload = json.loads(capsys.readouterr().out) + assert payload["output"] == "pyro$ pwd\n" + + +def test_cli_workspace_shell_write_signal_close_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def write_shell( + self, + workspace_id: str, + shell_id: str, + *, + input: str, + append_newline: bool, + ) -> dict[str, Any]: + del input, append_newline + return { + "workspace_id": workspace_id, + "shell_id": shell_id, + "state": "running", + "cwd": "/workspace", + "cols": 120, + "rows": 30, + "started_at": 1.0, + "ended_at": None, + "exit_code": None, + "execution_mode": "guest_vsock", + "input_length": 3, + "append_newline": True, + } + + def signal_shell( + self, + workspace_id: str, + shell_id: str, + *, + signal_name: str, + ) -> dict[str, Any]: + return { + "workspace_id": workspace_id, + "shell_id": shell_id, + "state": "running", + "cwd": "/workspace", + "cols": 120, + "rows": 30, + "started_at": 1.0, + "ended_at": None, + "exit_code": None, + "execution_mode": "guest_vsock", + "signal": signal_name, + } + + def close_shell(self, workspace_id: str, shell_id: str) -> dict[str, Any]: + return { + "workspace_id": workspace_id, + "shell_id": shell_id, + "state": "stopped", + "cwd": "/workspace", + "cols": 120, + "rows": 30, + "started_at": 1.0, + "ended_at": 2.0, + "exit_code": 0, + "execution_mode": "guest_vsock", + "closed": True, + } + + class WriteParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="shell", + workspace_shell_command="write", + workspace_id="workspace-123", + shell_id="shell-123", + input="pwd", + no_newline=False, + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: WriteParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + + class SignalParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="shell", + workspace_shell_command="signal", + workspace_id="workspace-123", + shell_id="shell-123", + signal="INT", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: SignalParser()) + cli.main() + + class CloseParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="shell", + workspace_shell_command="close", + workspace_id="workspace-123", + shell_id="shell-123", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: CloseParser()) + cli.main() + captured = capsys.readouterr() + assert "[workspace-shell-write]" in captured.err + assert "[workspace-shell-signal]" in captured.err + assert "[workspace-shell-close]" in captured.err + + +@pytest.mark.parametrize( + ("shell_command", "kwargs"), + [ + ("open", {"cwd": "/workspace", "cols": 120, "rows": 30}), + ("read", {"shell_id": "shell-123", "cursor": 0, "max_chars": 1024}), + ("write", {"shell_id": "shell-123", "input": "pwd", "no_newline": False}), + ("signal", {"shell_id": "shell-123", "signal": "INT"}), + ("close", {"shell_id": "shell-123"}), + ], +) +def test_cli_workspace_shell_error_paths( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + shell_command: str, + kwargs: dict[str, Any], +) -> None: + class StubPyro: + def open_shell(self, *args: Any, **inner_kwargs: Any) -> dict[str, Any]: + del args, inner_kwargs + raise RuntimeError("shell boom") + + def read_shell(self, *args: Any, **inner_kwargs: Any) -> dict[str, Any]: + del args, inner_kwargs + raise RuntimeError("shell boom") + + def write_shell(self, *args: Any, **inner_kwargs: Any) -> dict[str, Any]: + del args, inner_kwargs + raise RuntimeError("shell boom") + + def signal_shell(self, *args: Any, **inner_kwargs: Any) -> dict[str, Any]: + del args, inner_kwargs + raise RuntimeError("shell boom") + + def close_shell(self, *args: Any, **inner_kwargs: Any) -> dict[str, Any]: + del args, inner_kwargs + raise RuntimeError("shell boom") + + class Parser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="shell", + workspace_shell_command=shell_command, + workspace_id="workspace-123", + json=False, + **kwargs, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: Parser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + with pytest.raises(SystemExit, match="1"): + cli.main() + assert "[error] shell boom" in capsys.readouterr().err + + +def test_cli_workspace_service_start_prints_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def start_service( + self, workspace_id: str, service_name: str, **kwargs: Any + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert service_name == "app" + assert kwargs["command"] == "sh -lc 'touch .ready && while true; do sleep 60; done'" + assert kwargs["readiness"] == {"type": "file", "path": ".ready"} + return { + "workspace_id": workspace_id, + "service_name": service_name, + "state": "running", + "cwd": "/workspace", + "execution_mode": "guest_vsock", + } + + class StartParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="start", + workspace_id="workspace-123", + service_name="app", + cwd="/workspace", + ready_file=".ready", + ready_tcp=None, + ready_http=None, + ready_command=None, + ready_timeout_seconds=30, + ready_interval_ms=500, + json=True, + command_args=["--", "sh", "-lc", "touch .ready && while true; do sleep 60; done"], + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StartParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + payload = json.loads(capsys.readouterr().out) + assert payload["state"] == "running" + + +def test_cli_workspace_service_logs_prints_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def logs_service( + self, + workspace_id: str, + service_name: str, + *, + tail_lines: int, + all: bool, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert service_name == "app" + assert tail_lines == 200 + assert all is False + return { + "workspace_id": workspace_id, + "service_name": service_name, + "state": "running", + "cwd": "/workspace", + "execution_mode": "guest_vsock", + "stdout": "ready\n", + "stderr": "", + "tail_lines": 200, + "truncated": False, + } + + class LogsParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="logs", + workspace_id="workspace-123", + service_name="app", + tail_lines=200, + all=False, + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: LogsParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + captured = capsys.readouterr() + assert captured.out == "ready\n" + assert "service_name=app" in captured.err + + +def test_cli_workspace_service_list_prints_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def list_services(self, workspace_id: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + return { + "workspace_id": workspace_id, + "count": 2, + "running_count": 1, + "services": [ + { + "workspace_id": workspace_id, + "service_name": "app", + "state": "running", + "cwd": "/workspace", + "execution_mode": "guest_vsock", + "readiness": {"type": "file", "path": "/workspace/.ready"}, + }, + { + "workspace_id": workspace_id, + "service_name": "worker", + "state": "stopped", + "cwd": "/workspace", + "execution_mode": "guest_vsock", + "readiness": None, + }, + ], + } + + class ListParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="list", + workspace_id="workspace-123", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: ListParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + captured = capsys.readouterr() + assert "app [running] cwd=/workspace" in captured.out + assert "worker [stopped] cwd=/workspace" in captured.out + + +def test_cli_workspace_service_status_prints_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def status_service(self, workspace_id: str, service_name: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert service_name == "app" + return { + "workspace_id": workspace_id, + "service_name": service_name, + "state": "running", + "cwd": "/workspace", + "execution_mode": "guest_vsock", + "readiness": {"type": "file", "path": "/workspace/.ready"}, + } + + class StatusParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="status", + workspace_id="workspace-123", + service_name="app", + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StatusParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + payload = json.loads(capsys.readouterr().out) + assert payload["state"] == "running" + + +def test_cli_workspace_service_stop_prints_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def stop_service(self, workspace_id: str, service_name: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert service_name == "app" + return { + "workspace_id": workspace_id, + "service_name": service_name, + "state": "stopped", + "cwd": "/workspace", + "execution_mode": "guest_vsock", + "stop_reason": "sigterm", + } + + class StopParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="stop", + workspace_id="workspace-123", + service_name="app", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StopParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + captured = capsys.readouterr() + assert "service_name=app" in captured.err + assert "state=stopped" in captured.err + + +def test_cli_workspace_service_start_rejects_multiple_readiness_flags( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def start_service(self, *args: Any, **kwargs: Any) -> dict[str, Any]: + raise AssertionError("start_service should not be called") + + class StartParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="start", + workspace_id="workspace-123", + service_name="app", + cwd="/workspace", + ready_file=".ready", + ready_tcp="127.0.0.1:8080", + ready_http=None, + ready_command=None, + ready_timeout_seconds=30, + ready_interval_ms=500, + json=False, + command_args=["--", "sh", "-lc", "touch .ready && while true; do sleep 60; done"], + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StartParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + with pytest.raises(SystemExit, match="1"): + cli.main() + captured = capsys.readouterr() + assert "choose at most one" in captured.err + + +def test_cli_workspace_service_start_prints_human_with_ready_command( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def start_service( + self, workspace_id: str, service_name: str, **kwargs: Any + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert service_name == "app" + assert kwargs["readiness"] == {"type": "command", "command": "test -f .ready"} + return { + "workspace_id": workspace_id, + "service_name": service_name, + "state": "running", + "cwd": "/workspace", + "execution_mode": "guest_vsock", + } + + class StartParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="start", + workspace_id="workspace-123", + service_name="app", + cwd="/workspace", + ready_file=None, + ready_tcp=None, + ready_http=None, + ready_command="test -f .ready", + ready_timeout_seconds=30, + ready_interval_ms=500, + json=False, + command_args=["--", "sh", "-lc", "touch .ready && while true; do sleep 60; done"], + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StartParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + captured = capsys.readouterr() + assert "service_name=app" in captured.err + assert "state=running" in captured.err + + +def test_cli_workspace_service_start_prints_json_error( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def start_service( + self, workspace_id: str, service_name: str, **kwargs: Any + ) -> dict[str, Any]: + del workspace_id, service_name, kwargs + raise RuntimeError("service boom") + + class StartParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="start", + workspace_id="workspace-123", + service_name="app", + cwd="/workspace", + ready_file=None, + ready_tcp="127.0.0.1:8080", + ready_http=None, + ready_command=None, + ready_timeout_seconds=30, + ready_interval_ms=500, + json=True, + command_args=["--", "sh", "-lc", "while true; do sleep 60; done"], + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StartParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + with pytest.raises(SystemExit, match="1"): + cli.main() + payload = json.loads(capsys.readouterr().out) + assert payload["error"] == "service boom" + + +def test_cli_workspace_service_list_prints_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def list_services(self, workspace_id: str) -> dict[str, Any]: + return {"workspace_id": workspace_id, "count": 0, "running_count": 0, "services": []} + + class ListParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="list", + workspace_id="workspace-123", + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: ListParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + payload = json.loads(capsys.readouterr().out) + assert payload["count"] == 0 + + +def test_cli_workspace_service_status_prints_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def status_service(self, workspace_id: str, service_name: str) -> dict[str, Any]: + del workspace_id + return { + "workspace_id": "workspace-123", + "service_name": service_name, + "state": "running", + "cwd": "/workspace", + "execution_mode": "guest_vsock", + "readiness": {"type": "tcp", "address": "127.0.0.1:8080"}, + } + + class StatusParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="status", + workspace_id="workspace-123", + service_name="app", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StatusParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + captured = capsys.readouterr() + assert "service_name=app" in captured.err + assert "state=running" in captured.err + + +def test_cli_workspace_service_logs_prints_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def logs_service( + self, + workspace_id: str, + service_name: str, + *, + tail_lines: int | None, + all: bool, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert service_name == "app" + assert tail_lines is None + assert all is True + return { + "workspace_id": workspace_id, + "service_name": service_name, + "state": "running", + "cwd": "/workspace", + "execution_mode": "guest_vsock", + "stdout": "ready\n", + "stderr": "", + "tail_lines": None, + "truncated": False, + } + + class LogsParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="logs", + workspace_id="workspace-123", + service_name="app", + tail_lines=None, + all=True, + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: LogsParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + payload = json.loads(capsys.readouterr().out) + assert payload["tail_lines"] is None + + +def test_cli_workspace_service_stop_prints_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def stop_service(self, workspace_id: str, service_name: str) -> dict[str, Any]: + return { + "workspace_id": workspace_id, + "service_name": service_name, + "state": "stopped", + "cwd": "/workspace", + "execution_mode": "guest_vsock", + } + + class StopParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="stop", + workspace_id="workspace-123", + service_name="app", + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StopParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + payload = json.loads(capsys.readouterr().out) + assert payload["state"] == "stopped" + + def test_cli_workspace_exec_json_error_exits_nonzero( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: diff --git a/tests/test_public_contract.py b/tests/test_public_contract.py index f026351..39534f2 100644 --- a/tests/test_public_contract.py +++ b/tests/test_public_contract.py @@ -20,6 +20,12 @@ from pyro_mcp.contract import ( PUBLIC_CLI_WORKSPACE_CREATE_FLAGS, PUBLIC_CLI_WORKSPACE_DIFF_FLAGS, PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS, + PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS, + PUBLIC_CLI_WORKSPACE_SERVICE_LOGS_FLAGS, + PUBLIC_CLI_WORKSPACE_SERVICE_START_FLAGS, + PUBLIC_CLI_WORKSPACE_SERVICE_STATUS_FLAGS, + PUBLIC_CLI_WORKSPACE_SERVICE_STOP_FLAGS, + PUBLIC_CLI_WORKSPACE_SERVICE_SUBCOMMANDS, PUBLIC_CLI_WORKSPACE_SHELL_CLOSE_FLAGS, PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS, PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS, @@ -135,6 +141,37 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None: ).format_help() for flag in PUBLIC_CLI_WORKSPACE_SHELL_CLOSE_FLAGS: assert flag in workspace_shell_close_help_text + workspace_service_help_text = _subparser_choice( + _subparser_choice(parser, "workspace"), + "service", + ).format_help() + for subcommand_name in PUBLIC_CLI_WORKSPACE_SERVICE_SUBCOMMANDS: + assert subcommand_name in workspace_service_help_text + workspace_service_start_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "service"), "start" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_SERVICE_START_FLAGS: + assert flag in workspace_service_start_help_text + workspace_service_list_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "service"), "list" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS: + assert flag in workspace_service_list_help_text + workspace_service_status_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "service"), "status" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_SERVICE_STATUS_FLAGS: + assert flag in workspace_service_status_help_text + workspace_service_logs_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "service"), "logs" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_SERVICE_LOGS_FLAGS: + assert flag in workspace_service_logs_help_text + workspace_service_stop_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "service"), "stop" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_SERVICE_STOP_FLAGS: + assert flag in workspace_service_stop_help_text demo_help_text = _subparser_choice(parser, "demo").format_help() for subcommand_name in PUBLIC_CLI_DEMO_SUBCOMMANDS: diff --git a/tests/test_server.py b/tests/test_server.py index 0df0197..87c3669 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import time from pathlib import Path from typing import Any, cast @@ -42,6 +41,11 @@ def test_create_server_registers_vm_tools(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_vm_run_round_trip(tmp_path: Path) -> None: @@ -192,20 +196,7 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None: raise TypeError("expected structured dictionary result") return cast(dict[str, Any], structured) - async def _run() -> tuple[ - dict[str, Any], - dict[str, Any], - dict[str, Any], - dict[str, Any], - dict[str, Any], - dict[str, Any], - dict[str, Any], - dict[str, Any], - dict[str, Any], - dict[str, Any], - dict[str, Any], - dict[str, Any], - ]: + async def _run() -> tuple[dict[str, Any], ...]: server = create_server(manager=manager) created = _extract_structured( await server.call_tool( @@ -254,57 +245,45 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None: }, ) ) - opened = _extract_structured( - await server.call_tool("shell_open", {"workspace_id": workspace_id}) - ) - shell_id = str(opened["shell_id"]) - written = _extract_structured( + service = _extract_structured( await server.call_tool( - "shell_write", + "service_start", { "workspace_id": workspace_id, - "shell_id": shell_id, - "input": "pwd", + "service_name": "app", + "command": "sh -lc 'touch .ready; while true; do sleep 60; done'", + "ready_file": ".ready", }, ) ) - read = _extract_structured( + services = _extract_structured( + await server.call_tool("service_list", {"workspace_id": workspace_id}) + ) + service_status = _extract_structured( await server.call_tool( - "shell_read", + "service_status", { "workspace_id": workspace_id, - "shell_id": shell_id, + "service_name": "app", }, ) ) - deadline = time.time() + 5 - while "/workspace" not in str(read["output"]) and time.time() < deadline: - read = _extract_structured( - await server.call_tool( - "shell_read", - { - "workspace_id": workspace_id, - "shell_id": shell_id, - "cursor": 0, - }, - ) - ) - await asyncio.sleep(0.05) - signaled = _extract_structured( + service_logs = _extract_structured( await server.call_tool( - "shell_signal", + "service_logs", { "workspace_id": workspace_id, - "shell_id": shell_id, + "service_name": "app", + "all": True, }, ) ) - closed = _extract_structured( + service_stopped = _extract_structured( await server.call_tool( - "shell_close", + "service_stop", { "workspace_id": workspace_id, - "shell_id": shell_id, + "service_name": "app", }, ) ) @@ -320,11 +299,11 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None: executed, diffed, exported, - opened, - written, - read, - signaled, - closed, + service, + services, + service_status, + service_logs, + service_stopped, logs, deleted, ) @@ -335,11 +314,11 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None: executed, diffed, exported, - opened, - written, - read, - signaled, - closed, + service, + services, + service_status, + service_logs, + service_stopped, logs, deleted, ) = asyncio.run(_run()) @@ -350,10 +329,10 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None: assert diffed["changed"] is True assert exported["artifact_type"] == "file" assert Path(str(exported["output_path"])).read_text(encoding="utf-8") == "more\n" - assert opened["state"] == "running" - assert written["input_length"] == 3 - 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 logs["count"] == 1 assert deleted["deleted"] is True diff --git a/tests/test_vm_guest.py b/tests/test_vm_guest.py index a9310fd..8ce007c 100644 --- a/tests/test_vm_guest.py +++ b/tests/test_vm_guest.py @@ -262,6 +262,105 @@ def test_vsock_exec_client_shell_round_trip(monkeypatch: pytest.MonkeyPatch) -> assert open_request["shell_id"] == "shell-1" +def test_vsock_exec_client_service_round_trip(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(socket, "AF_VSOCK", 40, raising=False) + responses = [ + json.dumps( + { + "service_name": "app", + "command": "echo ok", + "cwd": "/workspace", + "state": "running", + "started_at": 1.0, + "ready_at": 2.0, + "ended_at": None, + "exit_code": None, + "pid": 42, + "readiness": {"type": "file", "path": "/workspace/.ready"}, + "stop_reason": None, + } + ).encode("utf-8"), + json.dumps( + { + "service_name": "app", + "command": "echo ok", + "cwd": "/workspace", + "state": "running", + "started_at": 1.0, + "ready_at": 2.0, + "ended_at": None, + "exit_code": None, + "pid": 42, + "readiness": {"type": "file", "path": "/workspace/.ready"}, + "stop_reason": None, + } + ).encode("utf-8"), + json.dumps( + { + "service_name": "app", + "command": "echo ok", + "cwd": "/workspace", + "state": "running", + "started_at": 1.0, + "ready_at": 2.0, + "ended_at": None, + "exit_code": None, + "pid": 42, + "readiness": {"type": "file", "path": "/workspace/.ready"}, + "stop_reason": None, + "stdout": "ok\n", + "stderr": "", + "tail_lines": 200, + "truncated": False, + } + ).encode("utf-8"), + json.dumps( + { + "service_name": "app", + "command": "echo ok", + "cwd": "/workspace", + "state": "stopped", + "started_at": 1.0, + "ready_at": 2.0, + "ended_at": 3.0, + "exit_code": 0, + "pid": 42, + "readiness": {"type": "file", "path": "/workspace/.ready"}, + "stop_reason": "sigterm", + } + ).encode("utf-8"), + ] + stubs = [StubSocket(response) for response in responses] + remaining = list(stubs) + + def socket_factory(family: int, sock_type: int) -> StubSocket: + assert family == socket.AF_VSOCK + assert sock_type == socket.SOCK_STREAM + return remaining.pop(0) + + client = VsockExecClient(socket_factory=socket_factory) + started = client.start_service( + 1234, + 5005, + service_name="app", + command="echo ok", + cwd="/workspace", + readiness={"type": "file", "path": "/workspace/.ready"}, + ready_timeout_seconds=30, + ready_interval_ms=500, + ) + assert started["service_name"] == "app" + status = client.status_service(1234, 5005, service_name="app") + assert status["state"] == "running" + logs = client.logs_service(1234, 5005, service_name="app", tail_lines=200) + assert logs["stdout"] == "ok\n" + stopped = client.stop_service(1234, 5005, service_name="app") + assert stopped["state"] == "stopped" + start_request = json.loads(stubs[0].sent.decode("utf-8").strip()) + assert start_request["action"] == "start_service" + assert start_request["service_name"] == "app" + + def test_vsock_exec_client_raises_agent_error(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(socket, "AF_VSOCK", 40, raising=False) stub = StubSocket(b'{"error":"shell is unavailable"}') diff --git a/tests/test_vm_manager.py b/tests/test_vm_manager.py index ab17a11..67b3bbd 100644 --- a/tests/test_vm_manager.py +++ b/tests/test_vm_manager.py @@ -3,6 +3,7 @@ from __future__ import annotations import io import json import os +import signal import subprocess import tarfile import time @@ -1144,3 +1145,369 @@ def test_reap_expired_workspaces_removes_invalid_and_expired_records(tmp_path: P assert not invalid_dir.exists() assert not (tmp_path / "vms" / "workspaces" / workspace_id).exists() + + +def test_workspace_service_lifecycle_and_status_counts(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + )["workspace_id"] + ) + + started = manager.start_service( + workspace_id, + "app", + command="sh -lc 'printf \"service ready\\n\"; touch .ready; while true; do sleep 60; done'", + readiness={"type": "file", "path": ".ready"}, + ) + assert started["state"] == "running" + + listed = manager.list_services(workspace_id) + assert listed["count"] == 1 + assert listed["running_count"] == 1 + + status = manager.status_service(workspace_id, "app") + assert status["state"] == "running" + assert status["ready_at"] is not None + + logs = manager.logs_service(workspace_id, "app") + assert "service ready" in str(logs["stdout"]) + + workspace_status = manager.status_workspace(workspace_id) + assert workspace_status["service_count"] == 1 + assert workspace_status["running_service_count"] == 1 + + stopped = manager.stop_service(workspace_id, "app") + assert stopped["state"] == "stopped" + assert stopped["stop_reason"] in {"sigterm", "sigkill"} + + deleted = manager.delete_workspace(workspace_id) + assert deleted["deleted"] is True + + +def test_workspace_service_start_replaces_non_running_record(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + )["workspace_id"] + ) + + failed = manager.start_service( + workspace_id, + "app", + command="sh -lc 'exit 2'", + readiness={"type": "file", "path": ".ready"}, + ready_timeout_seconds=1, + ready_interval_ms=50, + ) + assert failed["state"] == "failed" + + started = manager.start_service( + workspace_id, + "app", + command="sh -lc 'touch .ready; while true; do sleep 60; done'", + readiness={"type": "file", "path": ".ready"}, + ) + assert started["state"] == "running" + manager.delete_workspace(workspace_id) + + +def test_workspace_service_supports_command_readiness_and_helper_probes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + )["workspace_id"] + ) + + command_started = manager.start_service( + workspace_id, + "command-ready", + command="sh -lc 'touch command.ready; while true; do sleep 60; done'", + readiness={"type": "command", "command": "test -f command.ready"}, + ) + assert command_started["state"] == "running" + + listed = manager.list_services(workspace_id) + assert listed["count"] == 1 + assert listed["running_count"] == 1 + + status = manager.status_workspace(workspace_id) + assert status["service_count"] == 1 + assert status["running_service_count"] == 1 + + assert manager.stop_service(workspace_id, "command-ready")["state"] == "stopped" + + workspace_dir = tmp_path / "vms" / "workspaces" / workspace_id / "workspace" + ready_file = workspace_dir / "probe.ready" + ready_file.write_text("ok\n", encoding="utf-8") + assert vm_manager_module._service_ready_on_host( # noqa: SLF001 + readiness={"type": "file", "path": "/workspace/probe.ready"}, + workspace_dir=workspace_dir, + cwd=workspace_dir, + ) + + class StubSocket: + def __enter__(self) -> StubSocket: + return self + + def __exit__(self, *args: object) -> None: + del args + + def settimeout(self, timeout: int) -> None: + assert timeout == 1 + + def connect(self, address: tuple[str, int]) -> None: + assert address == ("127.0.0.1", 8080) + + monkeypatch.setattr("pyro_mcp.vm_manager.socket.socket", lambda *args: StubSocket()) + assert vm_manager_module._service_ready_on_host( # noqa: SLF001 + readiness={"type": "tcp", "address": "127.0.0.1:8080"}, + workspace_dir=workspace_dir, + cwd=workspace_dir, + ) + + class StubResponse: + status = 204 + + def __enter__(self) -> StubResponse: + return self + + def __exit__(self, *args: object) -> None: + del args + + def _urlopen(request: object, timeout: int) -> StubResponse: + del request + assert timeout == 2 + return StubResponse() + + monkeypatch.setattr("pyro_mcp.vm_manager.urllib.request.urlopen", _urlopen) + assert vm_manager_module._service_ready_on_host( # noqa: SLF001 + readiness={"type": "http", "url": "http://127.0.0.1:8080/"}, + workspace_dir=workspace_dir, + cwd=workspace_dir, + ) + + +def test_workspace_service_logs_tail_and_delete_cleanup(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + )["workspace_id"] + ) + + manager.start_service( + workspace_id, + "logger", + command=( + "sh -lc 'printf \"one\\n\"; printf \"two\\n\"; " + "touch .ready; while true; do sleep 60; done'" + ), + readiness={"type": "file", "path": ".ready"}, + ) + + logs = manager.logs_service(workspace_id, "logger", tail_lines=1) + assert logs["stdout"] == "two\n" + assert logs["truncated"] is True + + services_dir = tmp_path / "vms" / "workspaces" / workspace_id / "services" + assert services_dir.exists() + deleted = manager.delete_workspace(workspace_id) + assert deleted["deleted"] is True + assert not services_dir.exists() + + +def test_workspace_status_stops_service_counts_when_workspace_is_stopped(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + )["workspace_id"] + ) + manager.start_service( + workspace_id, + "app", + command="sh -lc 'touch .ready; while true; do sleep 60; done'", + readiness={"type": "file", "path": ".ready"}, + ) + service_path = tmp_path / "vms" / "workspaces" / workspace_id / "services" / "app.json" + live_service_payload = json.loads(service_path.read_text(encoding="utf-8")) + live_pid = int(live_service_payload["pid"]) + + try: + workspace_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json" + payload = json.loads(workspace_path.read_text(encoding="utf-8")) + payload["state"] = "stopped" + workspace_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + + status = manager.status_workspace(workspace_id) + assert status["state"] == "stopped" + assert status["service_count"] == 1 + assert status["running_service_count"] == 0 + + service_payload = json.loads(service_path.read_text(encoding="utf-8")) + assert service_payload["state"] == "stopped" + assert service_payload["stop_reason"] == "workspace_stopped" + finally: + vm_manager_module._stop_process_group(live_pid) # noqa: SLF001 + + +def test_workspace_service_readiness_validation_helpers() -> None: + assert vm_manager_module._normalize_workspace_service_name("app-1") == "app-1" # noqa: SLF001 + with pytest.raises(ValueError, match="service_name must not be empty"): + vm_manager_module._normalize_workspace_service_name(" ") # noqa: SLF001 + with pytest.raises(ValueError, match="service_name must match"): + vm_manager_module._normalize_workspace_service_name("bad name") # noqa: SLF001 + + assert vm_manager_module._normalize_workspace_service_readiness( # noqa: SLF001 + {"type": "file", "path": "subdir/.ready"} + ) == {"type": "file", "path": "/workspace/subdir/.ready"} + assert vm_manager_module._normalize_workspace_service_readiness( # noqa: SLF001 + {"type": "tcp", "address": "127.0.0.1:8080"} + ) == {"type": "tcp", "address": "127.0.0.1:8080"} + assert vm_manager_module._normalize_workspace_service_readiness( # noqa: SLF001 + {"type": "http", "url": "http://127.0.0.1:8080/"} + ) == {"type": "http", "url": "http://127.0.0.1:8080/"} + assert vm_manager_module._normalize_workspace_service_readiness( # noqa: SLF001 + {"type": "command", "command": "test -f .ready"} + ) == {"type": "command", "command": "test -f .ready"} + + with pytest.raises(ValueError, match="one of: file, tcp, http, command"): + vm_manager_module._normalize_workspace_service_readiness({"type": "bogus"}) # noqa: SLF001 + with pytest.raises(ValueError, match="required for file readiness"): + vm_manager_module._normalize_workspace_service_readiness({"type": "file"}) # noqa: SLF001 + with pytest.raises(ValueError, match="HOST:PORT format"): + vm_manager_module._normalize_workspace_service_readiness( # noqa: SLF001 + {"type": "tcp", "address": "127.0.0.1"} + ) + with pytest.raises(ValueError, match="required for http readiness"): + vm_manager_module._normalize_workspace_service_readiness({"type": "http"}) # noqa: SLF001 + with pytest.raises(ValueError, match="required for command readiness"): + vm_manager_module._normalize_workspace_service_readiness({"type": "command"}) # noqa: SLF001 + + +def test_workspace_service_text_and_exit_code_helpers(tmp_path: Path) -> None: + status_path = tmp_path / "service.status" + assert vm_manager_module._read_service_exit_code(status_path) is None # noqa: SLF001 + status_path.write_text("", encoding="utf-8") + assert vm_manager_module._read_service_exit_code(status_path) is None # noqa: SLF001 + status_path.write_text("7\n", encoding="utf-8") + assert vm_manager_module._read_service_exit_code(status_path) == 7 # noqa: SLF001 + + log_path = tmp_path / "service.log" + assert vm_manager_module._tail_text(log_path, tail_lines=10) == ("", False) # noqa: SLF001 + log_path.write_text("one\ntwo\nthree\n", encoding="utf-8") + assert vm_manager_module._tail_text(log_path, tail_lines=None) == ( # noqa: SLF001 + "one\ntwo\nthree\n", + False, + ) + assert vm_manager_module._tail_text(log_path, tail_lines=5) == ( # noqa: SLF001 + "one\ntwo\nthree\n", + False, + ) + assert vm_manager_module._tail_text(log_path, tail_lines=1) == ("three\n", True) # noqa: SLF001 + + +def test_workspace_service_process_group_helpers(monkeypatch: pytest.MonkeyPatch) -> None: + def _missing(_pid: int, _signal: int) -> None: + raise ProcessLookupError() + + monkeypatch.setattr("pyro_mcp.vm_manager.os.killpg", _missing) + assert vm_manager_module._stop_process_group(123) == (False, False) # noqa: SLF001 + + kill_calls: list[int] = [] + monotonic_values = iter([0.0, 0.0, 5.0, 5.0, 10.0]) + running_states = iter([True, True, False]) + + def _killpg(_pid: int, signum: int) -> None: + kill_calls.append(signum) + + def _monotonic() -> float: + return next(monotonic_values) + + def _is_running(_pid: int | None) -> bool: + return next(running_states) + + monkeypatch.setattr("pyro_mcp.vm_manager.os.killpg", _killpg) + monkeypatch.setattr("pyro_mcp.vm_manager.time.monotonic", _monotonic) + monkeypatch.setattr("pyro_mcp.vm_manager.time.sleep", lambda _seconds: None) + monkeypatch.setattr("pyro_mcp.vm_manager._pid_is_running", _is_running) + + stopped, killed = vm_manager_module._stop_process_group(456, wait_seconds=5) # noqa: SLF001 + assert (stopped, killed) == (True, True) + assert kill_calls == [signal.SIGTERM, signal.SIGKILL] + + +def test_workspace_service_probe_and_refresh_helpers( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + assert vm_manager_module._run_service_probe_command(tmp_path, "exit 3") == 3 # noqa: SLF001 + + services_dir = tmp_path / "services" + services_dir.mkdir() + status_path = services_dir / "app.status" + status_path.write_text("9\n", encoding="utf-8") + running = vm_manager_module.WorkspaceServiceRecord( # noqa: SLF001 + workspace_id="workspace-1", + service_name="app", + command="sleep 60", + cwd="/workspace", + state="running", + started_at=time.time(), + readiness=None, + ready_at=None, + ended_at=None, + exit_code=None, + pid=1234, + execution_mode="host_compat", + stop_reason=None, + ) + + monkeypatch.setattr("pyro_mcp.vm_manager._pid_is_running", lambda _pid: False) + refreshed = vm_manager_module._refresh_local_service_record( # noqa: SLF001 + running, + services_dir=services_dir, + ) + assert refreshed.state == "exited" + assert refreshed.exit_code == 9 + + monkeypatch.setattr( + "pyro_mcp.vm_manager._stop_process_group", + lambda _pid: (True, False), + ) + stopped = vm_manager_module._stop_local_service( # noqa: SLF001 + refreshed, + services_dir=services_dir, + ) + assert stopped.state == "stopped" + assert stopped.stop_reason == "sigterm" diff --git a/tests/test_workspace_shells.py b/tests/test_workspace_shells.py index 7b56cf4..4f4f206 100644 --- a/tests/test_workspace_shells.py +++ b/tests/test_workspace_shells.py @@ -165,6 +165,7 @@ def test_workspace_shells_write_and_signal_runtime_errors( try: with session._lock: # noqa: SLF001 session._master_fd = None # noqa: SLF001 + session._input_pipe = None # noqa: SLF001 with pytest.raises(RuntimeError, match="transport is unavailable"): session.write("pwd", append_newline=True) diff --git a/uv.lock b/uv.lock index 6f69a2e..d7370c6 100644 --- a/uv.lock +++ b/uv.lock @@ -706,7 +706,7 @@ crypto = [ [[package]] name = "pyro-mcp" -version = "2.6.0" +version = "2.7.0" source = { editable = "." } dependencies = [ { name = "mcp" },