diff --git a/CHANGELOG.md b/CHANGELOG.md index 863a6d0..7d099a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,63 @@ All notable user-visible changes to `pyro-mcp` are documented here. +## 4.5.0 + +- Added `pyro prepare` as the machine-level warmup path for the daily local + loop, with cached reuse when the runtime, catalog, and environment state are + already warm. +- Extended `pyro doctor` with daily-loop readiness output so users can see + whether the machine is cold, warm, or stale for `debian:12` before they + reconnect a chat host. +- Added `make smoke-daily-loop` to prove the warmed repro/fix/reset path end + to end on a real guest-backed machine. + +## 4.4.0 + +- Added explicit named MCP/server modes for the main workspace workflows: + `repro-fix`, `inspect`, `cold-start`, and `review-eval`. +- Kept the generic no-mode `workspace-core` path available as the escape hatch, + while making named modes the first user-facing story across help text, host + helpers, and the recipe docs. +- Aligned the shared use-case smoke runner with those modes so the repro/fix + and cold-start flows now prove a mode-backed happy path instead of only the + generic profile path. + +## 4.3.0 + +- Added `pyro workspace summary`, `Pyro.summarize_workspace()`, and MCP + `workspace_summary` so users and chat hosts can review a concise view of the + current workspace session since the last reset. +- Added a lightweight review-event log for edits, syncs, exports, service + lifecycle, and snapshot activity without duplicating the command journal. +- Updated the main workspace walkthroughs and review/eval recipe so + `workspace summary` is the first review surface before dropping down to raw + diffs, logs, and exported files. + +## 4.2.0 + +- Added host bootstrap and repair helpers with `pyro host connect`, + `pyro host print-config`, `pyro host doctor`, and `pyro host repair` for the + supported Claude Code, Codex, and OpenCode flows. +- Repositioned the docs and examples so supported hosts now start from the + helper flow first, while keeping raw `pyro mcp serve` commands as the + underlying MCP entrypoint and advanced fallback. +- Added deterministic host-helper coverage so the shipped helper commands and + OpenCode config snippet stay aligned with the canonical `pyro mcp serve` + command shape. + +## 4.1.0 + +- Added project-aware MCP startup so bare `pyro mcp serve` from a repo root can + auto-detect the current Git checkout and let `workspace_create` omit + `seed_path` safely. +- Added explicit fallback startup flags for chat hosts that do not preserve the + server working directory: `--project-path`, `--repo-url`, `--repo-ref`, and + `--no-project-source`. +- Extended workspace seed metadata with startup origin fields so chat-facing + workspace creation can show whether a workspace came from a manual seed path, + the current project, or a clean cloned repo source. + ## 4.0.0 - Flipped the default MCP/server profile from `workspace-full` to diff --git a/Makefile b/Makefile index 8c71e5b..465a2ab 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ PYTHON ?= uv run python UV_CACHE_DIR ?= .uv-cache -PYTEST_FLAGS ?= -n auto +PYTEST_WORKERS ?= $(shell sh -c 'n=$$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc 2>/dev/null || echo 2); if [ "$$n" -gt 8 ]; then n=8; fi; if [ "$$n" -lt 2 ]; then echo 1; else echo $$n; fi') +PYTEST_FLAGS ?= -n $(PYTEST_WORKERS) OLLAMA_BASE_URL ?= http://localhost:11434/v1 OLLAMA_MODEL ?= llama3.2:3b OLLAMA_DEMO_FLAGS ?= @@ -17,8 +18,9 @@ TWINE_USERNAME ?= __token__ PYPI_REPOSITORY_URL ?= USE_CASE_ENVIRONMENT ?= debian:12 USE_CASE_SMOKE_FLAGS ?= +DAILY_LOOP_ENVIRONMENT ?= debian:12 -.PHONY: help setup lint format typecheck test check dist-check pypi-publish demo network-demo doctor ollama ollama-demo run-server install-hooks smoke-use-cases smoke-cold-start-validation smoke-repro-fix-loop smoke-parallel-workspaces smoke-untrusted-inspection smoke-review-eval runtime-bundle runtime-binaries runtime-kernel runtime-rootfs runtime-agent runtime-validate runtime-manifest runtime-sync runtime-clean runtime-fetch-binaries runtime-build-kernel-real runtime-build-rootfs-real runtime-materialize runtime-export-environment-oci runtime-export-official-environments-oci runtime-publish-environment-oci runtime-publish-official-environments-oci runtime-boot-check runtime-network-check +.PHONY: help setup lint format typecheck test check dist-check pypi-publish demo network-demo doctor ollama ollama-demo run-server install-hooks smoke-daily-loop smoke-use-cases smoke-cold-start-validation smoke-repro-fix-loop smoke-parallel-workspaces smoke-untrusted-inspection smoke-review-eval runtime-bundle runtime-binaries runtime-kernel runtime-rootfs runtime-agent runtime-validate runtime-manifest runtime-sync runtime-clean runtime-fetch-binaries runtime-build-kernel-real runtime-build-rootfs-real runtime-materialize runtime-export-environment-oci runtime-export-official-environments-oci runtime-publish-environment-oci runtime-publish-official-environments-oci runtime-boot-check runtime-network-check help: @printf '%s\n' \ @@ -35,6 +37,7 @@ help: ' demo Run the deterministic VM demo' \ ' network-demo Run the deterministic VM demo with guest networking enabled' \ ' doctor Show runtime and host diagnostics' \ + ' smoke-daily-loop Run the real guest-backed prepare plus reset daily-loop smoke' \ ' smoke-use-cases Run all real guest-backed workspace use-case smokes' \ ' smoke-cold-start-validation Run the cold-start repo validation smoke' \ ' smoke-repro-fix-loop Run the repro-plus-fix loop smoke' \ @@ -82,13 +85,16 @@ test: check: lint typecheck test dist-check: - .venv/bin/pyro --version - .venv/bin/pyro --help >/dev/null - .venv/bin/pyro mcp --help >/dev/null - .venv/bin/pyro run --help >/dev/null - .venv/bin/pyro env list >/dev/null - .venv/bin/pyro env inspect debian:12 >/dev/null - .venv/bin/pyro doctor >/dev/null + uv run python -m pyro_mcp.cli --version + uv run python -m pyro_mcp.cli --help >/dev/null + uv run python -m pyro_mcp.cli prepare --help >/dev/null + uv run python -m pyro_mcp.cli host --help >/dev/null + uv run python -m pyro_mcp.cli host doctor >/dev/null + uv run python -m pyro_mcp.cli mcp --help >/dev/null + uv run python -m pyro_mcp.cli run --help >/dev/null + uv run python -m pyro_mcp.cli env list >/dev/null + uv run python -m pyro_mcp.cli env inspect debian:12 >/dev/null + uv run python -m pyro_mcp.cli doctor --environment debian:12 >/dev/null pypi-publish: @if [ -z "$$TWINE_PASSWORD" ]; then \ @@ -113,6 +119,9 @@ network-demo: doctor: uv run pyro doctor +smoke-daily-loop: + uv run python scripts/daily_loop_smoke.py --environment "$(DAILY_LOOP_ENVIRONMENT)" + smoke-use-cases: uv run python scripts/workspace_use_case_smoke.py --scenario all --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS) diff --git a/README.md b/README.md index 793a7b3..06956d1 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,50 @@ # pyro-mcp -`pyro-mcp` is a stable agent workspace product for one-shot commands and persistent work inside ephemeral Firecracker microVMs using curated Linux environments such as `debian:12`. +`pyro-mcp` is a disposable MCP workspace for chat-based coding agents such as +Claude Code, Codex, and OpenCode. + +It is built for Linux `x86_64` hosts with working KVM. The product path is: + +1. prove the host works +2. connect a chat host over MCP +3. let the agent work inside a disposable workspace +4. validate the workflow with the recipe-backed smoke pack + +`pyro-mcp` currently has no users. Expect breaking changes while this chat-host +path is still being shaped. + +This repo is not trying to be a generic VM toolkit, a CI runner, or an +SDK-first platform. [![PyPI version](https://img.shields.io/pypi/v/pyro-mcp.svg)](https://pypi.org/project/pyro-mcp/) -This is for coding agents, MCP clients, and developers who want isolated command execution and stable disposable workspaces in ephemeral microVMs. - -It exposes the same runtime in three public forms: - -- the `pyro` CLI -- the Python SDK via `from pyro_mcp import Pyro` -- an MCP server so LLM clients can call VM tools directly - ## Start Here -- Install: [docs/install.md](docs/install.md) -- Vision: [docs/vision.md](docs/vision.md) -- Workspace GA roadmap: [docs/roadmap/task-workspace-ga.md](docs/roadmap/task-workspace-ga.md) -- LLM chat roadmap: [docs/roadmap/llm-chat-ergonomics.md](docs/roadmap/llm-chat-ergonomics.md) -- Use-case recipes: [docs/use-cases/README.md](docs/use-cases/README.md) +- Install and zero-to-hero path: [docs/install.md](docs/install.md) - First run transcript: [docs/first-run.md](docs/first-run.md) +- Chat host integrations: [docs/integrations.md](docs/integrations.md) +- Use-case recipes: [docs/use-cases/README.md](docs/use-cases/README.md) +- Vision: [docs/vision.md](docs/vision.md) +- Public contract: [docs/public-contract.md](docs/public-contract.md) +- Host requirements: [docs/host-requirements.md](docs/host-requirements.md) +- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md) - Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif) - Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif) +- What's new in 4.5.0: [CHANGELOG.md#450](CHANGELOG.md#450) - PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/) -- What's new in 4.0.0: [CHANGELOG.md#400](CHANGELOG.md#400) -- 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) -- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md) -- Changelog: [CHANGELOG.md](CHANGELOG.md) + +## Who It's For + +- Claude Code users who want disposable workspaces instead of running directly + on the host +- Codex users who want an MCP-backed sandbox for repo setup, bug fixing, and + evaluation loops +- OpenCode users who want the same disposable workspace model +- people evaluating repo setup, test, and app-start workflows from a chat + interface on a clean machine + +If you want a general VM platform, a queueing system, or a broad SDK product, +this repo is intentionally biased away from that story. ## Quickstart @@ -38,8 +54,7 @@ Use either of these equivalent quickstart paths: # Package without install python -m pip install uv uvx --from pyro-mcp pyro doctor -uvx --from pyro-mcp pyro env list -uvx --from pyro-mcp pyro env pull debian:12 +uvx --from pyro-mcp pyro prepare debian:12 uvx --from pyro-mcp pyro run debian:12 -- git --version ``` @@ -48,8 +63,7 @@ uvx --from pyro-mcp pyro run debian:12 -- git --version ```bash # Already installed pyro doctor -pyro env list -pyro env pull debian:12 +pyro prepare debian:12 pyro run debian:12 -- git --version ``` @@ -60,7 +74,7 @@ What success looks like: ```bash Platform: linux-x86_64 Runtime: PASS -Catalog version: 4.0.0 +Catalog version: 4.4.0 ... [pull] phase=install environment=debian:12 [pull] phase=ready environment=debian:12 @@ -73,89 +87,76 @@ Pulled: debian:12 git version ... ``` -The first pull downloads an OCI environment from public Docker Hub, requires outbound HTTPS -access to `registry-1.docker.io`, and needs local cache space for the guest image. - -## Stable Workspace Path - -`pyro run` is the stable one-shot entrypoint. `pyro workspace ...` is the stable path when an -agent needs one sandbox to stay alive across repeated commands, shells, services, checkpoints, -diffs, exports, and reset. - -After that stable walkthrough works, continue with the recipe set in -[docs/use-cases/README.md](docs/use-cases/README.md). It packages the five core workspace stories -into documented flows plus real guest-backed smoke targets such as `make smoke-use-cases` and -`make smoke-repro-fix-loop`. At this point `make smoke-use-cases` is the -trustworthy guest-backed release-gate path for the advertised workspace workflows. - -The commands below use plain `pyro ...`. Run the same flow with `uvx --from pyro-mcp pyro ...` -for the published package, or `uv run pyro ...` from a source checkout. - -```bash -uv tool install pyro-mcp -WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --id-only)" -pyro workspace list -pyro workspace update "$WORKSPACE_ID" --label owner=codex -pyro workspace sync push "$WORKSPACE_ID" ./changes -pyro workspace file read "$WORKSPACE_ID" note.txt --content-only -pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch -pyro workspace exec "$WORKSPACE_ID" -- cat note.txt -pyro workspace snapshot create "$WORKSPACE_ID" checkpoint -pyro workspace service start "$WORKSPACE_ID" web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done' -pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint -pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt -pyro workspace delete "$WORKSPACE_ID" -``` - -![Stable workspace walkthrough](docs/assets/workspace-first-run.gif) - -That stable workspace path gives you: - -- initial host-in seeding with `--seed-path` -- discovery metadata with `--name`, `--label`, `workspace list`, and `workspace update` -- later host-in updates with `workspace sync push` -- model-native file inspection and text edits with `workspace file *` and `workspace patch apply` -- one-shot commands with `workspace exec` and persistent PTYs with `workspace shell *` -- long-running processes with `workspace service *` -- explicit checkpoints with `workspace snapshot *` -- full-sandbox recovery with `workspace reset` -- baseline comparison with `workspace diff` -- explicit host-out export with `workspace export` -- secondary stopped-workspace disk inspection with `workspace stop|start` and `workspace disk *` - -After the quickstart works: - -- prove the full one-shot lifecycle with `uvx --from pyro-mcp pyro demo` -- start most chat hosts with `uvx --from pyro-mcp pyro mcp serve` -- create a persistent workspace with `uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo` -- add a human-friendly workspace name with `uvx --from pyro-mcp pyro workspace create debian:12 --name repro-fix --label issue=123` -- rediscover or retag workspaces with `uvx --from pyro-mcp pyro workspace list` and `uvx --from pyro-mcp pyro workspace update WORKSPACE_ID --label owner=codex` -- update a live workspace from the host with `uvx --from pyro-mcp pyro workspace sync push WORKSPACE_ID ./changes` -- enable outbound guest networking for one workspace with `uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress` -- add literal or file-backed secrets with `uvx --from pyro-mcp pyro workspace create debian:12 --secret API_TOKEN=expected --secret-file PIP_TOKEN=./token.txt` -- map one persisted secret into one exec, shell, or service call with `--secret-env API_TOKEN` -- inspect and edit files without shell quoting with `uvx --from pyro-mcp pyro workspace file read WORKSPACE_ID src/app.py --content-only`, `uvx --from pyro-mcp pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py`, and `uvx --from pyro-mcp pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch` -- diff the live workspace against its create-time baseline with `uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID` -- capture a checkpoint with `uvx --from pyro-mcp pyro workspace snapshot create WORKSPACE_ID checkpoint` -- reset a broken workspace with `uvx --from pyro-mcp pyro workspace reset WORKSPACE_ID --snapshot checkpoint` -- 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 --id-only` -- 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'` -- publish one guest service port to the host with `uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress+published-ports` and `uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app` -- stop a workspace for offline inspection with `uvx --from pyro-mcp pyro workspace stop WORKSPACE_ID` -- inspect or export one stopped guest rootfs with `uvx --from pyro-mcp pyro workspace disk list WORKSPACE_ID`, `uvx --from pyro-mcp pyro workspace disk read WORKSPACE_ID note.txt --content-only`, and `uvx --from pyro-mcp pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4` -- move to Python or MCP via [docs/integrations.md](docs/integrations.md) +The first pull downloads an OCI environment from public Docker Hub, requires +outbound HTTPS access to `registry-1.docker.io`, and needs local cache space +for the guest image. `pyro prepare debian:12` performs that install step +automatically, then proves create, exec, reset, and delete on one throwaway +workspace so the daily loop is warm before the chat host connects. ## Chat Host Quickstart -For most MCP chat hosts, bare `pyro mcp serve` now starts `workspace-core`. It exposes the practical -persistent editing loop without shells, services, snapshots, secrets, network -policy, or disk tools. +After the quickstart works, make the daily loop explicit before you connect the +chat host: + +```bash +uvx --from pyro-mcp pyro doctor --environment debian:12 +uvx --from pyro-mcp pyro prepare debian:12 +``` + +Then connect a chat host in one named mode. Use the helper flow first: + +```bash +uvx --from pyro-mcp pyro host connect codex --mode repro-fix +uvx --from pyro-mcp pyro host connect codex --mode inspect +uvx --from pyro-mcp pyro host connect claude-code --mode cold-start +uvx --from pyro-mcp pyro host connect claude-code --mode review-eval +uvx --from pyro-mcp pyro host print-config opencode --mode repro-fix +``` + +If setup drifts or you want to inspect it first: + +```bash +uvx --from pyro-mcp pyro host doctor +uvx --from pyro-mcp pyro host repair claude-code +uvx --from pyro-mcp pyro host repair codex +uvx --from pyro-mcp pyro host repair opencode +``` + +Those helpers wrap the same `pyro mcp serve` entrypoint. Use a named mode when +one workflow already matches the job. Fall back to the generic no-mode path +when the mode feels too narrow. + +Mode examples: + +```bash +uvx --from pyro-mcp pyro mcp serve --mode repro-fix +uvx --from pyro-mcp pyro mcp serve --mode inspect +uvx --from pyro-mcp pyro mcp serve --mode cold-start +uvx --from pyro-mcp pyro mcp serve --mode review-eval +``` + +Generic escape hatch: ```bash uvx --from pyro-mcp pyro mcp serve ``` +From a repo root, the generic path auto-detects the current Git checkout and +lets the first `workspace_create` omit `seed_path`. If the host does not +preserve the server working directory, use: + +```bash +uvx --from pyro-mcp pyro host connect codex --project-path /abs/path/to/repo +uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo +``` + +If you are starting outside a local checkout, use a clean clone source: + +```bash +uvx --from pyro-mcp pyro host connect codex --repo-url https://github.com/example/project.git +uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git +``` + Copy-paste host-specific starts: - Claude Code: [examples/claude_code_mcp.md](examples/claude_code_mcp.md) @@ -163,16 +164,16 @@ Copy-paste host-specific starts: - OpenCode: [examples/opencode_mcp_config.json](examples/opencode_mcp_config.json) - Generic MCP config: [examples/mcp_client_config.md](examples/mcp_client_config.md) -Claude Code: +Claude Code cold-start or review-eval: ```bash -claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve +claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start ``` -Codex: +Codex repro-fix or inspect: ```bash -codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve +codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix ``` OpenCode `opencode.json` snippet: @@ -183,229 +184,72 @@ OpenCode `opencode.json` snippet: "pyro": { "type": "local", "enabled": true, - "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"] + "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"] } } } ``` -If `pyro-mcp` is already installed, replace the `uvx --from pyro-mcp pyro` -command with `pyro` in the same host-specific command or config shape. Use -`--profile workspace-full` only when the host truly needs the full advanced -workspace surface. +If OpenCode launches the server from an unexpected cwd, use +`pyro host print-config opencode --project-path /abs/path/to/repo` or add +`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same command +array. -Profile progression: +If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with +`pyro` in the same command or config shape. -- `workspace-core`: default and recommended first profile for normal persistent chat editing -- `vm-run`: smallest one-shot-only surface -- `workspace-full`: explicit advanced opt-in when the chat truly needs shells, services, snapshots, secrets, network policy, or disk tools +Use the generic no-mode path when the named mode feels too narrow. Move to +`--profile workspace-full` only when the chat truly needs shells, services, +snapshots, secrets, network policy, or disk tools. -## Supported Hosts +## Zero To Hero -Supported today: +1. Validate the host with `pyro doctor`. +2. Warm the machine-level daily loop with `pyro prepare debian:12`. +3. Prove guest execution with `pyro run debian:12 -- git --version`. +4. Connect Claude Code, Codex, or OpenCode with one named mode such as + `pyro host connect codex --mode repro-fix`, then fall back to raw + `pyro mcp serve --mode ...` or the generic no-mode path when needed. +5. Start with one recipe from [docs/use-cases/README.md](docs/use-cases/README.md). + `repro-fix` is the shortest chat-first mode and story. +6. Use `workspace reset` as the normal retry step inside that warmed loop. +7. Use `make smoke-use-cases` as the trustworthy guest-backed verification path + for the advertised workflows. -- Linux x86_64 -- Python 3.12+ -- `uv` -- `/dev/kvm` +That is the intended user journey. The terminal commands exist to validate and +debug that chat-host path, not to replace it as the main product story. -Optional for outbound guest networking: +## Manual Terminal Workspace Flow -- `ip` -- `nft` or `iptables` -- privilege to create TAP devices and configure NAT - -Not supported today: - -- macOS -- Windows -- Linux hosts without working KVM at `/dev/kvm` - -## Detailed Walkthrough - -If you want the expanded version of the canonical quickstart, use the step-by-step flow below. - -### 1. Check the host +If you want to understand what the agent gets inside the sandbox, or debug a +recipe outside the chat host, use the terminal companion flow below: ```bash -uvx --from pyro-mcp pyro doctor +uv tool install pyro-mcp +WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --id-only)" +pyro workspace list +pyro workspace sync push "$WORKSPACE_ID" ./changes +pyro workspace file read "$WORKSPACE_ID" note.txt --content-only +pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch +pyro workspace exec "$WORKSPACE_ID" -- cat note.txt +pyro workspace summary "$WORKSPACE_ID" +pyro workspace snapshot create "$WORKSPACE_ID" checkpoint +pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint +pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt +pyro workspace delete "$WORKSPACE_ID" ``` -Expected success signals: +Add `workspace-full` only when the chat or your manual debugging loop really +needs: -```bash -Platform: linux-x86_64 -Runtime: PASS -KVM: exists=yes readable=yes writable=yes -Environment cache: /home/you/.cache/pyro-mcp/environments -Capabilities: vm_boot=yes guest_exec=yes guest_network=yes -Networking: tun=yes ip_forward=yes -``` +- persistent PTY shells +- long-running services and readiness probes +- guest networking and published ports +- secrets +- stopped-workspace disk inspection -### 2. Inspect the catalog - -```bash -uvx --from pyro-mcp pyro env list -``` - -Expected output: - -```bash -Catalog version: 4.0.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. -``` - -### 3. Pull the default environment - -```bash -uvx --from pyro-mcp pyro env pull debian:12 -``` - -The first pull downloads an OCI environment from public Docker Hub, requires outbound HTTPS -access to `registry-1.docker.io`, and needs local cache space for the guest image. -See [docs/host-requirements.md](docs/host-requirements.md) for the full host requirements. - -### 4. Run one command in a guest - -```bash -uvx --from pyro-mcp pyro run debian:12 -- git --version -``` - -Expected success signals: - -```bash -[run] environment=debian:12 execution_mode=guest_vsock exit_code=0 duration_ms=... -git version ... -``` - -The guest command output and the `[run] ...` summary are written to different streams, so they -may appear in either order in terminals or capture tools. Use `--json` if you need a -deterministic structured result. - -### 5. Optional demos - -```bash -uvx --from pyro-mcp pyro demo -uvx --from pyro-mcp pyro demo --network -``` - -`pyro demo` proves the one-shot create/start/exec/delete VM lifecycle works end to end. - -Example output: - -```json -{ - "cleanup": { - "deleted": true, - "reason": "post_exec_cleanup", - "vm_id": "..." - }, - "command": "git --version", - "environment": "debian:12", - "execution_mode": "guest_vsock", - "exit_code": 0, - "stdout": "git version ...\n" -} -``` - -When you are done evaluating and want to remove stale cached environments, run `pyro env prune`. - -If you prefer a fuller copy-pasteable transcript, see [docs/first-run.md](docs/first-run.md). -The walkthrough GIF above was rendered from [docs/assets/first-run.tape](docs/assets/first-run.tape) using [scripts/render_tape.sh](scripts/render_tape.sh). - -## Stable Workspaces - -Use `pyro run` for one-shot commands. Use `pyro workspace ...` when you need repeated commands in one -workspace without recreating the sandbox every time. - -The project direction is an agent workspace, not a CI job runner. Persistent -workspaces are meant to let an agent stay inside one bounded sandbox across multiple -steps. See [docs/vision.md](docs/vision.md) for the product thesis and the -longer-term interaction model. - -```bash -pyro workspace create debian:12 --seed-path ./repo -pyro workspace create debian:12 --network-policy egress -pyro workspace create debian:12 --seed-path ./repo --secret API_TOKEN=expected -pyro workspace create debian:12 --network-policy egress+published-ports -pyro workspace sync push WORKSPACE_ID ./changes --dest src -pyro workspace file list WORKSPACE_ID src --recursive -pyro workspace file read WORKSPACE_ID src/note.txt --content-only -pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py -pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch -pyro workspace exec WORKSPACE_ID -- cat src/note.txt -pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"' -pyro workspace diff WORKSPACE_ID -pyro workspace snapshot create WORKSPACE_ID checkpoint -pyro workspace reset WORKSPACE_ID --snapshot checkpoint -pyro workspace reset WORKSPACE_ID -pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt -pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN --id-only -pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd' -pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300 -pyro workspace shell close WORKSPACE_ID SHELL_ID -pyro workspace service start WORKSPACE_ID web --secret-env API_TOKEN --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 start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app -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 stop WORKSPACE_ID -pyro workspace disk list WORKSPACE_ID -pyro workspace disk read WORKSPACE_ID src/note.txt --content-only -pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4 -pyro workspace start WORKSPACE_ID -pyro workspace logs WORKSPACE_ID -pyro workspace delete WORKSPACE_ID -``` - -Persistent workspaces start in `/workspace` and keep command history until you delete them. For -machine consumption, use `--id-only` for only the identifier or `--json` for the full -workspace payload. 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 `4.0.0`; if it fails -partway through, prefer `pyro workspace reset` to recover from `baseline` or one named snapshot. -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 snapshot *` and `pyro workspace reset` when you want explicit checkpoints and -full-sandbox recovery. 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. Prefer -`pyro workspace shell read --plain --wait-for-idle-ms 300` for chat-facing shell reads. 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`. -Use `--network-policy egress` when the workspace needs outbound guest networking, and -`--network-policy egress+published-ports` plus `workspace service start --publish` when one -service must be probed from the host on `127.0.0.1`. -Use `--secret` and `--secret-file` at workspace creation when the sandbox needs private tokens or -config. Persisted secrets are materialized inside the guest at `/run/pyro-secrets/`, and -`--secret-env SECRET_NAME[=ENV_VAR]` maps one secret into one exec, shell, or service call without -exposing the raw value in workspace status, logs, diffs, or exports. Use `pyro workspace stop` -plus `pyro workspace disk list|read|export` when you need offline inspection or one raw ext4 copy -from a stopped guest-backed workspace, then `pyro workspace start` to resume the same workspace. - -## Public Interfaces - -The public user-facing interface is `pyro` and `Pyro`. After the CLI validation path works, you can choose one of three surfaces: - -- `pyro` for direct CLI usage, including one-shot `run` and persistent `workspace` workflows -- `from pyro_mcp import Pyro` for Python orchestration -- `pyro mcp serve` for MCP clients - -Command forms: - -- published package without install: `uvx --from pyro-mcp pyro ...` -- installed package: `pyro ...` -- source checkout: `uv run pyro ...` - -`Makefile` targets are contributor conveniences for this repository and are not the primary product UX. +The five recipe docs show when those capabilities are justified: +[docs/use-cases/README.md](docs/use-cases/README.md) ## Official Environments @@ -415,216 +259,10 @@ Current official environments in the shipped catalog: - `debian:12-base` - `debian:12-build` -The package ships the embedded Firecracker runtime and a package-controlled environment catalog. -Official environments are pulled as OCI artifacts from public Docker Hub repositories into a local -cache on first use or through `pyro env pull`. -End users do not need registry credentials to pull or run official environments. -The default cache location is `~/.cache/pyro-mcp/environments`; override it with -`PYRO_ENVIRONMENT_CACHE_DIR`. - -## CLI - -List available environments: - -```bash -pyro env list -``` - -Prefetch one environment: - -```bash -pyro env pull debian:12 -``` - -Run one command in an ephemeral VM: - -```bash -pyro run debian:12 -- git --version -``` - -Run with outbound internet enabled: - -```bash -pyro run debian:12 --network -- \ - 'python3 -c "import urllib.request; print(urllib.request.urlopen(\"https://example.com\", timeout=10).status)"' -``` - -Show runtime and host diagnostics: - -```bash -pyro doctor -pyro doctor --json -``` - -`pyro run` defaults to `1 vCPU / 1024 MiB`. -It fails closed when guest boot or guest exec is unavailable. -Use `--allow-host-compat` only if you explicitly want host execution. - -Run the MCP server after the CLI path above works. Start most chat hosts with -`workspace-core`: - -```bash -pyro mcp serve -``` - -Profile progression for chat hosts: - -- `workspace-core`: recommended first profile for normal persistent chat editing -- `vm-run`: expose only `vm_run` for one-shot-only hosts -- `workspace-full`: expose shells, services, snapshots, secrets, network policy, and disk tools when the chat truly needs the full stable surface - -Run the deterministic demo: - -```bash -pyro demo -pyro demo --network -``` - -Run the Ollama demo: - -```bash -ollama serve -ollama pull llama3.2:3b -pyro demo ollama -``` - -## Python SDK - -```python -from pyro_mcp import Pyro - -pyro = Pyro() -result = pyro.run_in_vm( - environment="debian:12", - command="git --version", - timeout_seconds=30, - network=False, -) -print(result["stdout"]) -``` - -Lower-level lifecycle control remains available: - -```python -from pyro_mcp import Pyro - -pyro = Pyro() -created = pyro.create_vm( - environment="debian:12", - ttl_seconds=600, - network=True, -) -vm_id = created["vm_id"] -pyro.start_vm(vm_id) -result = pyro.exec_vm(vm_id, command="git --version", timeout_seconds=30) -print(result["stdout"]) -``` - -`exec_vm()` is a one-command auto-cleaning call. After it returns, the VM is already deleted. - -Environment management is also available through the SDK: - -```python -from pyro_mcp import Pyro - -pyro = Pyro() -print(pyro.list_environments()) -print(pyro.inspect_environment("debian:12")) -``` - -For repeated commands in one workspace: - -```python -from pyro_mcp import Pyro - -pyro = Pyro() -workspace = pyro.create_workspace(environment="debian:12", seed_path="./repo") -workspace_id = workspace["workspace_id"] -try: - pyro.push_workspace_sync(workspace_id, "./changes", dest="src") - result = pyro.exec_workspace(workspace_id, command="cat src/note.txt") - print(result["stdout"], end="") -finally: - pyro.delete_workspace(workspace_id) -``` - -## MCP Tools - -Primary agent-facing tool: - -- `vm_run(environment, command, vcpu_count=1, mem_mib=1024, timeout_seconds=30, ttl_seconds=600, network=false, allow_host_compat=false)` - -Advanced lifecycle tools: - -- `vm_list_environments()` -- `vm_create(environment, vcpu_count=1, mem_mib=1024, ttl_seconds=600, network=false, allow_host_compat=false)` -- `vm_start(vm_id)` -- `vm_exec(vm_id, command, timeout_seconds=30)` auto-cleans the VM after that command -- `vm_stop(vm_id)` -- `vm_delete(vm_id)` -- `vm_status(vm_id)` -- `vm_network_info(vm_id)` -- `vm_reap_expired()` - -Persistent workspace tools: - -- `workspace_create(environment, vcpu_count=1, mem_mib=1024, ttl_seconds=600, network_policy="off", allow_host_compat=false, seed_path=null, secrets=null)` -- `workspace_sync_push(workspace_id, source_path, dest="/workspace")` -- `workspace_exec(workspace_id, command, timeout_seconds=30, secret_env=null)` -- `workspace_export(workspace_id, path, output_path)` -- `workspace_diff(workspace_id)` -- `snapshot_create(workspace_id, snapshot_name)` -- `snapshot_list(workspace_id)` -- `snapshot_delete(workspace_id, snapshot_name)` -- `workspace_reset(workspace_id, snapshot="baseline")` -- `service_start(workspace_id, service_name, command, cwd="/workspace", readiness=null, ready_timeout_seconds=30, ready_interval_ms=500, secret_env=null, published_ports=null)` -- `service_list(workspace_id)` -- `service_status(workspace_id, service_name)` -- `service_logs(workspace_id, service_name, tail_lines=200)` -- `service_stop(workspace_id, service_name)` -- `shell_open(workspace_id, cwd="/workspace", cols=120, rows=30, secret_env=null)` -- `shell_read(workspace_id, shell_id, cursor=0, max_chars=65536, plain=False, wait_for_idle_ms=None)` -- `shell_write(workspace_id, shell_id, input, append_newline=true)` -- `shell_signal(workspace_id, shell_id, signal_name="INT")` -- `shell_close(workspace_id, shell_id)` -- `workspace_status(workspace_id)` -- `workspace_logs(workspace_id)` -- `workspace_delete(workspace_id)` - -Recommended MCP tool profiles: - -- `vm-run`: `vm_run` only -- `workspace-core`: `vm_run`, `workspace_create`, `workspace_list`, `workspace_update`, `workspace_status`, `workspace_sync_push`, `workspace_exec`, `workspace_logs`, `workspace_file_list`, `workspace_file_read`, `workspace_file_write`, `workspace_patch_apply`, `workspace_diff`, `workspace_export`, `workspace_reset`, `workspace_delete` -- `workspace-full`: the complete stable MCP surface above - -## Integration Examples - -- Python one-shot SDK example: [examples/python_run.py](examples/python_run.py) -- Python lifecycle example: [examples/python_lifecycle.py](examples/python_lifecycle.py) -- Python workspace example: [examples/python_workspace.py](examples/python_workspace.py) -- Claude Code MCP setup: [examples/claude_code_mcp.md](examples/claude_code_mcp.md) -- Codex MCP setup: [examples/codex_mcp.md](examples/codex_mcp.md) -- OpenCode MCP config: [examples/opencode_mcp_config.json](examples/opencode_mcp_config.json) -- Generic MCP client config: [examples/mcp_client_config.md](examples/mcp_client_config.md) -- Claude Desktop MCP config: [examples/claude_desktop_mcp_config.json](examples/claude_desktop_mcp_config.json) -- Cursor MCP config: [examples/cursor_mcp_config.json](examples/cursor_mcp_config.json) -- OpenAI Responses API example: [examples/openai_responses_vm_run.py](examples/openai_responses_vm_run.py) -- OpenAI Responses `workspace-core` example: [examples/openai_responses_workspace_core.py](examples/openai_responses_workspace_core.py) -- LangChain wrapper example: [examples/langchain_vm_run.py](examples/langchain_vm_run.py) -- Agent-ready `vm_run` example: [examples/agent_vm_run.py](examples/agent_vm_run.py) - -## Runtime - -The package ships an embedded Linux x86_64 runtime payload with: - -- Firecracker -- Jailer -- guest agent -- runtime manifest and diagnostics - -No system Firecracker installation is required. -`pyro` installs curated environments into a local cache and reports their status through `pyro env inspect` and `pyro doctor`. -The public CLI is human-readable by default; add `--json` for structured output. +The embedded Firecracker runtime ships with the package. Official environments +are pulled as OCI artifacts from public Docker Hub into a local cache on first +use or through `pyro env pull`. End users do not need registry credentials to +pull or run the official environments. ## Contributor Workflow @@ -637,11 +275,12 @@ make check make dist-check ``` -Contributor runtime sources live under `runtime_sources/`. The packaged runtime bundle under -`src/pyro_mcp/runtime_bundle/` contains the embedded boot/runtime assets plus manifest metadata; -end-user environment installs pull OCI-published environments by default. Use -`PYRO_RUNTIME_BUNDLE_DIR=build/runtime_bundle` only when you are explicitly validating a locally -built contributor runtime bundle. +Contributor runtime sources live under `runtime_sources/`. The packaged runtime +bundle under `src/pyro_mcp/runtime_bundle/` contains the embedded boot/runtime +assets plus manifest metadata. End-user environment installs pull +OCI-published environments by default. Use +`PYRO_RUNTIME_BUNDLE_DIR=build/runtime_bundle` only when you are explicitly +validating a locally built contributor runtime bundle. Official environment publication is performed locally against Docker Hub: @@ -652,20 +291,9 @@ make runtime-materialize make runtime-publish-official-environments-oci ``` -`make runtime-publish-environment-oci` auto-exports the OCI layout for the selected -environment if it is missing. -The publisher accepts either `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` or -`OCI_REGISTRY_USERNAME` and `OCI_REGISTRY_PASSWORD`. -Docker Hub uploads are chunked by default for large rootfs layers; if you need to tune a slow -link, use `PYRO_OCI_UPLOAD_TIMEOUT_SECONDS`, `PYRO_OCI_UPLOAD_CHUNK_SIZE_BYTES`, and -`PYRO_OCI_REQUEST_TIMEOUT_SECONDS`. - For a local PyPI publish: ```bash export TWINE_PASSWORD='pypi-...' make pypi-publish ``` - -`make pypi-publish` defaults `TWINE_USERNAME` to `__token__`. -Set `PYPI_REPOSITORY_URL=https://test.pypi.org/legacy/` to publish to TestPyPI instead. diff --git a/docs/first-run.md b/docs/first-run.md index 842bb59..04af501 100644 --- a/docs/first-run.md +++ b/docs/first-run.md @@ -1,28 +1,36 @@ # First Run Transcript -This is the intended evaluator path for a first successful run on a supported host. +This is the intended evaluator-to-chat-host path for a first successful run on +a supported host. + Copy the commands as-is. Paths and timing values will differ on your machine. The same sequence works with an installed `pyro` binary by dropping the -`uvx --from pyro-mcp` prefix. If you are running from a source checkout instead -of the published package, replace `pyro` with `uv run pyro`. +`uvx --from pyro-mcp` prefix. If you are running from a source checkout +instead of the published package, replace `pyro` with `uv run pyro`. + +`pyro-mcp` currently has no users. Expect breaking changes while the chat-host +path is still being shaped. ## 1. Verify the host ```bash -$ uvx --from pyro-mcp pyro doctor +$ uvx --from pyro-mcp pyro doctor --environment debian:12 Platform: linux-x86_64 Runtime: PASS KVM: exists=yes readable=yes writable=yes Environment cache: /home/you/.cache/pyro-mcp/environments +Catalog version: 4.5.0 Capabilities: vm_boot=yes guest_exec=yes guest_network=yes Networking: tun=yes ip_forward=yes +Daily loop: COLD (debian:12) + Run: pyro prepare debian:12 ``` ## 2. Inspect the catalog ```bash $ uvx --from pyro-mcp pyro env list -Catalog version: 4.0.0 +Catalog version: 4.4.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. @@ -30,9 +38,10 @@ debian:12-build [installed|not installed] Debian 12 environment with Git and com ## 3. Pull the default environment -The first pull downloads an OCI environment from public Docker Hub, requires outbound HTTPS -access to `registry-1.docker.io`, and needs local cache space for the guest image. See -[host-requirements.md](host-requirements.md) for the full host requirements. +The first pull downloads an OCI environment from public Docker Hub, requires +outbound HTTPS access to `registry-1.docker.io`, and needs local cache space +for the guest image. See [host-requirements.md](host-requirements.md) for the +full host requirements. ```bash $ uvx --from pyro-mcp pyro env pull debian:12 @@ -45,9 +54,6 @@ Installed: yes Cache dir: /home/you/.cache/pyro-mcp/environments Default packages: bash, coreutils, git Install dir: /home/you/.cache/pyro-mcp/environments/linux-x86_64/debian_12-1.0.0 -Install manifest: /home/you/.cache/pyro-mcp/environments/linux-x86_64/debian_12-1.0.0/environment.json -Kernel image: /home/you/.cache/pyro-mcp/environments/linux-x86_64/debian_12-1.0.0/vmlinux -Rootfs image: /home/you/.cache/pyro-mcp/environments/linux-x86_64/debian_12-1.0.0/rootfs.ext4 OCI source: registry-1.docker.io/thalesmaciel/pyro-environment-debian-12:1.0.0 ``` @@ -62,239 +68,152 @@ $ uvx --from pyro-mcp pyro run debian:12 -- git --version git version ... ``` -The guest command output and the `[run] ...` summary are written to different streams, so they -may appear in either order in terminals or capture tools. Use `--json` if you need a -deterministic structured result. +The guest command output and the `[run] ...` summary are written to different +streams, so they may appear in either order in terminals or capture tools. Use +`--json` if you need a deterministic structured result. -## 5. Continue into the stable workspace path +## 5. Start the MCP server -The commands below use the published-package form. The same stable workspace path works with an -installed `pyro` binary by dropping the `uvx --from pyro-mcp` prefix, or with `uv run pyro` from -a source checkout. +Warm the daily loop first so the host is already ready for repeated create and +reset cycles: + +```bash +$ uvx --from pyro-mcp pyro prepare debian:12 +Prepare: debian:12 +Daily loop: WARM +Result: prepared network_prepared=no +``` + +Use a named mode when one workflow already matches the job: + +```bash +$ uvx --from pyro-mcp pyro mcp serve --mode repro-fix +$ uvx --from pyro-mcp pyro mcp serve --mode inspect +$ uvx --from pyro-mcp pyro mcp serve --mode cold-start +$ uvx --from pyro-mcp pyro mcp serve --mode review-eval +``` + +Use the generic no-mode path when the mode feels too narrow. Bare +`pyro mcp serve` still starts `workspace-core`. From a repo root, it also +auto-detects the current Git checkout so the first `workspace_create` can omit +`seed_path`: + +```bash +$ uvx --from pyro-mcp pyro mcp serve +``` + +If the host does not preserve the server working directory: + +```bash +$ uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo +``` + +If you are outside a local checkout: + +```bash +$ uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git +``` + +## 6. Connect a chat host + +Use the helper flow first: + +```bash +$ uvx --from pyro-mcp pyro host connect codex --mode repro-fix +$ uvx --from pyro-mcp pyro host connect codex --mode inspect +$ uvx --from pyro-mcp pyro host connect claude-code --mode cold-start +$ uvx --from pyro-mcp pyro host connect claude-code --mode review-eval +$ uvx --from pyro-mcp pyro host print-config opencode --mode repro-fix +``` + +If setup drifts later: + +```bash +$ uvx --from pyro-mcp pyro host doctor +$ uvx --from pyro-mcp pyro host repair claude-code +$ uvx --from pyro-mcp pyro host repair codex +$ uvx --from pyro-mcp pyro host repair opencode +``` + +Claude Code cold-start or review-eval: + +```bash +$ uvx --from pyro-mcp pyro host connect claude-code --mode cold-start +$ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start +$ claude mcp list +``` + +Codex repro-fix or inspect: + +```bash +$ uvx --from pyro-mcp pyro host connect codex --mode repro-fix +$ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix +$ codex mcp list +``` + +OpenCode uses the local config shape shown in: + +- [opencode_mcp_config.json](../examples/opencode_mcp_config.json) + +Other host-specific references: + +- [claude_code_mcp.md](../examples/claude_code_mcp.md) +- [codex_mcp.md](../examples/codex_mcp.md) +- [mcp_client_config.md](../examples/mcp_client_config.md) + +## 7. Continue into a real workflow + +Once the host is connected, move to one of the five recipe docs in +[use-cases/README.md](use-cases/README.md). + +The shortest chat-first mode and story is: + +- [use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md) + +If you want terminal-level visibility into what the agent gets, use the manual +workspace flow below: ```bash $ export WORKSPACE_ID="$(uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --id-only)" $ uvx --from pyro-mcp pyro workspace list -$ uvx --from pyro-mcp pyro workspace update "$WORKSPACE_ID" --label owner=codex $ uvx --from pyro-mcp pyro workspace sync push "$WORKSPACE_ID" ./changes $ uvx --from pyro-mcp pyro workspace file read "$WORKSPACE_ID" note.txt --content-only $ uvx --from pyro-mcp pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch $ uvx --from pyro-mcp pyro workspace exec "$WORKSPACE_ID" -- cat note.txt +$ uvx --from pyro-mcp pyro workspace summary "$WORKSPACE_ID" $ uvx --from pyro-mcp pyro workspace snapshot create "$WORKSPACE_ID" checkpoint -$ 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' $ uvx --from pyro-mcp pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint $ uvx --from pyro-mcp pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt -$ uvx --from pyro-mcp pyro workspace stop "$WORKSPACE_ID" -$ uvx --from pyro-mcp pyro workspace disk list "$WORKSPACE_ID" -$ uvx --from pyro-mcp pyro workspace disk read "$WORKSPACE_ID" note.txt --content-only -$ uvx --from pyro-mcp pyro workspace disk export "$WORKSPACE_ID" --output ./workspace.ext4 -$ uvx --from pyro-mcp pyro workspace start "$WORKSPACE_ID" $ uvx --from pyro-mcp pyro workspace delete "$WORKSPACE_ID" ``` -## 6. Optional one-shot demo and expanded workspace flow +Move to the generic no-mode path when the named mode is too narrow. Move to +`--profile workspace-full` only when the chat really needs shells, services, +snapshots, secrets, network policy, or disk tools. + +## 8. Trust the smoke pack + +The repo now treats the full smoke pack as the trustworthy guest-backed +verification path for the advertised workflows: + +```bash +$ make smoke-use-cases +``` + +That runner creates real guest-backed workspaces, exercises all five documented +stories, exports concrete results where relevant, and cleans up on both success +and failure. + +For the machine-level warmup plus retry story specifically: + +```bash +$ make smoke-daily-loop +``` + +## 9. Optional one-shot demo ```bash $ uvx --from pyro-mcp pyro demo -$ uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 -$ uvx --from pyro-mcp pyro workspace list -$ uvx --from pyro-mcp pyro workspace update WORKSPACE_ID --label owner=codex -$ uvx --from pyro-mcp pyro workspace sync push WORKSPACE_ID ./changes -$ uvx --from pyro-mcp pyro workspace file list WORKSPACE_ID src --recursive -$ uvx --from pyro-mcp pyro workspace file read WORKSPACE_ID src/app.py --content-only -$ uvx --from pyro-mcp pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py -$ uvx --from pyro-mcp pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch -$ uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress -$ uvx --from pyro-mcp pyro workspace create debian:12 --secret API_TOKEN=expected --secret-file PIP_TOKEN=./token.txt -$ uvx --from pyro-mcp pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"' -$ uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID -$ uvx --from pyro-mcp pyro workspace snapshot create WORKSPACE_ID checkpoint -$ uvx --from pyro-mcp pyro workspace reset WORKSPACE_ID --snapshot checkpoint -$ uvx --from pyro-mcp pyro workspace export WORKSPACE_ID note.txt --output ./note.txt -$ uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN --id-only -$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --secret-env API_TOKEN --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done' -$ uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress+published-ports -$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app -$ uvx --from pyro-mcp pyro mcp serve -$ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve -$ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve -``` - -For most chat hosts, bare `pyro mcp serve` now starts `workspace-core`, the -recommended first MCP profile. -Move to `workspace-full` only when the host truly needs shells, services, -snapshots, secrets, network policy, or disk tools. - -Host-specific MCP starts: - -- Claude Code: [examples/claude_code_mcp.md](../examples/claude_code_mcp.md) -- Codex: [examples/codex_mcp.md](../examples/codex_mcp.md) -- OpenCode: [examples/opencode_mcp_config.json](../examples/opencode_mcp_config.json) -- Generic MCP config: [examples/mcp_client_config.md](../examples/mcp_client_config.md) - -`pyro demo` proves the one-shot create/start/exec/delete VM lifecycle works end to end. - -Once that stable workspace flow works, continue with the five recipe docs in -[use-cases/README.md](use-cases/README.md) or run the real guest-backed smoke packs directly with -`make smoke-use-cases`. Treat that smoke pack as the trustworthy guest-backed -verification path for the advertised workspace workflows. - -When you need repeated commands in one sandbox, switch to `pyro workspace ...`: - -```bash -$ uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo -Workspace ID: ... -Environment: debian:12 -State: started -Workspace: /workspace -Workspace seed: directory from ... -Network policy: off -Execution mode: guest_vsock -Resources: 1 vCPU / 1024 MiB -Command count: 0 - -$ uvx --from pyro-mcp pyro workspace sync push WORKSPACE_ID ./changes --dest src -[workspace-sync] workspace_id=... mode=directory source=... destination=/workspace/src entry_count=... bytes_written=... execution_mode=guest_vsock - -$ uvx --from pyro-mcp pyro workspace file list WORKSPACE_ID src --recursive -Workspace file path: /workspace/src -- /workspace/src/note.txt [file] bytes=... - -$ uvx --from pyro-mcp pyro workspace file read WORKSPACE_ID src/note.txt -hello from synced workspace -[workspace-file-read] workspace_id=... path=/workspace/src/note.txt size_bytes=... truncated=False execution_mode=guest_vsock - -$ uvx --from pyro-mcp pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch - -$ uvx --from pyro-mcp pyro workspace exec WORKSPACE_ID -- cat src/note.txt -hello from synced workspace -[workspace-exec] workspace_id=... sequence=1 cwd=/workspace execution_mode=guest_vsock exit_code=0 duration_ms=... - -$ uvx --from pyro-mcp pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"' -[workspace-exec] workspace_id=... sequence=2 cwd=/workspace execution_mode=guest_vsock exit_code=0 duration_ms=... - -$ uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID -[workspace-diff] workspace_id=... total=... added=... modified=... deleted=... type_changed=... text_patched=... non_text=... ---- a/src/note.txt -+++ b/src/note.txt -@@ ... - -$ uvx --from pyro-mcp pyro workspace snapshot create WORKSPACE_ID checkpoint -[workspace-snapshot-create] snapshot_name=checkpoint kind=named entry_count=... bytes_written=... - -$ uvx --from pyro-mcp pyro workspace reset WORKSPACE_ID --snapshot checkpoint -Workspace reset from snapshot: checkpoint (named) -[workspace-reset] destination=/workspace entry_count=... bytes_written=... -Workspace ID: ... -State: started -Command count: 0 -Reset count: 1 - -$ uvx --from pyro-mcp pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt -[workspace-export] workspace_id=... workspace_path=/workspace/src/note.txt output_path=... artifact_type=file entry_count=... bytes_written=... execution_mode=guest_vsock - -$ uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN --id-only -[workspace-shell-open] workspace_id=... shell_id=... state=running cwd=/workspace cols=120 rows=30 execution_mode=guest_vsock - -$ uvx --from pyro-mcp pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd' -[workspace-shell-write] workspace_id=... shell_id=... state=running cwd=/workspace cols=120 rows=30 execution_mode=guest_vsock - -$ uvx --from pyro-mcp pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300 -/workspace -[workspace-shell-read] workspace_id=... shell_id=... state=running cursor=0 next_cursor=... truncated=False plain=True wait_for_idle_ms=300 execution_mode=guest_vsock - -$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID web --secret-env API_TOKEN --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 create debian:12 --network-policy egress+published-ports -Workspace ID: ... -Network policy: egress+published-ports -... - -$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app -[workspace-service-start] workspace_id=... service=app state=running cwd=/workspace ready_type=http execution_mode=guest_vsock published=127.0.0.1:18080->8080/tcp - -$ 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 - -$ uvx --from pyro-mcp pyro workspace stop WORKSPACE_ID -Workspace ID: ... -State: stopped - -$ uvx --from pyro-mcp pyro workspace disk list WORKSPACE_ID src --recursive -Workspace: ... -Path: /workspace/src -- /workspace/src [directory] -- /workspace/src/note.txt [file] bytes=... - -$ uvx --from pyro-mcp pyro workspace disk read WORKSPACE_ID src/note.txt -hello from synced workspace -[workspace-disk-read] workspace_id=... path=/workspace/src/note.txt size_bytes=... truncated=False - -$ uvx --from pyro-mcp pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4 -[workspace-disk-export] workspace_id=... output_path=... disk_format=ext4 bytes_written=... - -$ uvx --from pyro-mcp pyro workspace start WORKSPACE_ID -Workspace ID: ... -State: started -``` - -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 `4.0.0`; if it fails partway through, prefer `pyro workspace reset` -to recover from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the current -`/workspace` tree to its immutable create-time baseline, `pyro workspace snapshot *` to create -named checkpoints, and `pyro workspace export` to copy one changed file or directory back to the -host. Use `pyro workspace file *` and `pyro workspace patch apply` for model-native text edits, -`pyro workspace exec` for one-shot commands, and `pyro workspace shell *` when you -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. Use `--network-policy egress` for outbound guest networking, and -`--network-policy egress+published-ports` plus `workspace service start --publish` when one -service must be reachable from the host on `127.0.0.1`. Use `--secret` and `--secret-file` at -workspace creation when the sandbox needs private tokens or config. Persisted secret files are -materialized at `/run/pyro-secrets/`, and `--secret-env SECRET_NAME[=ENV_VAR]` maps one -secret into one exec, shell, or service call without storing that environment mapping on the -workspace itself. Use `pyro workspace stop` plus `pyro workspace disk list|read|export` when you -need offline inspection or one raw ext4 copy from a stopped guest-backed workspace, then -`pyro workspace start` to resume the same workspace. - -The stable workspace walkthrough GIF in the README is rendered from -[docs/assets/workspace-first-run.tape](assets/workspace-first-run.tape) with -[scripts/render_tape.sh](../scripts/render_tape.sh). - -Example output: - -```json { "cleanup": { "deleted": true, @@ -309,7 +228,5 @@ Example output: } ``` -When you are done evaluating and want to remove stale cached environments, run `pyro env prune`. - -If `pyro doctor` reports `Runtime: FAIL`, or if the `pyro run` summary does not show -`execution_mode=guest_vsock`, stop and use [troubleshooting.md](troubleshooting.md). +`pyro demo` proves the one-shot create/start/exec/delete VM lifecycle works end +to end. diff --git a/docs/install.md b/docs/install.md index 88f3d84..c809a35 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,11 +1,17 @@ # Install +`pyro-mcp` is built for chat-based coding agents on Linux `x86_64` with KVM. +This document is intentionally biased toward that path. + +`pyro-mcp` currently has no users. Expect breaking changes while the chat-host +flow is still being shaped. + ## Support Matrix Supported today: -- Linux x86_64 -- Python 3.12+ +- Linux `x86_64` +- Python `3.12+` - `uv` - `/dev/kvm` @@ -40,27 +46,27 @@ Use either of these equivalent evaluator paths: ```bash # Package without install uvx --from pyro-mcp pyro doctor -uvx --from pyro-mcp pyro env list -uvx --from pyro-mcp pyro env pull debian:12 +uvx --from pyro-mcp pyro prepare debian:12 uvx --from pyro-mcp pyro run debian:12 -- git --version ``` ```bash # Already installed pyro doctor -pyro env list -pyro env pull debian:12 +pyro prepare debian:12 pyro run debian:12 -- git --version ``` -If you are running from a repo checkout instead, replace `pyro` with `uv run pyro`. +If you are running from a repo checkout instead, replace `pyro` with +`uv run pyro`. -After that one-shot proof works, continue into the stable workspace path with `pyro workspace ...`. +After that one-shot proof works, the intended next step is a warmed daily loop +plus a named chat mode through `pyro host connect` or `pyro host print-config`. -### 1. Check the host first +## 1. Check the host ```bash -uvx --from pyro-mcp pyro doctor +uvx --from pyro-mcp pyro doctor --environment debian:12 ``` Expected success signals: @@ -70,13 +76,16 @@ Platform: linux-x86_64 Runtime: PASS KVM: exists=yes readable=yes writable=yes Environment cache: /home/you/.cache/pyro-mcp/environments +Catalog version: 4.5.0 Capabilities: vm_boot=yes guest_exec=yes guest_network=yes Networking: tun=yes ip_forward=yes +Daily loop: COLD (debian:12) + Run: pyro prepare debian:12 ``` If `Runtime: FAIL`, stop here and use [troubleshooting.md](troubleshooting.md). -### 2. Inspect the catalog +## 2. Inspect the catalog ```bash uvx --from pyro-mcp pyro env list @@ -85,21 +94,22 @@ uvx --from pyro-mcp pyro env list Expected output: ```bash -Catalog version: 4.0.0 +Catalog version: 4.4.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. ``` -### 3. Pull the default environment +## 3. Pull the default environment ```bash uvx --from pyro-mcp pyro env pull debian:12 ``` -The first pull downloads an OCI environment from public Docker Hub, requires outbound HTTPS -access to `registry-1.docker.io`, and needs local cache space for the guest image. See -[host-requirements.md](host-requirements.md) for the full host requirements. +The first pull downloads an OCI environment from public Docker Hub, requires +outbound HTTPS access to `registry-1.docker.io`, and needs local cache space +for the guest image. See [host-requirements.md](host-requirements.md) for the +full host requirements. Expected success signals: @@ -110,7 +120,7 @@ Pulled: debian:12 ... ``` -### 4. Run one command in a guest +## 4. Run one command in a guest ```bash uvx --from pyro-mcp pyro run debian:12 -- git --version @@ -126,17 +136,124 @@ Expected success signals: git version ... ``` -The guest command output and the `[run] ...` summary are written to different streams, so they -may appear in either order in terminals or capture tools. Use `--json` if you need a +The guest command output and the `[run] ...` summary are written to different +streams, so they may appear in either order. Use `--json` if you need a deterministic structured result. -If guest execution is unavailable, the command fails unless you explicitly pass -`--allow-host-compat`. +## 5. Warm the daily loop -## 5. Continue into the stable workspace path +```bash +uvx --from pyro-mcp pyro prepare debian:12 +``` -The commands below use plain `pyro ...`. Run the same flow with `uvx --from pyro-mcp pyro ...` -for the published package, or `uv run pyro ...` from a source checkout. +That one command ensures the environment is installed, proves one guest-backed +create/exec/reset/delete loop, and records a warm manifest so the next +`pyro prepare debian:12` call can reuse it instead of repeating the full cycle. + +## 6. Connect a chat host + +Use the helper flow first: + +```bash +uvx --from pyro-mcp pyro host connect codex --mode repro-fix +uvx --from pyro-mcp pyro host connect codex --mode inspect +uvx --from pyro-mcp pyro host connect claude-code --mode cold-start +uvx --from pyro-mcp pyro host connect claude-code --mode review-eval +uvx --from pyro-mcp pyro host print-config opencode --mode repro-fix +``` + +If setup drifts later, inspect and repair it with: + +```bash +uvx --from pyro-mcp pyro host doctor +uvx --from pyro-mcp pyro host repair claude-code +uvx --from pyro-mcp pyro host repair codex +uvx --from pyro-mcp pyro host repair opencode +``` + +Use a named mode when one workflow already matches the job: + +```bash +uvx --from pyro-mcp pyro mcp serve --mode repro-fix +uvx --from pyro-mcp pyro mcp serve --mode inspect +uvx --from pyro-mcp pyro mcp serve --mode cold-start +uvx --from pyro-mcp pyro mcp serve --mode review-eval +``` + +Use the generic no-mode path when the mode feels too narrow. Bare +`pyro mcp serve` still starts `workspace-core`. From a repo root, it also +auto-detects the current Git checkout so the first `workspace_create` can omit +`seed_path`. + +```bash +uvx --from pyro-mcp pyro mcp serve +``` + +If the host does not preserve the server working directory, use: + +```bash +uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo +``` + +If you are starting outside a local checkout, use a clean clone source: + +```bash +uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git +``` + +Copy-paste host-specific starts: + +- Claude Code setup: [claude_code_mcp.md](../examples/claude_code_mcp.md) +- Codex setup: [codex_mcp.md](../examples/codex_mcp.md) +- OpenCode config: [opencode_mcp_config.json](../examples/opencode_mcp_config.json) +- Generic MCP fallback: [mcp_client_config.md](../examples/mcp_client_config.md) + +Claude Code cold-start or review-eval: + +```bash +pyro host connect claude-code --mode cold-start +claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start +``` + +Codex repro-fix or inspect: + +```bash +pyro host connect codex --mode repro-fix +codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix +``` + +OpenCode uses the `mcp` / `type: "local"` config shape shown in +[opencode_mcp_config.json](../examples/opencode_mcp_config.json). + +If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with +`pyro` in the same command or config shape. + +Use the generic no-mode path when the named mode is too narrow. Move to +`--profile workspace-full` only when the chat truly needs shells, services, +snapshots, secrets, network policy, or disk tools. + +## 7. Go from zero to hero + +The intended user journey is: + +1. validate the host with `pyro doctor --environment debian:12` +2. warm the machine with `pyro prepare debian:12` +3. prove guest execution with `pyro run debian:12 -- git --version` +4. connect Claude Code, Codex, or OpenCode with one named mode such as + `pyro host connect codex --mode repro-fix`, then use raw + `pyro mcp serve --mode ...` or the generic no-mode path when needed +5. use `workspace reset` as the normal retry step inside that warmed loop +6. start with one use-case recipe from [use-cases/README.md](use-cases/README.md) +7. trust but verify with `make smoke-use-cases` + +If you want the shortest chat-first story, start with +[use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md). + +## 8. Manual terminal workspace flow + +If you want to inspect the workspace model directly from the terminal, use the +companion flow below. This is for understanding and debugging the chat-host +product, not the primary story. ```bash uv tool install pyro-mcp @@ -147,202 +264,49 @@ pyro workspace sync push "$WORKSPACE_ID" ./changes pyro workspace file read "$WORKSPACE_ID" note.txt --content-only pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch pyro workspace exec "$WORKSPACE_ID" -- cat note.txt +pyro workspace summary "$WORKSPACE_ID" pyro workspace snapshot create "$WORKSPACE_ID" checkpoint -pyro workspace service start "$WORKSPACE_ID" web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done' pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt pyro workspace delete "$WORKSPACE_ID" ``` -This is the stable persistent-workspace contract: +When you need deeper debugging or richer recipes, add: -- `workspace create` seeds `/workspace` -- `workspace create --name/--label`, `workspace list`, and `workspace update` make workspaces discoverable -- `workspace sync push` imports later host-side changes -- `workspace file *` and `workspace patch apply` cover model-native text inspection and edits -- `workspace exec` and `workspace shell *` keep work inside one sandbox -- `workspace service *` manages long-running processes with typed readiness -- `workspace snapshot *` and `workspace reset` make reset-over-repair explicit -- `workspace diff` compares against the immutable create-time baseline -- `workspace export` copies results back to the host -- `workspace stop|start` and `workspace disk *` add secondary stopped-workspace inspection and raw ext4 export +- `pyro workspace shell *` for interactive PTY state +- `pyro workspace service *` for long-running processes and readiness probes +- `pyro workspace create --network-policy egress+published-ports` plus + `workspace service start --publish` for host-probed services +- `pyro workspace create --secret` and `--secret-file` when the sandbox needs + private tokens +- `pyro workspace stop` plus `workspace disk *` for offline inspection -When that stable workspace path is working, continue with the recipe index at -[use-cases/README.md](use-cases/README.md). It groups the five core workspace stories and the -real smoke targets behind them, starting with `make smoke-use-cases` or one of the per-scenario -targets such as `make smoke-repro-fix-loop`. -Treat `make smoke-use-cases` as the trustworthy guest-backed verification path for the advertised -workspace workflows. +## 9. Trustworthy verification path -## 6. Optional demo proof point +The five recipe docs in [use-cases/README.md](use-cases/README.md) are backed +by a real Firecracker smoke pack: ```bash -uvx --from pyro-mcp pyro demo +make smoke-use-cases ``` -`pyro demo` proves the one-shot create/start/exec/delete VM lifecycle works end to end. - -Example output: - -```json -{ - "cleanup": { - "deleted": true, - "reason": "post_exec_cleanup", - "vm_id": "..." - }, - "command": "git --version", - "environment": "debian:12", - "execution_mode": "guest_vsock", - "exit_code": 0, - "stdout": "git version ...\n" -} -``` - -For a fuller copy-pasteable transcript, see [first-run.md](first-run.md). -When you are done evaluating and want to remove stale cached environments, run `pyro env prune`. +Treat that smoke pack as the trustworthy guest-backed verification path for the +advertised chat-host workflows. ## Installed CLI -If you already installed the package, the same evaluator path works with plain `pyro ...`: +If you already installed the package, the same path works with plain `pyro ...`: ```bash uv tool install pyro-mcp pyro --version -pyro doctor -pyro env list -pyro env pull debian:12 +pyro doctor --environment debian:12 +pyro prepare debian:12 pyro run debian:12 -- git --version +pyro mcp serve ``` -After the CLI path works, you can move on to: - -- persistent workspaces: `pyro workspace create debian:12 --seed-path ./repo` -- workspace discovery metadata: `pyro workspace create debian:12 --name repro-fix --label issue=123` -- workspace discovery commands: `pyro workspace list` and `pyro workspace update WORKSPACE_ID --label owner=codex` -- live workspace updates: `pyro workspace sync push WORKSPACE_ID ./changes` -- guest networking policy: `pyro workspace create debian:12 --network-policy egress` -- workspace secrets: `pyro workspace create debian:12 --secret API_TOKEN=expected --secret-file PIP_TOKEN=./token.txt` -- model-native file editing: `pyro workspace file read WORKSPACE_ID src/app.py --content-only`, `pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py`, and `pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch` -- baseline diff: `pyro workspace diff WORKSPACE_ID` -- snapshots and reset: `pyro workspace snapshot create WORKSPACE_ID checkpoint` and `pyro workspace reset WORKSPACE_ID --snapshot checkpoint` -- host export: `pyro workspace export WORKSPACE_ID note.txt --output ./note.txt` -- stopped-workspace inspection: `pyro workspace stop WORKSPACE_ID`, `pyro workspace disk list WORKSPACE_ID`, `pyro workspace disk read WORKSPACE_ID note.txt --content-only`, and `pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4` -- interactive shells: `pyro workspace shell open WORKSPACE_ID --id-only` -- long-running services: `pyro workspace service start WORKSPACE_ID app --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done'` -- localhost-published ports: `pyro workspace create debian:12 --network-policy egress+published-ports` and `pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app` -- MCP: `pyro mcp serve` -- Python SDK: `from pyro_mcp import Pyro` -- Demos: `pyro demo` or `pyro demo --network` - -## Chat Host Quickstart - -For most chat-host integrations, bare `pyro mcp serve` now starts -`workspace-core`: - -```bash -uvx --from pyro-mcp pyro mcp serve -``` - -Copy-paste host-specific starts: - -- Claude Code: [examples/claude_code_mcp.md](../examples/claude_code_mcp.md) -- Codex: [examples/codex_mcp.md](../examples/codex_mcp.md) -- OpenCode: [examples/opencode_mcp_config.json](../examples/opencode_mcp_config.json) -- Generic MCP config: [examples/mcp_client_config.md](../examples/mcp_client_config.md) - -Claude Code: - -```bash -claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve -``` - -Codex: - -```bash -codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve -``` - -OpenCode uses the `mcp`/`type: "local"` config shape shown in -[examples/opencode_mcp_config.json](../examples/opencode_mcp_config.json). If -`pyro-mcp` is already installed, replace the `uvx --from pyro-mcp pyro` -command with `pyro` in the same host-specific command or config shape. Use -`--profile workspace-full` only when the host truly needs the full advanced -workspace surface. - -Use profile progression like this: - -- `workspace-core`: default and recommended first profile for normal persistent chat editing -- `vm-run`: one-shot-only integrations -- `workspace-full`: explicit advanced opt-in when the host truly needs shells, services, snapshots, secrets, network policy, or disk tools - -## Stable Workspace - -Use `pyro workspace ...` when you need repeated commands in one sandbox instead of one-shot `pyro run`. - -```bash -pyro workspace create debian:12 --seed-path ./repo -pyro workspace create debian:12 --network-policy egress -pyro workspace create debian:12 --seed-path ./repo --secret API_TOKEN=expected -pyro workspace create debian:12 --network-policy egress+published-ports -pyro workspace sync push WORKSPACE_ID ./changes --dest src -pyro workspace file list WORKSPACE_ID src --recursive -pyro workspace file read WORKSPACE_ID src/note.txt --content-only -pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py -pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch -pyro workspace exec WORKSPACE_ID -- cat src/note.txt -pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"' -pyro workspace diff WORKSPACE_ID -pyro workspace snapshot create WORKSPACE_ID checkpoint -pyro workspace reset WORKSPACE_ID --snapshot checkpoint -pyro workspace reset WORKSPACE_ID -pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt -pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN --id-only -pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd' -pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300 -pyro workspace shell close WORKSPACE_ID SHELL_ID -pyro workspace service start WORKSPACE_ID web --secret-env API_TOKEN --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 start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app -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 stop WORKSPACE_ID -pyro workspace disk list WORKSPACE_ID -pyro workspace disk read WORKSPACE_ID src/note.txt --content-only -pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4 -pyro workspace start WORKSPACE_ID -pyro workspace logs WORKSPACE_ID -pyro workspace delete WORKSPACE_ID -``` - -Workspace commands default to the persistent `/workspace` directory inside the guest. If you need -the identifier programmatically, use `--id-only` for only the identifier or `--json` for the full -workspace payload. 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 `4.0.0`; if it fails partway through, prefer `pyro workspace reset` to recover -from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the current workspace -tree to its immutable create-time baseline, `pyro workspace snapshot *` to capture named -checkpoints, 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. Prefer -`pyro workspace shell read --plain --wait-for-idle-ms 300` for chat-facing shell loops. 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. Use `--network-policy egress` when the workspace needs outbound guest networking, -and `--network-policy egress+published-ports` plus `workspace service start --publish` when one -service must be reachable from the host on `127.0.0.1`. Use `--secret` and `--secret-file` at -workspace creation when the sandbox needs private tokens or config, and -`--secret-env SECRET_NAME[=ENV_VAR]` when one exec, shell, or service call needs that secret as an -environment variable. Persisted secret files are available in the guest at -`/run/pyro-secrets/`. Use `pyro workspace stop` plus `pyro workspace disk list|read|export` -when you need offline inspection or one raw ext4 copy from a stopped guest-backed workspace, then -`pyro workspace start` to resume it. - -## Contributor Clone +## Contributor clone ```bash git lfs install diff --git a/docs/integrations.md b/docs/integrations.md index 2883784..4974302 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -1,164 +1,257 @@ -# Integration Targets +# Chat Host Integrations -These are the main ways to integrate `pyro-mcp` into an LLM application. +This page documents the intended product path for `pyro-mcp`: -Use this page after you have already validated the host and guest execution through the -CLI path in [install.md](install.md) or [first-run.md](first-run.md). +- validate the host with the CLI +- warm the daily loop with `pyro prepare debian:12` +- run `pyro mcp serve` +- connect a chat host +- let the agent work inside disposable workspaces -## Recommended Default +`pyro-mcp` currently has no users. Expect breaking changes while this chat-host +path is still being shaped. -Bare `pyro mcp serve` now starts `workspace-core`. Use `vm_run` only for one-shot -integrations, and promote the chat surface to `workspace-full` only when it -truly needs shells, services, snapshots, secrets, network policy, or disk -tools. +Use this page after you have already validated the host and guest execution +through [install.md](install.md) or [first-run.md](first-run.md). -That keeps the model-facing contract small: +Recommended first commands before connecting a host: -- one tool -- one command -- one ephemeral VM -- automatic cleanup +```bash +pyro doctor --environment debian:12 +pyro prepare debian:12 +``` -Profile progression: +## Recommended Modes -- `workspace-core`: default and recommended first profile for persistent chat editing -- `vm-run`: one-shot only -- `workspace-full`: the full stable workspace surface, including shells, services, snapshots, secrets, network policy, and disk tools +Use a named mode when one workflow already matches the job: -## OpenAI Responses API +```bash +pyro host connect codex --mode repro-fix +pyro host connect codex --mode inspect +pyro host connect claude-code --mode cold-start +pyro host connect claude-code --mode review-eval +``` -Best when: +The mode-backed raw server forms are: -- your agent already uses OpenAI models directly -- you want a normal tool-calling loop instead of MCP transport -- you want the smallest amount of integration code +```bash +pyro mcp serve --mode repro-fix +pyro mcp serve --mode inspect +pyro mcp serve --mode cold-start +pyro mcp serve --mode review-eval +``` -Recommended surface: +Use the generic no-mode path only when the named mode feels too narrow. -- `vm_run` for one-shot loops -- the `workspace-core` tool set for the normal persistent chat loop -- the `workspace-full` tool set only when the host explicitly needs advanced workspace capabilities +## Generic Default -Canonical example: +Bare `pyro mcp serve` starts `workspace-core`. From a repo root, it also +auto-detects the current Git checkout so the first `workspace_create` can omit +`seed_path`. That is the product path. -- [examples/openai_responses_vm_run.py](../examples/openai_responses_vm_run.py) -- [examples/openai_responses_workspace_core.py](../examples/openai_responses_workspace_core.py) -- [docs/use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md) +```bash +pyro mcp serve +``` -## MCP Clients +If the host does not preserve cwd, fall back to: -Best when: +```bash +pyro mcp serve --project-path /abs/path/to/repo +``` -- your host application already supports MCP -- you want `pyro` to run as an external stdio server -- you want tool schemas to be discovered directly from the server +If you are outside a repo checkout entirely, start from a clean clone source: -Recommended entrypoint: +```bash +pyro mcp serve --repo-url https://github.com/example/project.git +``` -- `pyro mcp serve` +Use `--profile workspace-full` only when the chat truly needs shells, services, +snapshots, secrets, network policy, or disk tools. -Profile progression: +## Helper First -- `pyro mcp serve --profile vm-run` for the smallest one-shot surface -- `pyro mcp serve` for the normal persistent chat loop -- `pyro mcp serve --profile workspace-full` only when the model truly needs advanced workspace tools +Use the helper flow before the raw host CLI commands: -Host-specific onramps: +```bash +pyro host connect codex --mode repro-fix +pyro host connect codex --mode inspect +pyro host connect claude-code --mode cold-start +pyro host connect claude-code --mode review-eval +pyro host print-config opencode --mode repro-fix +pyro host doctor +pyro host repair opencode +``` -- Claude Code: [examples/claude_code_mcp.md](../examples/claude_code_mcp.md) -- Codex: [examples/codex_mcp.md](../examples/codex_mcp.md) -- OpenCode: [examples/opencode_mcp_config.json](../examples/opencode_mcp_config.json) -- Generic MCP config: [examples/mcp_client_config.md](../examples/mcp_client_config.md) -- Claude Desktop fallback: [examples/claude_desktop_mcp_config.json](../examples/claude_desktop_mcp_config.json) -- Cursor fallback: [examples/cursor_mcp_config.json](../examples/cursor_mcp_config.json) -- Use-case recipes: [docs/use-cases/README.md](use-cases/README.md) +These helpers wrap the same `pyro mcp serve` entrypoint, make named modes the +first user-facing story, and still leave the generic no-mode path available +when a mode is too narrow. -## Direct Python SDK +## Claude Code -Best when: +Preferred: -- your application owns orchestration itself -- you do not need MCP transport -- you want direct access to `Pyro` +```bash +pyro host connect claude-code --mode cold-start +``` -Recommended default: +Repair: -- `Pyro.run_in_vm(...)` -- `Pyro.create_server()` for most chat hosts now that `workspace-core` is the default profile -- `Pyro.create_workspace(name=..., labels=...)` + `Pyro.list_workspaces()` + `Pyro.update_workspace(...)` when repeated workspaces need human-friendly discovery metadata -- `Pyro.create_workspace(seed_path=...)` + `Pyro.push_workspace_sync(...)` + `Pyro.exec_workspace(...)` when repeated workspace commands are required -- `Pyro.list_workspace_files(...)` / `Pyro.read_workspace_file(...)` / `Pyro.write_workspace_file(...)` / `Pyro.apply_workspace_patch(...)` when the agent needs model-native file inspection and text edits inside one live workspace -- `Pyro.create_workspace(..., secrets=...)` + `Pyro.exec_workspace(..., secret_env=...)` when the workspace needs private tokens or authenticated setup -- `Pyro.create_workspace(..., network_policy="egress+published-ports")` + `Pyro.start_service(..., published_ports=[...])` when the host must probe one workspace service -- `Pyro.diff_workspace(...)` + `Pyro.export_workspace(...)` when the agent needs baseline comparison or host-out file transfer -- `Pyro.start_service(..., secret_env=...)` + `Pyro.list_services(...)` + `Pyro.logs_service(...)` when the agent needs long-running background processes in one workspace -- `Pyro.open_shell(..., secret_env=...)` + `Pyro.write_shell(...)` + `Pyro.read_shell(..., plain=True, wait_for_idle_ms=300)` when the agent needs an interactive PTY inside the workspace +```bash +pyro host repair claude-code +``` -Lifecycle note: +Package without install: -- `Pyro.exec_vm(...)` runs one command and auto-cleans the VM afterward -- use `create_vm(...)` + `start_vm(...)` only when you need pre-exec inspection or status before - that final exec -- use `create_workspace(seed_path=...)` when the agent needs repeated commands in one persistent - `/workspace` that starts from host content -- use `create_workspace(name=..., labels=...)`, `list_workspaces()`, and `update_workspace(...)` - when the agent or operator needs to rediscover the right workspace later without external notes -- use `push_workspace_sync(...)` when later host-side changes need to be imported into that - running workspace without recreating it -- use `list_workspace_files(...)`, `read_workspace_file(...)`, `write_workspace_file(...)`, and - `apply_workspace_patch(...)` when the agent should inspect or edit workspace files without shell - quoting tricks -- use `create_workspace(..., secrets=...)` plus `secret_env` on exec, shell, or service start when - the agent needs private tokens or authenticated startup inside that workspace -- use `create_workspace(..., network_policy="egress+published-ports")` plus - `start_service(..., published_ports=[...])` when the host must probe one service from that - workspace -- 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 `stop_workspace(...)` plus `list_workspace_disk(...)`, `read_workspace_disk(...)`, or - `export_workspace_disk(...)` when the agent needs offline inspection or one raw ext4 copy from - a stopped guest-backed workspace -- 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 +```bash +claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start +claude mcp list +``` -Examples: +If Claude Code launches the server from an unexpected cwd, use: -- [examples/python_run.py](../examples/python_run.py) -- [examples/python_lifecycle.py](../examples/python_lifecycle.py) -- [examples/python_workspace.py](../examples/python_workspace.py) -- [examples/python_shell.py](../examples/python_shell.py) -- [docs/use-cases/README.md](use-cases/README.md) +```bash +claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start --project-path /abs/path/to/repo +``` -## Agent Framework Wrappers +Already installed: -Examples: +```bash +claude mcp add pyro -- pyro mcp serve +claude mcp list +``` -- LangChain tools -- PydanticAI tools -- custom in-house orchestration layers +Reference: -Best when: +- [claude_code_mcp.md](../examples/claude_code_mcp.md) -- you already have an application framework that expects a Python callable tool -- you want to wrap `vm_run` behind framework-specific abstractions +## Codex -Recommended pattern: +Preferred: -- keep the framework wrapper thin -- map one-shot framework tool input directly onto `vm_run` -- expose `workspace_*` only when the framework truly needs repeated commands in one workspace +```bash +pyro host connect codex --mode repro-fix +``` -Concrete example: +Repair: -- [examples/langchain_vm_run.py](../examples/langchain_vm_run.py) +```bash +pyro host repair codex +``` -## Selection Rule +Package without install: -Choose the narrowest integration that matches the host environment: +```bash +codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix +codex mcp list +``` -1. OpenAI Responses API if you want a direct provider tool loop. -2. MCP if your host already speaks MCP. -3. Python SDK if you own orchestration and do not need transport. -4. Framework wrappers only as thin adapters over the same `vm_run` contract. +If Codex launches the server from an unexpected cwd, use: + +```bash +codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix --project-path /abs/path/to/repo +``` + +Already installed: + +```bash +codex mcp add pyro -- pyro mcp serve +codex mcp list +``` + +Reference: + +- [codex_mcp.md](../examples/codex_mcp.md) + +## OpenCode + +Preferred: + +```bash +pyro host print-config opencode +pyro host repair opencode +``` + +Use the local MCP config shape from: + +- [opencode_mcp_config.json](../examples/opencode_mcp_config.json) + +Minimal `opencode.json` snippet: + +```json +{ + "mcp": { + "pyro": { + "type": "local", + "enabled": true, + "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"] + } + } +} +``` + +If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with +`pyro` in the same config shape. + +If OpenCode launches the server from an unexpected cwd, add +`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same command +array. + +## Generic MCP Fallback + +Use this only when the host expects a plain `mcpServers` JSON config, when the +named modes are too narrow, and when it does not already have a dedicated +example in the repo: + +- [mcp_client_config.md](../examples/mcp_client_config.md) + +Generic `mcpServers` shape: + +```json +{ + "mcpServers": { + "pyro": { + "command": "uvx", + "args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"] + } + } +} +``` + +## When To Use `workspace-full` + +Stay on bare `pyro mcp serve` unless the chat host truly needs: + +- persistent PTY shell sessions +- long-running services and readiness probes +- secrets +- guest networking and published ports +- stopped-workspace disk inspection or raw ext4 export + +When that is necessary: + +```bash +pyro mcp serve --profile workspace-full +``` + +## Recipe-Backed Workflows + +Once the host is connected, move to the five real workflows in +[use-cases/README.md](use-cases/README.md): + +- cold-start repo validation +- repro plus fix loops +- parallel isolated workspaces +- unsafe or untrusted code inspection +- review and evaluation workflows + +Validate the whole story with: + +```bash +make smoke-use-cases +``` + +For the machine-warmup plus reset/retry path specifically: + +```bash +make smoke-daily-loop +``` diff --git a/docs/public-contract.md b/docs/public-contract.md index d225c0b..69412f0 100644 --- a/docs/public-contract.md +++ b/docs/public-contract.md @@ -1,375 +1,192 @@ # Public Contract -This document defines the stable public interface for `pyro-mcp` `3.x`. +This document describes the chat way to use `pyro-mcp` in `4.x`. + +`pyro-mcp` currently has no users. Expect breaking changes while this chat-host +path is still being shaped. + +This document is intentionally biased. It describes the path users are meant to +follow today: + +- prove the host with the terminal companion commands +- serve disposable workspaces over MCP +- connect Claude Code, Codex, or OpenCode +- use the recipe-backed workflows + +This page does not try to document every building block in the repo. It +documents the chat-host path the project is actively shaping. ## Package Identity -- Distribution name: `pyro-mcp` -- Public executable: `pyro` -- Public Python import: `from pyro_mcp import Pyro` -- Public package-level factory: `from pyro_mcp import create_server` +- distribution name: `pyro-mcp` +- public executable: `pyro` +- primary product entrypoint: `pyro mcp serve` -Stable product framing: +`pyro-mcp` is a disposable MCP workspace for chat-based coding agents on Linux +`x86_64` KVM hosts. -- `pyro run` is the stable one-shot entrypoint. -- `pyro workspace ...` is the stable persistent workspace contract. +## Supported Product Path -## CLI Contract +The intended user journey is: -Top-level commands: +1. `pyro doctor` +2. `pyro prepare debian:12` +3. `pyro run debian:12 -- git --version` +4. `pyro mcp serve` +5. connect Claude Code, Codex, or OpenCode +6. use `workspace reset` as the normal retry step +7. run one of the documented recipe-backed workflows +8. validate the whole story with `make smoke-use-cases` +## Evaluator CLI + +These terminal commands are the documented companion path for the chat-host +product: + +- `pyro doctor` +- `pyro prepare` - `pyro env list` - `pyro env pull` -- `pyro env inspect` -- `pyro env prune` -- `pyro mcp serve` - `pyro run` -- `pyro workspace create` -- `pyro workspace list` -- `pyro workspace sync push` -- `pyro workspace stop` -- `pyro workspace start` -- `pyro workspace exec` -- `pyro workspace file list` -- `pyro workspace file read` -- `pyro workspace file write` -- `pyro workspace export` -- `pyro workspace patch apply` -- `pyro workspace disk export` -- `pyro workspace disk list` -- `pyro workspace disk read` -- `pyro workspace diff` -- `pyro workspace snapshot create` -- `pyro workspace snapshot list` -- `pyro workspace snapshot delete` -- `pyro workspace reset` -- `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` -- `pyro workspace shell signal` -- `pyro workspace shell close` -- `pyro workspace status` -- `pyro workspace update` -- `pyro workspace logs` -- `pyro workspace delete` -- `pyro doctor` - `pyro demo` -- `pyro demo ollama` -Stable `pyro run` interface: +What to expect from that path: -- positional environment name -- `--vcpu-count` -- `--mem-mib` -- `--timeout-seconds` -- `--ttl-seconds` -- `--network` -- `--allow-host-compat` -- `--json` +- `pyro run -- ` defaults to `1 vCPU / 1024 MiB` +- `pyro run` fails if guest boot or guest exec is unavailable unless + `--allow-host-compat` is set +- `pyro run`, `pyro env list`, `pyro env pull`, `pyro env inspect`, + `pyro env prune`, `pyro doctor`, and `pyro prepare` are human-readable by + default and return structured JSON with `--json` +- the first official environment pull downloads from public Docker Hub into the + local environment cache +- `pyro prepare debian:12` proves the warmed daily loop with one throwaway + workspace create, exec, reset, and delete cycle +- `pyro demo` proves the one-shot create/start/exec/delete VM lifecycle end to + end -Behavioral guarantees: +These commands exist to validate and debug the chat-host path. They are not the +main product destination. -- `pyro run -- ` defaults to `1 vCPU / 1024 MiB`. -- `pyro run` fails if guest boot or guest exec is unavailable unless `--allow-host-compat` is set. -- `pyro run`, `pyro env list`, `pyro env pull`, `pyro env inspect`, `pyro env prune`, and `pyro doctor` are human-readable by default and return structured JSON with `--json`. -- `pyro demo ollama` prints log lines plus a final summary line. -- `pyro workspace create` auto-starts a persistent workspace. -- `pyro workspace create --seed-path PATH` seeds `/workspace` from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive before the workspace is returned. -- `pyro workspace create --id-only` prints only the new `workspace_id` plus a trailing newline. -- `pyro workspace create --name NAME --label KEY=VALUE` attaches human-oriented discovery metadata without changing the stable `workspace_id`. -- `pyro workspace create --network-policy {off,egress,egress+published-ports}` controls workspace guest networking and whether services may publish localhost ports. -- `pyro mcp serve --profile {vm-run,workspace-core,workspace-full}` narrows the model-facing MCP surface without changing runtime behavior; `workspace-core` is the recommended first profile for most chat hosts. -- `pyro workspace create --secret NAME=VALUE` and `--secret-file NAME=PATH` persist guest-only UTF-8 secrets outside `/workspace`. -- `pyro workspace list` returns persisted workspaces sorted by most recent `last_activity_at`. -- `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 stop WORKSPACE_ID` stops one persistent workspace without deleting its `/workspace`, snapshots, or command history. -- `pyro workspace start WORKSPACE_ID` restarts one stopped workspace without resetting `/workspace`. -- `pyro workspace file list WORKSPACE_ID [PATH] [--recursive]` returns metadata for one live path under `/workspace`. -- `pyro workspace file read WORKSPACE_ID PATH [--max-bytes N] [--content-only]` reads one regular text file under `/workspace`. -- `pyro workspace file write WORKSPACE_ID PATH --text TEXT` and `--text-file PATH` create or replace one regular text file under `/workspace`, creating missing parent directories automatically. -- `pyro workspace export WORKSPACE_ID PATH --output HOST_PATH` exports one file or directory from `/workspace` back to the host. -- `pyro workspace disk export WORKSPACE_ID --output HOST_PATH` copies the stopped guest-backed workspace rootfs as raw ext4 to the host. -- `pyro workspace disk list WORKSPACE_ID [PATH] [--recursive]` inspects a stopped guest-backed workspace rootfs offline without booting the guest. -- `pyro workspace disk read WORKSPACE_ID PATH [--max-bytes N] [--content-only]` reads one regular file from a stopped guest-backed workspace rootfs offline. -- `pyro workspace disk *` requires `state=stopped` and a guest-backed workspace; it fails on `host_compat`. -- `pyro workspace diff WORKSPACE_ID` compares the current `/workspace` tree to the immutable create-time baseline. -- `pyro workspace snapshot *` manages explicit named snapshots in addition to the implicit `baseline`. -- `pyro workspace reset WORKSPACE_ID [--snapshot SNAPSHOT_NAME|baseline]` recreates the full sandbox and restores `/workspace` from the chosen snapshot. -- `pyro workspace service *` manages long-running named services inside one started workspace with typed readiness probes. -- `pyro workspace service start --publish GUEST_PORT` or `--publish HOST_PORT:GUEST_PORT` publishes one guest TCP port to `127.0.0.1` on the host. -- `pyro workspace exec --secret-env SECRET_NAME[=ENV_VAR]` maps one persisted secret into one exec call. -- `pyro workspace service start --secret-env SECRET_NAME[=ENV_VAR]` maps one persisted secret into one service start call. -- `pyro workspace exec` runs in the persistent `/workspace` for that workspace and does not auto-clean. -- `pyro workspace patch apply WORKSPACE_ID --patch TEXT` and `--patch-file PATH` apply one unified text patch with add/modify/delete operations under `/workspace`. -- `pyro workspace shell open --id-only` prints only the new `shell_id` plus a trailing newline. -- `pyro workspace shell open --secret-env SECRET_NAME[=ENV_VAR]` maps one persisted secret into the opened shell environment. -- `pyro workspace shell *` manages persistent PTY sessions inside a started workspace. -- `pyro workspace shell read --plain --wait-for-idle-ms 300` is the recommended chat-facing read mode; raw shell reads remain available without `--plain`. -- `pyro workspace logs` returns persisted command history for that workspace until `pyro workspace delete`. -- `pyro workspace update` changes only discovery metadata such as `name` and key/value `labels`. -- Workspace create/status results expose `workspace_seed` metadata describing how `/workspace` was initialized. -- Workspace create/status/reset/update results expose `name`, `labels`, and `last_activity_at`. -- Workspace create/status/reset results expose `network_policy`. -- Workspace create/status/reset results expose `reset_count` and `last_reset_at`. -- Workspace create/status/reset results expose safe `secrets` metadata with each secret name and source kind, but never the secret values. -- `pyro workspace status` includes aggregate `service_count` and `running_service_count` fields. -- `pyro workspace list` returns one summary row per persisted workspace with `workspace_id`, `name`, `labels`, `environment`, `state`, `created_at`, `last_activity_at`, `expires_at`, `command_count`, `service_count`, and `running_service_count`. -- `pyro workspace service start`, `pyro workspace service list`, and `pyro workspace service status` expose published-port metadata when present. +## MCP Entry Point -## Python SDK Contract +The product entrypoint is: -Primary facade: +```bash +pyro mcp serve +``` -- `Pyro` +What to expect: -Supported public entrypoints: +- named modes are now the first chat-host story: + - `pyro mcp serve --mode repro-fix` + - `pyro mcp serve --mode inspect` + - `pyro mcp serve --mode cold-start` + - `pyro mcp serve --mode review-eval` +- bare `pyro mcp serve` remains the generic no-mode path and starts + `workspace-core` +- from a repo root, bare `pyro mcp serve` also auto-detects the current Git + checkout so `workspace_create` can omit `seed_path` +- `pyro mcp serve --profile workspace-full` explicitly opts into the larger + tool surface +- `pyro mcp serve --profile vm-run` exposes the smallest one-shot-only surface +- `pyro mcp serve --project-path /abs/path/to/repo` is the fallback when the + host does not preserve cwd +- `pyro mcp serve --repo-url ... [--repo-ref ...]` starts from a clean clone + source instead of a local checkout -- `create_server()` -- `Pyro.create_server()` -- `Pyro.list_environments()` -- `Pyro.pull_environment(environment)` -- `Pyro.inspect_environment(environment)` -- `Pyro.prune_environments()` -- `Pyro.create_vm(...)` -- `Pyro.create_workspace(..., name=None, labels=None, network_policy="off", secrets=None)` -- `Pyro.list_workspaces()` -- `Pyro.push_workspace_sync(workspace_id, source_path, *, dest="/workspace")` -- `Pyro.stop_workspace(workspace_id)` -- `Pyro.start_workspace(workspace_id)` -- `Pyro.list_workspace_files(workspace_id, path="/workspace", recursive=False)` -- `Pyro.read_workspace_file(workspace_id, path, *, max_bytes=65536)` -- `Pyro.write_workspace_file(workspace_id, path, *, text)` -- `Pyro.export_workspace(workspace_id, path, *, output_path)` -- `Pyro.apply_workspace_patch(workspace_id, *, patch)` -- `Pyro.export_workspace_disk(workspace_id, *, output_path)` -- `Pyro.list_workspace_disk(workspace_id, path="/workspace", recursive=False)` -- `Pyro.read_workspace_disk(workspace_id, path, *, max_bytes=65536)` -- `Pyro.diff_workspace(workspace_id)` -- `Pyro.create_snapshot(workspace_id, snapshot_name)` -- `Pyro.list_snapshots(workspace_id)` -- `Pyro.delete_snapshot(workspace_id, snapshot_name)` -- `Pyro.reset_workspace(workspace_id, *, snapshot="baseline")` -- `Pyro.start_service(workspace_id, service_name, *, command, cwd="/workspace", readiness=None, ready_timeout_seconds=30, ready_interval_ms=500, secret_env=None, published_ports=None)` -- `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, secret_env=None)` -- `Pyro.read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536, plain=False, wait_for_idle_ms=None)` -- `Pyro.write_shell(workspace_id, shell_id, *, input, append_newline=True)` -- `Pyro.signal_shell(workspace_id, shell_id, *, signal_name="INT")` -- `Pyro.close_shell(workspace_id, shell_id)` -- `Pyro.start_vm(vm_id)` -- `Pyro.exec_vm(vm_id, *, command, timeout_seconds=30)` -- `Pyro.exec_workspace(workspace_id, *, command, timeout_seconds=30, secret_env=None)` -- `Pyro.stop_vm(vm_id)` -- `Pyro.delete_vm(vm_id)` -- `Pyro.delete_workspace(workspace_id)` -- `Pyro.status_vm(vm_id)` -- `Pyro.status_workspace(workspace_id)` -- `Pyro.update_workspace(workspace_id, *, name=None, clear_name=False, labels=None, clear_labels=None)` -- `Pyro.logs_workspace(workspace_id)` -- `Pyro.network_info_vm(vm_id)` -- `Pyro.reap_expired()` -- `Pyro.run_in_vm(...)` +Host-specific setup docs: -Stable public method names: +- [claude_code_mcp.md](../examples/claude_code_mcp.md) +- [codex_mcp.md](../examples/codex_mcp.md) +- [opencode_mcp_config.json](../examples/opencode_mcp_config.json) +- [mcp_client_config.md](../examples/mcp_client_config.md) -- `create_server()` -- `list_environments()` -- `pull_environment(environment)` -- `inspect_environment(environment)` -- `prune_environments()` -- `create_vm(...)` -- `create_workspace(..., name=None, labels=None, network_policy="off", secrets=None)` -- `list_workspaces()` -- `push_workspace_sync(workspace_id, source_path, *, dest="/workspace")` -- `stop_workspace(workspace_id)` -- `start_workspace(workspace_id)` -- `list_workspace_files(workspace_id, path="/workspace", recursive=False)` -- `read_workspace_file(workspace_id, path, *, max_bytes=65536)` -- `write_workspace_file(workspace_id, path, *, text)` -- `export_workspace(workspace_id, path, *, output_path)` -- `apply_workspace_patch(workspace_id, *, patch)` -- `export_workspace_disk(workspace_id, *, output_path)` -- `list_workspace_disk(workspace_id, path="/workspace", recursive=False)` -- `read_workspace_disk(workspace_id, path, *, max_bytes=65536)` -- `diff_workspace(workspace_id)` -- `create_snapshot(workspace_id, snapshot_name)` -- `list_snapshots(workspace_id)` -- `delete_snapshot(workspace_id, snapshot_name)` -- `reset_workspace(workspace_id, *, snapshot="baseline")` -- `start_service(workspace_id, service_name, *, command, cwd="/workspace", readiness=None, ready_timeout_seconds=30, ready_interval_ms=500, secret_env=None, published_ports=None)` -- `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, secret_env=None)` -- `read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536, plain=False, wait_for_idle_ms=None)` -- `write_shell(workspace_id, shell_id, *, input, append_newline=True)` -- `signal_shell(workspace_id, shell_id, *, signal_name="INT")` -- `close_shell(workspace_id, shell_id)` -- `start_vm(vm_id)` -- `exec_vm(vm_id, *, command, timeout_seconds=30)` -- `exec_workspace(workspace_id, *, command, timeout_seconds=30, secret_env=None)` -- `stop_vm(vm_id)` -- `delete_vm(vm_id)` -- `delete_workspace(workspace_id)` -- `status_vm(vm_id)` -- `status_workspace(workspace_id)` -- `update_workspace(workspace_id, *, name=None, clear_name=False, labels=None, clear_labels=None)` -- `logs_workspace(workspace_id)` -- `network_info_vm(vm_id)` -- `reap_expired()` -- `run_in_vm(...)` +The chat-host bootstrap helper surface is: -Behavioral defaults: +- `pyro host connect claude-code` +- `pyro host connect codex` +- `pyro host print-config opencode` +- `pyro host doctor` +- `pyro host repair HOST` -- `Pyro.create_vm(...)` and `Pyro.run_in_vm(...)` default to `vcpu_count=1` and `mem_mib=1024`. -- `Pyro.create_workspace(...)` defaults to `vcpu_count=1` and `mem_mib=1024`. -- `allow_host_compat` defaults to `False` on `create_vm(...)` and `run_in_vm(...)`. -- `allow_host_compat` defaults to `False` on `create_workspace(...)`. -- `Pyro.create_workspace(..., seed_path=...)` seeds `/workspace` from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive before the workspace is returned. -- `Pyro.create_workspace(..., name=..., labels=...)` attaches human-oriented discovery metadata without changing the stable `workspace_id`. -- `Pyro.create_workspace(..., network_policy="off"|"egress"|"egress+published-ports")` controls workspace guest networking and whether services may publish host ports. -- `Pyro.create_workspace(..., secrets=...)` persists guest-only UTF-8 secrets outside `/workspace`. -- `Pyro.list_workspaces()` returns persisted workspace summaries sorted by most recent `last_activity_at`. -- `Pyro.push_workspace_sync(...)` imports later host-side directory or archive content into a started workspace. -- `Pyro.stop_workspace(...)` stops one persistent workspace without deleting its `/workspace`, snapshots, or command history. -- `Pyro.start_workspace(...)` restarts one stopped workspace without resetting `/workspace`. -- `Pyro.list_workspace_files(...)`, `Pyro.read_workspace_file(...)`, and `Pyro.write_workspace_file(...)` provide structured live `/workspace` inspection and text edits without shell quoting. -- `Pyro.export_workspace(...)` exports one file or directory from `/workspace` to an explicit host path. -- `Pyro.apply_workspace_patch(...)` applies unified text patches for add/modify/delete operations under `/workspace`. -- `Pyro.export_workspace_disk(...)` copies the stopped guest-backed workspace rootfs as raw ext4 to an explicit host path. -- `Pyro.list_workspace_disk(...)` inspects a stopped guest-backed workspace rootfs offline without booting the guest. -- `Pyro.read_workspace_disk(...)` reads one regular file from a stopped guest-backed workspace rootfs offline. -- stopped-workspace disk helpers require `state=stopped` and a guest-backed workspace; they fail on `host_compat`. -- `Pyro.diff_workspace(...)` compares the current `/workspace` tree to the immutable create-time baseline. -- `Pyro.create_snapshot(...)` captures one named `/workspace` checkpoint. -- `Pyro.list_snapshots(...)` lists the implicit `baseline` plus any named snapshots. -- `Pyro.delete_snapshot(...)` deletes one named snapshot while leaving `baseline` intact. -- `Pyro.reset_workspace(...)` recreates the full sandbox from `baseline` or one named snapshot and clears command, shell, and service history. -- `Pyro.start_service(..., secret_env=...)` maps persisted workspace secrets into that service process as environment variables for that start call only. -- `Pyro.start_service(...)` starts one named long-running process in a started workspace and waits for its typed readiness probe when configured. -- `Pyro.start_service(..., published_ports=[...])` publishes one or more guest TCP ports to `127.0.0.1` on the host when the workspace network policy is `egress+published-ports`. -- `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(..., secret_env=...)` maps persisted workspace secrets into that exec call as environment variables for that call only. -- `Pyro.exec_workspace(...)` runs one command in the persistent workspace and leaves it alive. -- `Pyro.open_shell(..., secret_env=...)` maps persisted workspace secrets into the shell environment when that shell opens. -- `Pyro.open_shell(...)` opens a persistent PTY shell attached to one started workspace. -- `Pyro.read_shell(...)` reads merged text output from that shell by cursor, with optional plain rendering and idle batching for chat-facing consumers. -- `Pyro.write_shell(...)`, `Pyro.signal_shell(...)`, and `Pyro.close_shell(...)` operate on that persistent shell session. -- `Pyro.update_workspace(...)` changes only discovery metadata such as `name` and key/value `labels`. +These helpers wrap the same `pyro mcp serve` entrypoint and are the preferred +setup and repair path for supported hosts. -## MCP Contract +## Named Modes -Stable MCP profiles: +The supported named modes are: -- `vm-run`: exposes only `vm_run` -- `workspace-core`: exposes `vm_run`, `workspace_create`, `workspace_list`, `workspace_update`, `workspace_status`, `workspace_sync_push`, `workspace_exec`, `workspace_logs`, `workspace_file_list`, `workspace_file_read`, `workspace_file_write`, `workspace_patch_apply`, `workspace_diff`, `workspace_export`, `workspace_reset`, and `workspace_delete` -- `workspace-full`: exposes the complete stable MCP surface below +| Mode | Intended workflow | Key tools | +| --- | --- | --- | +| `repro-fix` | reproduce, patch, rerun, diff, export, reset | file ops, patch, diff, export, reset, summary | +| `inspect` | inspect suspicious or unfamiliar code with the smallest persistent surface | file list/read, exec, export, summary | +| `cold-start` | validate a fresh repo and keep services alive long enough to prove readiness | exec, export, reset, summary, service tools | +| `review-eval` | interactive review, checkpointing, shell-driven evaluation, and export | shell tools, snapshot tools, diff/export, summary | -Behavioral defaults: +Use the generic no-mode path when one of those named modes feels too narrow for +the job. -- `pyro mcp serve`, `create_server()`, and `Pyro.create_server()` default to `workspace-core`. -- `workspace-core` is the default and recommended first profile for most new chat-host integrations. -- `create_server(profile="workspace-full")` and `Pyro.create_server(profile="workspace-full")` opt into the full advanced workspace surface explicitly. -- `workspace-core` narrows `workspace_create` by omitting `network_policy` and `secrets`. -- `workspace-core` narrows `workspace_exec` by omitting `secret_env`. +## Generic Workspace Contract -Primary tool: +`workspace-core` is the normal chat path. It exposes: - `vm_run` - -Advanced lifecycle tools: - -- `vm_list_environments` -- `vm_create` -- `vm_start` -- `vm_exec` -- `vm_stop` -- `vm_delete` -- `vm_status` -- `vm_network_info` -- `vm_reap_expired` - -Persistent workspace tools: - - `workspace_create` - `workspace_list` +- `workspace_update` +- `workspace_status` - `workspace_sync_push` -- `workspace_stop` -- `workspace_start` - `workspace_exec` +- `workspace_logs` +- `workspace_summary` - `workspace_file_list` - `workspace_file_read` - `workspace_file_write` -- `workspace_export` - `workspace_patch_apply` -- `workspace_disk_export` -- `workspace_disk_list` -- `workspace_disk_read` - `workspace_diff` -- `snapshot_create` -- `snapshot_list` -- `snapshot_delete` +- `workspace_export` - `workspace_reset` -- `service_start` -- `service_list` -- `service_status` -- `service_logs` -- `service_stop` -- `shell_open` -- `shell_read` -- `shell_write` -- `shell_signal` -- `shell_close` -- `workspace_status` -- `workspace_update` -- `workspace_logs` - `workspace_delete` -Behavioral defaults: +That is enough for the normal persistent editing loop: -- `vm_run` and `vm_create` default to `vcpu_count=1` and `mem_mib=1024`. -- `workspace_create` defaults to `vcpu_count=1` and `mem_mib=1024`. -- `vm_run` and `vm_create` expose `allow_host_compat`, which defaults to `false`. -- `workspace_create` exposes `allow_host_compat`, which defaults to `false`. -- `workspace_create` accepts optional `seed_path` and seeds `/workspace` from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive before the workspace is returned. -- `workspace_create` accepts optional `name` and `labels` metadata for human discovery without changing the stable `workspace_id`. -- `workspace_create` accepts `network_policy` with `off`, `egress`, or `egress+published-ports` to control workspace guest networking and service port publication. -- `workspace_create` accepts optional `secrets` and persists guest-only UTF-8 secret material outside `/workspace`. -- `workspace_list` returns persisted workspace summaries sorted by most recent `last_activity_at`. -- `workspace_sync_push` imports later host-side directory or archive content into a started workspace, with an optional `dest` under `/workspace`. -- `workspace_stop` stops one persistent workspace without deleting its `/workspace`, snapshots, or command history. -- `workspace_start` restarts one stopped workspace without resetting `/workspace`. -- `workspace_file_list`, `workspace_file_read`, and `workspace_file_write` provide structured live `/workspace` inspection and text edits without shell quoting. -- `workspace_export` exports one file or directory from `/workspace` to an explicit host path. -- `workspace_patch_apply` applies unified text patches for add/modify/delete operations under `/workspace`. -- `workspace_disk_export` copies the stopped guest-backed workspace rootfs as raw ext4 to an explicit host path. -- `workspace_disk_list` inspects a stopped guest-backed workspace rootfs offline without booting the guest. -- `workspace_disk_read` reads one regular file from a stopped guest-backed workspace rootfs offline. -- stopped-workspace disk tools require `state=stopped` and a guest-backed workspace; they fail on `host_compat`. -- `workspace_diff` compares the current `/workspace` tree to the immutable create-time baseline. -- `snapshot_create`, `snapshot_list`, and `snapshot_delete` manage explicit named snapshots in addition to the implicit `baseline`. -- `workspace_reset` recreates the full sandbox and restores `/workspace` from `baseline` or one named snapshot. -- `service_start`, `service_list`, `service_status`, `service_logs`, and `service_stop` manage persistent named services inside a started workspace. -- `service_start` accepts optional `published_ports` to expose guest TCP ports on `127.0.0.1` when the workspace network policy is `egress+published-ports`. -- `vm_exec` runs one command and auto-cleans that VM after the exec completes. -- `workspace_exec` accepts optional `secret_env` mappings for one exec call and leaves the workspace alive. -- `workspace_update` changes only discovery metadata such as `name` and key/value `labels`. -- `service_start` accepts optional `secret_env` mappings for one service start call. -- `shell_open` accepts optional `secret_env` mappings for the opened shell session. -- `shell_open`, `shell_read`, `shell_write`, `shell_signal`, and `shell_close` manage persistent PTY shells inside a started workspace. +- create one workspace, often without `seed_path` when the server already has a + project source +- sync or seed repo content +- inspect and edit files without shell quoting +- run commands repeatedly in one sandbox +- review the current session in one concise summary +- diff and export results +- reset and retry +- delete the workspace when the task is done -## Versioning Rule +Move to `workspace-full` only when the chat truly needs: -- `pyro-mcp` uses SemVer. -- Environment names are stable identifiers in the shipped catalog. -- Changing a public command name, public flag, public method name, public MCP tool name, or required request field is a breaking change. +- persistent PTY shell sessions +- long-running services and readiness probes +- secrets +- guest networking and published ports +- stopped-workspace disk inspection + +## Recipe-Backed Workflows + +The documented product workflows are: + +| Workflow | Recommended mode | Doc | +| --- | --- | --- | +| Cold-start repo validation | `cold-start` | [use-cases/cold-start-repo-validation.md](use-cases/cold-start-repo-validation.md) | +| Repro plus fix loop | `repro-fix` | [use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md) | +| Parallel isolated workspaces | `repro-fix` | [use-cases/parallel-workspaces.md](use-cases/parallel-workspaces.md) | +| Unsafe or untrusted code inspection | `inspect` | [use-cases/untrusted-inspection.md](use-cases/untrusted-inspection.md) | +| Review and evaluation workflows | `review-eval` | [use-cases/review-eval-workflows.md](use-cases/review-eval-workflows.md) | + +Treat this smoke pack as the trustworthy guest-backed verification path for the +advertised product: + +```bash +make smoke-use-cases +``` + +The chat-host MCP path above is the thing the docs are intentionally shaping +around. diff --git a/docs/roadmap/llm-chat-ergonomics.md b/docs/roadmap/llm-chat-ergonomics.md index 93fa8e4..440f528 100644 --- a/docs/roadmap/llm-chat-ergonomics.md +++ b/docs/roadmap/llm-chat-ergonomics.md @@ -6,12 +6,15 @@ goal: make the core agent-workspace use cases feel trivial from a chat-driven LLM interface. -Current baseline is `4.0.0`: +Current baseline is `4.5.0`: -- the stable workspace contract exists across CLI, SDK, and MCP -- one-shot `pyro run` still exists as the narrow entrypoint +- `pyro mcp serve` is now the default product entrypoint +- `workspace-core` is now the default MCP profile +- one-shot `pyro run` still exists as the terminal companion path - workspaces already support seeding, sync push, exec, export, diff, snapshots, reset, services, PTY shells, secrets, network policy, and published ports +- host-specific onramps exist for Claude Code, Codex, and OpenCode +- the five documented use cases are now recipe-backed and smoke-tested - stopped-workspace disk tools now exist, but remain explicitly secondary ## What "Trivial In Chat" Means @@ -33,9 +36,16 @@ More concretely, the model should not need to: - choose from an unnecessarily large tool surface when a smaller profile would work -The remaining UX friction for a technically strong new user is now narrower: +The next gaps for the narrowed persona are now about real-project credibility: -- no major chat-host ergonomics gaps remain in the current roadmap +- current-checkout startup is still brittle for messy local repos with unreadable, + generated, or permission-sensitive files +- the guest-backed smoke pack is strong, but it still proves shaped scenarios + better than arbitrary local-repo readiness +- the chat-host path still does not let users choose the sandbox environment as + a first-class part of host connection and server startup +- the product should not claim full whole-project development readiness until it + qualifies a real-project loop beyond fixture-shaped use cases ## Locked Decisions @@ -43,9 +53,24 @@ The remaining UX friction for a technically strong new user is now narrower: or runner abstractions - keep disk tools secondary and do not make them the main chat-facing surface - prefer narrow tool profiles and structured outputs over more raw shell calls -- capability milestones should update CLI, SDK, and MCP together +- optimize the MCP/chat-host path first and keep the CLI companion path good + enough to validate and debug it +- lower-level SDK and repo substrate work can continue, but they should not + drive milestone scope or naming - CLI-only ergonomics are allowed when the SDK and MCP surfaces already have the structured behavior natively +- prioritize repo-aware startup, trust, and daily-loop speed before adding more + low-level workspace surface area +- for repo-root auto-detection and `--project-path` inside a Git checkout, the + default project source should become Git-tracked files only +- `--repo-url` remains the clean-clone path when users do not want to trust the + local checkout as the startup source +- environment selection must become first-class in the chat-host path before the + product claims whole-project development readiness +- real-project readiness must be proven with guest-backed qualification smokes + that cover ignored, generated, and unreadable-file cases +- breaking changes are acceptable while there are still no users and the + chat-host product is still being shaped - every milestone below must also update docs, help text, runnable examples, and at least one real smoke scenario @@ -62,6 +87,16 @@ The remaining UX friction for a technically strong new user is now narrower: 9. [`3.10.0` Use-Case Smoke Trust And Recipe Fidelity](llm-chat-ergonomics/3.10.0-use-case-smoke-trust-and-recipe-fidelity.md) - Done 10. [`3.11.0` Host-Specific MCP Onramps](llm-chat-ergonomics/3.11.0-host-specific-mcp-onramps.md) - Done 11. [`4.0.0` Workspace-Core Default Profile](llm-chat-ergonomics/4.0.0-workspace-core-default-profile.md) - Done +12. [`4.1.0` Project-Aware Chat Startup](llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md) - Done +13. [`4.2.0` Host Bootstrap And Repair](llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md) - Done +14. [`4.3.0` Reviewable Agent Output](llm-chat-ergonomics/4.3.0-reviewable-agent-output.md) - Done +15. [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md) - Done +16. [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md) - Done +17. [`4.6.0` Git-Tracked Project Sources](llm-chat-ergonomics/4.6.0-git-tracked-project-sources.md) - Planned +18. [`4.7.0` Project Source Diagnostics And Recovery](llm-chat-ergonomics/4.7.0-project-source-diagnostics-and-recovery.md) - Planned +19. [`4.8.0` First-Class Chat Environment Selection](llm-chat-ergonomics/4.8.0-first-class-chat-environment-selection.md) - Planned +20. [`4.9.0` Real-Repo Qualification Smokes](llm-chat-ergonomics/4.9.0-real-repo-qualification-smokes.md) - Planned +21. [`5.0.0` Whole-Project Sandbox Development](llm-chat-ergonomics/5.0.0-whole-project-sandbox-development.md) - Planned Completed so far: @@ -92,10 +127,29 @@ Completed so far: config manually. - `4.0.0` flipped the default MCP/server profile to `workspace-core`, so the bare entrypoint now matches the recommended narrow chat-host profile across CLI, SDK, and package-level factories. +- `4.1.0` made repo-root startup native for chat hosts, so bare `pyro mcp serve` can auto-detect + the current Git checkout and let the first `workspace_create` omit `seed_path`, with explicit + `--project-path` and `--repo-url` fallbacks when cwd is not the source of truth. +- `4.2.0` adds first-class host bootstrap and repair helpers so Claude Code, + Codex, and OpenCode users can connect or repair the supported chat-host path + without manually composing raw MCP commands or config edits. +- `4.3.0` adds a concise workspace review surface so users can inspect what the + agent changed and ran since the last reset without reconstructing the + session from several lower-level views by hand. +- `4.4.0` adds named use-case modes so chat hosts can start from `repro-fix`, + `inspect`, `cold-start`, or `review-eval` instead of choosing from the full + generic workspace surface first. +- `4.5.0` adds `pyro prepare`, daily-loop readiness in `pyro doctor`, and a + real `make smoke-daily-loop` verification path so the local machine warmup + story is explicit before the chat host connects. Planned next: -- no further chat-ergonomics milestones are currently planned in this roadmap. +- [`4.6.0` Git-Tracked Project Sources](llm-chat-ergonomics/4.6.0-git-tracked-project-sources.md) +- [`4.7.0` Project Source Diagnostics And Recovery](llm-chat-ergonomics/4.7.0-project-source-diagnostics-and-recovery.md) +- [`4.8.0` First-Class Chat Environment Selection](llm-chat-ergonomics/4.8.0-first-class-chat-environment-selection.md) +- [`4.9.0` Real-Repo Qualification Smokes](llm-chat-ergonomics/4.9.0-real-repo-qualification-smokes.md) +- [`5.0.0` Whole-Project Sandbox Development](llm-chat-ergonomics/5.0.0-whole-project-sandbox-development.md) ## Expected Outcome @@ -117,3 +171,16 @@ The intended model-facing shape is: - human-mode content reads are copy-paste safe - the default bare MCP server entrypoint matches the recommended narrow profile - the five core use cases are documented and smoke-tested end to end +- starting from the current repo feels native from the first chat-host setup +- supported hosts can be connected or repaired without manual config spelunking +- users can review one concise summary of what the agent changed and ran +- the main workflows feel like named modes instead of one giant reference +- reset and retry loops are fast enough to encourage daily use +- repo-root startup is robust even when the local checkout contains ignored, + generated, or unreadable files +- chat-host users can choose the sandbox environment as part of the normal + connect/start path +- the product has guest-backed qualification for real local repos, not only + shaped fixture scenarios +- it becomes credible to tell a user they can develop a real project inside + sandboxes, not just evaluate or patch one diff --git a/docs/roadmap/llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md b/docs/roadmap/llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md new file mode 100644 index 0000000..e3e6986 --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md @@ -0,0 +1,56 @@ +# `4.1.0` Project-Aware Chat Startup + +Status: Done + +## Goal + +Make "current repo to disposable sandbox" the default story for the narrowed +chat-host user, without requiring manual workspace seeding choreography first. + +## Public API Changes + +The chat entrypoint should gain one documented project-aware startup path: + +- `pyro mcp serve` should accept an explicit local project source, such as the + current checkout +- the product path should optionally support a clean-clone source, such as a + repo URL, when the user is not starting from a local checkout +- the first useful chat turn should not depend on manually teaching + `workspace create ... --seed-path ...` before the host can do real work + +Exact flag names can still change, but the product needs one obvious "use this +repo" path and one obvious "start from that repo" path. + +## Implementation Boundaries + +- keep host crossing explicit; do not silently mutate the user's checkout +- prefer local checkout seeding first, because that is the most natural daily + chat path +- preserve existing explicit sync, export, diff, and reset primitives rather + than inventing a hidden live-sync layer +- keep the startup story compatible with the existing `workspace-core` product + path + +## Non-Goals + +- no generic SCM integration platform +- no background multi-repo workspace manager +- no always-on bidirectional live sync between host checkout and guest + +## Acceptance Scenarios + +- from a repo root, a user can connect Claude Code, Codex, or OpenCode and the + first workspace starts from that repo without extra terminal choreography +- from outside a repo checkout, a user can still start from a documented clean + source such as a repo URL +- the README and install docs can teach a repo-aware chat flow before the + manual terminal workspace flow + +## Required Repo Updates + +- README, install docs, first-run docs, integrations docs, and public contract + updated to show the repo-aware chat startup path +- help text updated so the repo-aware startup path is visible from `pyro` and + `pyro mcp serve --help` +- at least one recipe and one real smoke scenario updated to validate the new + startup story diff --git a/docs/roadmap/llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md b/docs/roadmap/llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md new file mode 100644 index 0000000..a9d07a4 --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md @@ -0,0 +1,53 @@ +# `4.2.0` Host Bootstrap And Repair + +Status: Done + +## Goal + +Make supported chat hosts feel one-command to connect and easy to repair when a +local config drifts or the product changes shape. + +## Public API Changes + +The CLI should grow a small host-helper surface for the supported chat hosts: + +- `pyro host connect claude-code` +- `pyro host connect codex` +- `pyro host print-config opencode` +- `pyro host doctor` +- `pyro host repair HOST` + +The exact names can still move, but the product needs a first-class bootstrap +and repair path for Claude Code, Codex, and OpenCode. + +## Implementation Boundaries + +- host helpers should wrap the same `pyro mcp serve` entrypoint rather than + introduce per-host runtime behavior +- config changes should remain inspectable and predictable +- support both installed-package and `uvx`-style usage where that materially + reduces friction +- keep the host helper story narrow to the current supported hosts + +## Non-Goals + +- no GUI installer or onboarding wizard +- no attempt to support every possible MCP-capable editor or chat shell +- no hidden network service or account-based control plane + +## Acceptance Scenarios + +- a new Claude Code or Codex user can connect `pyro` with one command +- an OpenCode user can print or materialize a correct config without hand-writing + JSON +- a user with a stale or broken local host config can run one repair or doctor + flow instead of debugging MCP setup manually + +## Required Repo Updates + +- new host-helper docs and examples for all supported chat hosts +- README, install docs, and integrations docs updated to prefer the helper + flows when available +- help text updated with exact connect and repair commands +- runnable verification or smoke coverage that proves the shipped host-helper + examples stay current diff --git a/docs/roadmap/llm-chat-ergonomics/4.3.0-reviewable-agent-output.md b/docs/roadmap/llm-chat-ergonomics/4.3.0-reviewable-agent-output.md new file mode 100644 index 0000000..b1b02ba --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.3.0-reviewable-agent-output.md @@ -0,0 +1,54 @@ +# `4.3.0` Reviewable Agent Output + +Status: Done + +## Goal + +Make it easy for a human to review what the agent actually did inside the +sandbox without manually reconstructing the session from diffs, logs, and raw +artifacts. + +## Public API Changes + +The product should expose a concise workspace review surface, for example: + +- `pyro workspace summary WORKSPACE_ID` +- `workspace_summary` on the MCP side +- structured JSON plus a short human-readable summary view + +The summary should cover the things a chat-host user cares about: + +- commands run +- files changed +- diff or patch summary +- services started +- artifacts exported +- final workspace outcome + +## Implementation Boundaries + +- prefer concise review surfaces over raw event firehoses +- keep raw logs, diffs, and exported files available as drill-down tools +- summarize only the sandbox activity the product can actually observe +- make the summary good enough to paste into a chat, bug report, or PR comment + +## Non-Goals + +- no full compliance or audit product +- no attempt to summarize the model's hidden reasoning +- no remote storage backend for session history + +## Acceptance Scenarios + +- after a repro-fix or review-eval run, a user can inspect one summary and + understand what changed and what to review next +- the summary is useful enough to accompany exported patches or artifacts +- unsafe-inspection and review-eval flows become easier to trust because the + user can review agent-visible actions in one place + +## Required Repo Updates + +- public contract, help text, README, and recipe docs updated with the new + summary path +- at least one host-facing example showing how to ask for or export the summary +- at least one real smoke scenario validating the review surface end to end diff --git a/docs/roadmap/llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md b/docs/roadmap/llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md new file mode 100644 index 0000000..52d2d22 --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md @@ -0,0 +1,56 @@ +# `4.4.0` Opinionated Use-Case Modes + +Status: Done + +## Goal + +Stop making chat-host users think in terms of one giant workspace surface and +let them start from a small mode that matches the job they want the agent to do. + +## Public API Changes + +The chat entrypoint should gain named use-case modes, for example: + +- `pyro mcp serve --mode repro-fix` +- `pyro mcp serve --mode inspect` +- `pyro mcp serve --mode cold-start` +- `pyro mcp serve --mode review-eval` + +Modes should narrow the product story by selecting the right defaults for: + +- tool surface +- workspace bootstrap behavior +- docs and example prompts +- expected export and review outputs + +Parallel workspace use should come from opening more than one named workspace +inside the same mode, not from introducing a scheduler or queue abstraction. + +## Implementation Boundaries + +- build modes on top of the existing `workspace-core` and `workspace-full` + capabilities instead of inventing separate backends +- keep the mode list short and mapped to the documented use cases +- make modes visible from help text, host helpers, and recipe docs together +- let users opt out to the generic workspace path when the mode is too narrow + +## Non-Goals + +- no user-defined mode DSL +- no hidden host-specific behavior for the same mode name +- no CI-style pipelines, matrix builds, or queueing abstractions + +## Acceptance Scenarios + +- a new user can pick one mode and avoid reading the full workspace surface + before starting +- the documented use cases map cleanly to named entry modes +- parallel issue or PR work feels like "open another workspace in the same + mode", not "submit another job" + +## Required Repo Updates + +- help text, README, install docs, integrations docs, and use-case recipes + updated to teach the named modes +- host-specific setup docs updated so supported hosts can start in a named mode +- at least one smoke scenario proving a mode-specific happy path end to end diff --git a/docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md b/docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md new file mode 100644 index 0000000..ecbd8b8 --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md @@ -0,0 +1,57 @@ +# `4.5.0` Faster Daily Loops + +Status: Done + +## Goal + +Make the day-to-day chat-host loop feel cheap enough that users reach for it +for normal work, not only for special high-isolation tasks. + +## Public API Changes + +The product now adds an explicit fast-path for repeated local use: + +- `pyro prepare [ENVIRONMENT] [--network] [--force] [--json]` +- `pyro doctor --environment ENVIRONMENT` daily-loop readiness output +- `make smoke-daily-loop` to prove the warmed machine plus reset/retry story + +The exact command names can still move, but the user-visible story needs to be: + +- set the machine up once +- reconnect quickly +- create or reset a workspace cheaply +- keep iterating without redoing heavy setup work + +## Implementation Boundaries + +- optimize local-first loops on one machine before thinking about remote + execution +- focus on startup, create, reset, and retry latency rather than queue + throughput +- keep the fast path compatible with the repo-aware startup story and the + supported chat hosts +- prefer explicit caching and prewarm semantics over hidden long-running + daemons + +## Non-Goals + +- no cloud prewarm service +- no scheduler or queueing layer +- no daemon requirement for normal daily use + +## Acceptance Scenarios + +- after the first setup, entering the chat-host path again does not feel like + redoing the whole product onboarding +- reset and retry become cheap enough to recommend as the default repro-fix + workflow +- docs can present `pyro` as a daily coding-agent tool, not only as a special + heavy-duty sandbox + +## Required Repo Updates + +- docs now show the recommended daily-use fast path +- diagnostics and help text now show whether the machine is already warm and + ready +- the repo now includes `make smoke-daily-loop` as a repeat-loop verification + scenario for the daily workflow diff --git a/docs/roadmap/llm-chat-ergonomics/4.6.0-git-tracked-project-sources.md b/docs/roadmap/llm-chat-ergonomics/4.6.0-git-tracked-project-sources.md new file mode 100644 index 0000000..b170b21 --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.6.0-git-tracked-project-sources.md @@ -0,0 +1,55 @@ +# `4.6.0` Git-Tracked Project Sources + +Status: Planned + +## Goal + +Make repo-root startup and `--project-path` robust for messy real checkouts by +stopping the default chat-host path from trying to ingest every readable and +unreadable file in the working tree. + +## Public API Changes + +Project-aware startup should change its default local source semantics: + +- bare `pyro mcp serve` from inside a Git checkout should seed from Git-tracked + files only +- `pyro mcp serve --project-path PATH` should also use Git-tracked files only + when `PATH` is inside a Git checkout +- `--repo-url` remains the clean-clone path when the user wants a host-side + clone instead of the local checkout +- explicit `workspace create --seed-path PATH` remains unchanged in this + milestone + +## Implementation Boundaries + +- apply the new semantics only to project-aware startup sources, not every + explicit directory seed +- do not silently include ignored or untracked junk in the default chat-host + path +- preserve explicit diff, export, sync push, and reset behavior +- surface the chosen project source clearly enough that users know what the + sandbox started from + +## Non-Goals + +- no generic SCM abstraction layer +- no silent live sync between the host checkout and the guest +- no change to explicit archive seeding semantics in this milestone + +## Acceptance Scenarios + +- starting `pyro mcp serve` from a repo root no longer fails on unreadable + build artifacts or ignored runtime byproducts +- starting from `--project-path` inside a Git repo behaves the same way +- users can predict that the startup source matches the tracked project state + rather than the entire working tree + +## Required Repo Updates + +- README, install docs, integrations docs, and public contract updated to state + what local project-aware startup actually includes +- help text updated to distinguish project-aware startup from explicit + `--seed-path` behavior +- at least one guest-backed smoke scenario added for a repo with ignored, + generated, and unreadable files diff --git a/docs/roadmap/llm-chat-ergonomics/4.7.0-project-source-diagnostics-and-recovery.md b/docs/roadmap/llm-chat-ergonomics/4.7.0-project-source-diagnostics-and-recovery.md new file mode 100644 index 0000000..a758e79 --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.7.0-project-source-diagnostics-and-recovery.md @@ -0,0 +1,50 @@ +# `4.7.0` Project Source Diagnostics And Recovery + +Status: Planned + +## Goal + +Make project-source selection and startup failures understandable enough that a +chat-host user can recover without reading internals or raw tracebacks. + +## Public API Changes + +The chat-host path should expose clearer project-source diagnostics: + +- `pyro doctor` should report the active project-source kind and its readiness +- `pyro mcp serve` and host helpers should explain whether they are using + tracked local files, `--project-path`, `--repo-url`, or no project source +- startup failures should recommend the right fallback: + `--project-path`, `--repo-url`, `--no-project-source`, or explicit + `seed_path` + +## Implementation Boundaries + +- keep diagnostics focused on the chat-host path rather than inventing a broad + source-management subsystem +- prefer actionable recovery guidance over long implementation detail dumps +- make project-source diagnostics visible from the same surfaces users already + touch: help text, `doctor`, host helpers, and startup errors + +## Non-Goals + +- no generic repo-health audit product +- no attempt to auto-fix arbitrary local checkout corruption +- no host-specific divergence in project-source behavior + +## Acceptance Scenarios + +- a user can tell which project source the chat host will use before creating a + workspace +- a user who hits a project-source failure gets a concrete recovery path instead + of a raw permission traceback +- host helper doctor and repair flows can explain project-source problems, not + only MCP config problems + +## Required Repo Updates + +- docs, help text, and troubleshooting updated with project-source diagnostics + and fallback guidance +- at least one smoke or targeted CLI test covering the new failure guidance +- host-helper docs updated to show when to prefer `--project-path`, + `--repo-url`, or `--no-project-source` diff --git a/docs/roadmap/llm-chat-ergonomics/4.8.0-first-class-chat-environment-selection.md b/docs/roadmap/llm-chat-ergonomics/4.8.0-first-class-chat-environment-selection.md new file mode 100644 index 0000000..fcb80eb --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.8.0-first-class-chat-environment-selection.md @@ -0,0 +1,52 @@ +# `4.8.0` First-Class Chat Environment Selection + +Status: Planned + +## Goal + +Make curated environment choice part of the normal chat-host path so full +project work is not implicitly tied to one default environment. + +## Public API Changes + +Environment selection should become first-class in the chat-host path: + +- `pyro mcp serve` should accept an explicit environment +- `pyro host connect` should accept and preserve an explicit environment +- `pyro host print-config` and `pyro host repair` should preserve the selected + environment where relevant +- named modes should be able to recommend a default environment when one is + better for the workflow, without removing explicit user choice + +## Implementation Boundaries + +- keep environment selection aligned with the existing curated environment + catalog +- avoid inventing host-specific environment behavior for the same mode +- keep the default environment path simple for the quickest evaluator flow +- ensure the chosen environment is visible from generated config, help text, and + diagnostics + +## Non-Goals + +- no custom user-built environment pipeline in this milestone +- no per-host environment negotiation logic +- no attempt to solve arbitrary dependency installation through environment + sprawl alone + +## Acceptance Scenarios + +- a user can choose a build-oriented environment such as `debian:12-build` + before connecting the chat host +- host helpers, raw server startup, and printed configs all preserve the same + environment choice +- docs can teach whole-project development without pretending every project fits + the same default environment + +## Required Repo Updates + +- README, install docs, integrations docs, public contract, and host examples + updated to show environment selection in the chat-host path +- help text updated for raw server startup and host helpers +- at least one guest-backed smoke scenario updated to prove a non-default + environment in the chat-host flow diff --git a/docs/roadmap/llm-chat-ergonomics/4.9.0-real-repo-qualification-smokes.md b/docs/roadmap/llm-chat-ergonomics/4.9.0-real-repo-qualification-smokes.md new file mode 100644 index 0000000..04bea7c --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.9.0-real-repo-qualification-smokes.md @@ -0,0 +1,52 @@ +# `4.9.0` Real-Repo Qualification Smokes + +Status: Planned + +## Goal + +Replace fixture-only confidence with guest-backed proof that the chat-host path +works against messy local repos and clean-clone startup sources. + +## Public API Changes + +No new runtime surface is required in this milestone. The main additions are +qualification smokes and their supporting fixtures. + +The new coverage should prove: + +- repo-root startup from a local Git checkout with ignored, generated, and + unreadable files +- `--repo-url` clean-clone startup +- a realistic install, test, patch, rerun, and export loop +- at least one nontrivial service-start or readiness loop + +## Implementation Boundaries + +- keep the smoke pack guest-backed and deterministic enough to use as a release + gate +- focus on realistic repo-shape and project-loop problems, not synthetic + micro-feature assertions +- prefer a small number of representative project fixtures over a large matrix + of toy repos + +## Non-Goals + +- no promise to qualify every language ecosystem in one milestone +- no cloud or remote execution qualification layer +- no broad benchmark suite beyond what is needed to prove readiness + +## Acceptance Scenarios + +- the repo has one clear smoke target for real-repo qualification +- at least one local-checkout smoke proves the new Git-tracked startup behavior +- at least one clean-clone smoke proves the `--repo-url` path +- failures in these smokes clearly separate project-source issues from runtime + or host issues + +## Required Repo Updates + +- new guest-backed smoke targets and any supporting fixtures +- roadmap, use-case docs, and release/readiness docs updated to point at the + new qualification path +- troubleshooting updated with the distinction between shaped use-case smokes + and real-repo qualification smokes diff --git a/docs/roadmap/llm-chat-ergonomics/5.0.0-whole-project-sandbox-development.md b/docs/roadmap/llm-chat-ergonomics/5.0.0-whole-project-sandbox-development.md new file mode 100644 index 0000000..ba2195a --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/5.0.0-whole-project-sandbox-development.md @@ -0,0 +1,53 @@ +# `5.0.0` Whole-Project Sandbox Development + +Status: Planned + +## Goal + +Reach the point where it is credible to tell a user they can develop a real +project inside sandboxes, not just validate, inspect, or patch one. + +## Public API Changes + +No new generic VM breadth is required here. This milestone should consolidate +the earlier pieces into one believable full-project product story: + +- robust project-aware startup +- explicit environment selection in the chat-host path +- summaries, reset, export, and service workflows that hold up during longer + work loops +- qualification targets that prove a nontrivial development cycle + +## Implementation Boundaries + +- keep the product centered on the chat-host workspace path rather than a broad + CLI or SDK platform story +- use the existing named modes and generic workspace path where they fit, but + teach one end-to-end full-project development walkthrough +- prioritize daily development credibility over adding new low-level sandbox + surfaces + +## Non-Goals + +- no attempt to become a generic remote dev environment platform +- no scheduler, queue, or CI matrix abstractions +- no claim that every possible project type is equally well supported + +## Acceptance Scenarios + +- the docs contain one end-to-end “develop a project in sandboxes” walkthrough +- that walkthrough covers dependency install, tests, patching, reruns, review, + and export, with app/service startup when relevant +- at least one guest-backed qualification target proves the story on a + nontrivial project +- the readiness docs can honestly say whole-project development is supported + with explicit caveats instead of hedged aspirational language + +## Required Repo Updates + +- README, install docs, integrations docs, use-case docs, and public contract + updated to include the whole-project development story +- at least one walkthrough asset or transcript added for the new end-to-end + path +- readiness and troubleshooting docs updated with the actual supported scope and + remaining caveats diff --git a/docs/use-cases/README.md b/docs/use-cases/README.md index 15911d6..e471332 100644 --- a/docs/use-cases/README.md +++ b/docs/use-cases/README.md @@ -1,6 +1,6 @@ # Workspace Use-Case Recipes -These recipes turn the stable workspace surface into five concrete agent flows. +These recipes turn the chat-host workspace path into five concrete agent flows. They are the canonical next step after the quickstart in [install.md](../install.md) or [first-run.md](../first-run.md). @@ -12,13 +12,13 @@ make smoke-use-cases Recipe matrix: -| Use case | Recommended profile | Smoke target | Recipe | +| Use case | Recommended mode | Smoke target | Recipe | | --- | --- | --- | --- | -| Cold-start repo validation | `workspace-full` | `make smoke-cold-start-validation` | [cold-start-repo-validation.md](cold-start-repo-validation.md) | -| Repro plus fix loop | `workspace-core` | `make smoke-repro-fix-loop` | [repro-fix-loop.md](repro-fix-loop.md) | -| Parallel isolated workspaces | `workspace-core` | `make smoke-parallel-workspaces` | [parallel-workspaces.md](parallel-workspaces.md) | -| Unsafe or untrusted code inspection | `workspace-core` | `make smoke-untrusted-inspection` | [untrusted-inspection.md](untrusted-inspection.md) | -| Review and evaluation workflows | `workspace-full` | `make smoke-review-eval` | [review-eval-workflows.md](review-eval-workflows.md) | +| Cold-start repo validation | `cold-start` | `make smoke-cold-start-validation` | [cold-start-repo-validation.md](cold-start-repo-validation.md) | +| Repro plus fix loop | `repro-fix` | `make smoke-repro-fix-loop` | [repro-fix-loop.md](repro-fix-loop.md) | +| Parallel isolated workspaces | `repro-fix` | `make smoke-parallel-workspaces` | [parallel-workspaces.md](parallel-workspaces.md) | +| Unsafe or untrusted code inspection | `inspect` | `make smoke-untrusted-inspection` | [untrusted-inspection.md](untrusted-inspection.md) | +| Review and evaluation workflows | `review-eval` | `make smoke-review-eval` | [review-eval-workflows.md](review-eval-workflows.md) | All five recipes use the same real Firecracker-backed smoke runner: @@ -30,3 +30,9 @@ That runner generates its own host fixtures, creates real guest-backed workspace verifies the intended flow, exports one concrete result when relevant, and cleans up on both success and failure. Treat `make smoke-use-cases` as the trustworthy guest-backed verification path for the advertised workspace workflows. + +For a concise review before exporting, resetting, or handing work off, use: + +```bash +pyro workspace summary WORKSPACE_ID +``` diff --git a/docs/use-cases/cold-start-repo-validation.md b/docs/use-cases/cold-start-repo-validation.md index f856906..763210b 100644 --- a/docs/use-cases/cold-start-repo-validation.md +++ b/docs/use-cases/cold-start-repo-validation.md @@ -1,6 +1,12 @@ # Cold-Start Repo Validation -Recommended profile: `workspace-full` +Recommended mode: `cold-start` + +Recommended startup: + +```bash +pyro host connect claude-code --mode cold-start +``` Smoke target: @@ -12,26 +18,18 @@ Use this flow when an agent needs to treat a fresh repo like a new user would: seed it into a workspace, run the validation script, keep one long-running process alive, probe it from another command, and export a validation report. -Canonical SDK flow: +Chat-host recipe: -```python -from pyro_mcp import Pyro +1. Create one workspace from the repo seed. +2. Run the validation command inside that workspace. +3. Start the app as a long-running service with readiness configured. +4. Probe the ready service from another command in the same workspace. +5. Export the validation report back to the host. +6. Delete the workspace when the evaluation is done. -pyro = Pyro() -created = pyro.create_workspace(environment="debian:12", seed_path="./repo") -workspace_id = str(created["workspace_id"]) - -pyro.exec_workspace(workspace_id, command="sh validate.sh") -pyro.start_service( - workspace_id, - "app", - command="sh serve.sh", - readiness={"type": "file", "path": ".app-ready"}, -) -pyro.exec_workspace(workspace_id, command="sh -lc 'test -f .app-ready && cat service-state.txt'") -pyro.export_workspace(workspace_id, "validation-report.txt", output_path="./validation-report.txt") -pyro.delete_workspace(workspace_id) -``` +If the named mode feels too narrow, fall back to the generic no-mode path and +then opt into `--profile workspace-full` only when you truly need the larger +advanced surface. This recipe is intentionally guest-local and deterministic. It proves startup, service readiness, validation, and host-out report capture without depending on diff --git a/docs/use-cases/parallel-workspaces.md b/docs/use-cases/parallel-workspaces.md index ddc29b7..685f6a4 100644 --- a/docs/use-cases/parallel-workspaces.md +++ b/docs/use-cases/parallel-workspaces.md @@ -1,6 +1,12 @@ # Parallel Isolated Workspaces -Recommended profile: `workspace-core` +Recommended mode: `repro-fix` + +Recommended startup: + +```bash +pyro host connect codex --mode repro-fix +``` Smoke target: @@ -11,33 +17,16 @@ make smoke-parallel-workspaces Use this flow when the agent needs one isolated workspace per issue, branch, or review thread and must rediscover the right one later. -Canonical SDK flow: +Chat-host recipe: -```python -from pyro_mcp import Pyro - -pyro = Pyro() -alpha = pyro.create_workspace( - environment="debian:12", - seed_path="./repo", - name="parallel-alpha", - labels={"branch": "alpha", "issue": "123"}, -) -beta = pyro.create_workspace( - environment="debian:12", - seed_path="./repo", - name="parallel-beta", - labels={"branch": "beta", "issue": "456"}, -) - -pyro.write_workspace_file(alpha["workspace_id"], "branch.txt", text="alpha\n") -pyro.write_workspace_file(beta["workspace_id"], "branch.txt", text="beta\n") -pyro.update_workspace(alpha["workspace_id"], labels={"branch": "alpha", "owner": "alice"}) -pyro.list_workspaces() -pyro.delete_workspace(alpha["workspace_id"]) -pyro.delete_workspace(beta["workspace_id"]) -``` +1. Create one workspace per issue or branch with a human-friendly name and + labels. +2. Mutate each workspace independently. +3. Rediscover the right workspace later with `workspace_list`. +4. Update metadata when ownership or issue mapping changes. +5. Delete each workspace independently when its task is done. The important proof here is operational, not syntactic: names, labels, list ordering, and file contents stay isolated even when multiple workspaces are -active at the same time. +active at the same time. Parallel work still means “open another workspace in +the same mode,” not “pick a special parallel-work mode.” diff --git a/docs/use-cases/repro-fix-loop.md b/docs/use-cases/repro-fix-loop.md index f302974..ad920c5 100644 --- a/docs/use-cases/repro-fix-loop.md +++ b/docs/use-cases/repro-fix-loop.md @@ -1,6 +1,12 @@ # Repro Plus Fix Loop -Recommended profile: `workspace-core` +Recommended mode: `repro-fix` + +Recommended startup: + +```bash +pyro host connect codex --mode repro-fix +``` Smoke target: @@ -12,31 +18,21 @@ Use this flow when the agent has to reproduce a bug, patch files without shell quoting tricks, rerun the failing command, diff the result, export the fix, and reset back to baseline. -Canonical SDK flow: +Chat-host recipe: -```python -from pyro_mcp import Pyro +1. Start the server from the repo root with bare `pyro mcp serve`, or use + `--project-path` if the host does not preserve cwd. +2. Create one workspace from that project-aware server without manually passing + `seed_path`. +3. Run the failing command. +4. Inspect the broken file with structured file reads. +5. Apply the fix with `workspace_patch_apply`. +6. Rerun the failing command in the same workspace. +7. Diff and export the changed result. +8. Reset to baseline and delete the workspace. -pyro = Pyro() -created = pyro.create_workspace(environment="debian:12", seed_path="./broken-repro") -workspace_id = str(created["workspace_id"]) +If the mode feels too narrow for the job, fall back to the generic bare +`pyro mcp serve` path. -pyro.exec_workspace(workspace_id, command="sh check.sh") -pyro.read_workspace_file(workspace_id, "message.txt") -pyro.apply_workspace_patch( - workspace_id, - patch="--- a/message.txt\n+++ b/message.txt\n@@ -1 +1 @@\n-broken\n+fixed\n", -) -pyro.exec_workspace(workspace_id, command="sh check.sh") -pyro.diff_workspace(workspace_id) -pyro.export_workspace(workspace_id, "message.txt", output_path="./message.txt") -pyro.reset_workspace(workspace_id) -pyro.delete_workspace(workspace_id) -``` - -Canonical MCP/chat example: - -- [examples/openai_responses_workspace_core.py](../../examples/openai_responses_workspace_core.py) - -This is the main `workspace-core` story: model-native file ops, repeatable exec, +This is the main `repro-fix` story: model-native file ops, repeatable exec, structured diff, explicit export, and reset-over-repair. diff --git a/docs/use-cases/review-eval-workflows.md b/docs/use-cases/review-eval-workflows.md index eabe981..4012c34 100644 --- a/docs/use-cases/review-eval-workflows.md +++ b/docs/use-cases/review-eval-workflows.md @@ -1,6 +1,12 @@ # Review And Evaluation Workflows -Recommended profile: `workspace-full` +Recommended mode: `review-eval` + +Recommended startup: + +```bash +pyro host connect claude-code --mode review-eval +``` Smoke target: @@ -11,30 +17,15 @@ make smoke-review-eval Use this flow when an agent needs to read a checklist interactively, run an evaluation script, checkpoint or reset its changes, and export the final report. -Canonical SDK flow: +Chat-host recipe: -```python -from pyro_mcp import Pyro - -pyro = Pyro() -created = pyro.create_workspace(environment="debian:12", seed_path="./review-fixture") -workspace_id = str(created["workspace_id"]) - -pyro.create_snapshot(workspace_id, "pre-review") -shell = pyro.open_shell(workspace_id) -pyro.write_shell(workspace_id, shell["shell_id"], input="cat CHECKLIST.md") -pyro.read_shell( - workspace_id, - shell["shell_id"], - plain=True, - wait_for_idle_ms=300, -) -pyro.close_shell(workspace_id, shell["shell_id"]) -pyro.exec_workspace(workspace_id, command="sh review.sh") -pyro.export_workspace(workspace_id, "review-report.txt", output_path="./review-report.txt") -pyro.reset_workspace(workspace_id, snapshot="pre-review") -pyro.delete_workspace(workspace_id) -``` +1. Create a named snapshot before the review starts. +2. Open a readable PTY shell and inspect the checklist interactively. +3. Run the review or evaluation script in the same workspace. +4. Capture `workspace summary` to review what changed and what to export. +5. Export the final report. +6. Reset back to the snapshot if the review branch goes sideways. +7. Delete the workspace when the evaluation is done. This is the stable shell-facing story: readable PTY output for chat loops, checkpointed evaluation, explicit export, and reset when a review branch goes diff --git a/docs/use-cases/untrusted-inspection.md b/docs/use-cases/untrusted-inspection.md index a089faa..aab7ada 100644 --- a/docs/use-cases/untrusted-inspection.md +++ b/docs/use-cases/untrusted-inspection.md @@ -1,6 +1,12 @@ # Unsafe Or Untrusted Code Inspection -Recommended profile: `workspace-core` +Recommended mode: `inspect` + +Recommended startup: + +```bash +pyro host connect codex --mode inspect +``` Smoke target: @@ -11,24 +17,13 @@ make smoke-untrusted-inspection Use this flow when the agent needs to inspect suspicious code or an unfamiliar repo without granting more capabilities than necessary. -Canonical SDK flow: +Chat-host recipe: -```python -from pyro_mcp import Pyro - -pyro = Pyro() -created = pyro.create_workspace(environment="debian:12", seed_path="./suspicious-repo") -workspace_id = str(created["workspace_id"]) - -pyro.list_workspace_files(workspace_id, path="/workspace", recursive=True) -pyro.read_workspace_file(workspace_id, "suspicious.sh") -pyro.exec_workspace( - workspace_id, - command="sh -lc \"grep -n 'curl' suspicious.sh > inspection-report.txt\"", -) -pyro.export_workspace(workspace_id, "inspection-report.txt", output_path="./inspection-report.txt") -pyro.delete_workspace(workspace_id) -``` +1. Create one workspace from the suspicious repo seed. +2. Inspect the tree with structured file listing and file reads. +3. Run the smallest possible command that produces the inspection report. +4. Export only the report the agent chose to materialize. +5. Delete the workspace when inspection is complete. This recipe stays offline-by-default, uses only explicit file reads and execs, and exports only the inspection report the agent chose to materialize. diff --git a/docs/vision.md b/docs/vision.md index cdf852c..2192cce 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -1,16 +1,19 @@ # Vision -`pyro-mcp` should become the disposable sandbox where an agent can do real -development work safely, repeatedly, and reproducibly. +`pyro-mcp` should become the disposable MCP workspace for chat-based coding +agents. -That is a different product from a generic VM wrapper, a secure CI runner, or a -task queue with better isolation. +That is a different product from a generic VM wrapper, a secure CI runner, or +an SDK-first platform. + +`pyro-mcp` currently has no users. That means we can still make breaking +changes freely while we shape the chat-host path into the right product. ## Core Thesis The goal is not just to run one command in a microVM. -The goal is to give an LLM or coding agent a bounded workspace where it can: +The goal is to give a chat-hosted coding agent a bounded workspace where it can: - inspect a repo - install dependencies @@ -23,6 +26,25 @@ The goal is to give an LLM or coding agent a bounded workspace where it can: The sandbox is the execution boundary for agentic software work. +## Current Product Focus + +The product path should be obvious and narrow: + +- Claude Code +- Codex +- OpenCode +- Linux `x86_64` with KVM + +The happy path is: + +1. prove the host with the terminal companion commands +2. run `pyro mcp serve` +3. connect a chat host +4. work through one disposable workspace per task + +The repo can contain lower-level building blocks, but they should not drive the +product story. + ## What This Is Not `pyro-mcp` should not drift into: @@ -32,9 +54,10 @@ The sandbox is the execution boundary for agentic software work. - a generic CI job runner - a scheduler or queueing platform - a broad VM orchestration product +- an SDK product that happens to have an MCP server on the side -Those products optimize for queued work, throughput, retries, matrix builds, and -shared infrastructure. +Those products optimize for queued work, throughput, retries, matrix builds, or +library ergonomics. `pyro-mcp` should optimize for agent loops: @@ -57,10 +80,15 @@ Any sandbox product starts to look like CI if the main abstraction is: That shape is useful, but it is not the center of the vision. To stay aligned, the primary abstraction should be a workspace the agent -inhabits, not a job the agent submits. +inhabits from a chat host, not a job the agent submits to a runner. ## Product Principles +### Chat Hosts First + +The product should be shaped around the MCP path used from chat interfaces. +Everything else is there to support, debug, or build that path. + ### Workspace-First The default mental model should be "open a disposable workspace" rather than @@ -85,11 +113,6 @@ Anything that crosses the host boundary should be intentional and visible: Agents should be able to checkpoint, reset, and retry cheaply. Disposable state is a feature, not a limitation. -### Same Contract Across Surfaces - -CLI, Python, and MCP should expose the same underlying workspace model so the -product feels coherent no matter how it is consumed. - ### Agent-Native Observability The sandbox should expose the things an agent actually needs to reason about: @@ -101,10 +124,16 @@ The sandbox should expose the things an agent actually needs to reason about: - readiness - exported results -## The Shape Of An LLM-First Sandbox +## The Shape Of The Product -The strongest future direction is a small, agent-native contract built around -workspaces, shells, files, services, and reset. +The strongest direction is a small chat-facing contract built around: + +- one MCP server +- one disposable workspace model +- structured file inspection and edits +- repeated commands in the same sandbox +- service lifecycle when the workflow needs it +- reset as a first-class workflow primitive Representative primitives: @@ -114,95 +143,57 @@ Representative primitives: - `workspace.sync_push` - `workspace.export` - `workspace.diff` -- `workspace.snapshot` - `workspace.reset` +- `workspace.exec` - `shell.open` - `shell.read` - `shell.write` -- `shell.signal` -- `shell.close` -- `workspace.exec` - `service.start` - `service.status` - `service.logs` -- `service.stop` -These names are illustrative, not a committed public API. - -The important point is the interaction model: - -- a shell session is interactive state inside the sandbox -- a workspace is durable for the life of the task -- services are first-class, not accidental background jobs -- reset is a core workflow primitive +These names are illustrative, not a promise that every lower-level repo surface +should be treated as equally stable or equally important. ## Interactive Shells And Disk Operations Interactive shells are aligned with the vision because they make the agent feel present inside the sandbox rather than reduced to one-shot job submission. -That does not mean `pyro-mcp` should become a raw SSH replacement. The shell -should sit inside a higher-level workspace model with structured file, service, -diff, and reset operations around it. +They should remain subordinate to the workspace model, not replace it with a +raw SSH story. -Disk-level operations are also useful, but they should remain supporting tools. -They are good for: +Disk-level operations are useful for: - fast workspace seeding - snapshotting - offline inspection -- diffing - export/import without a full boot -They should not become the primary product identity. If the center of the -product becomes "operate on VM disks", it will read as image tooling rather -than an agent workspace. +They should remain supporting tools rather than the product identity. ## What To Build Next -Features should be prioritized in this order: +Features should keep reinforcing the chat-host path in this order: -1. Repeated commands in one persistent workspace -2. Interactive shell sessions with PTY semantics -3. Structured workspace sync and export -4. Service lifecycle and readiness checks -5. Snapshot and reset workflows -6. Explicit secrets and network policy -7. Secondary disk-level import/export and inspection tools +1. make the first chat-host setup painfully obvious +2. make the recipe-backed workflows feel trivial from chat +3. keep the smoke pack trustworthy enough to gate the advertised stories +4. keep the terminal companion path good enough to debug what the chat sees +5. let lower-level repo surfaces move freely when the chat-host product needs it The completed workspace GA roadmap lives in [roadmap/task-workspace-ga.md](roadmap/task-workspace-ga.md). -The next implementation milestones that make those workflows feel natural from -chat-driven LLM interfaces live in +The follow-on milestones that make the chat-host path clearer live in [roadmap/llm-chat-ergonomics.md](roadmap/llm-chat-ergonomics.md). -## Naming Guidance - -Prefer language that reinforces the workspace model: - -- `workspace` -- `sandbox` -- `shell` -- `service` -- `snapshot` -- `reset` - -Avoid centering language that makes the product feel like CI infrastructure: - -- `job` -- `runner` -- `pipeline` -- `worker` -- `queue` -- `build matrix` - ## Litmus Test When evaluating a new feature, ask: -"Does this help an agent inhabit a safe disposable workspace and do real -software work inside it?" +"Does this make Claude Code, Codex, or OpenCode feel more natural and powerful +when they work inside a disposable sandbox?" -If the better description is "it helps submit, schedule, and report jobs", the -feature is probably pushing the product in the wrong direction. +If the better description is "it helps build a broader VM toolkit or SDK", it +is probably pushing the product in the wrong direction. diff --git a/examples/claude_code_mcp.md b/examples/claude_code_mcp.md index d62cae4..de9931c 100644 --- a/examples/claude_code_mcp.md +++ b/examples/claude_code_mcp.md @@ -1,21 +1,49 @@ # Claude Code MCP Setup -Recommended profile: `workspace-core`. +Recommended modes: + +- `cold-start` +- `review-eval` + +Preferred helper flow: + +```bash +pyro host connect claude-code --mode cold-start +pyro host connect claude-code --mode review-eval +pyro host doctor --mode cold-start +``` Package without install: ```bash -claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve +claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start claude mcp list ``` +Run that from the repo root when you want the first `workspace_create` to start +from the current checkout automatically. + Already installed: ```bash -claude mcp add pyro -- pyro mcp serve +claude mcp add pyro -- pyro mcp serve --mode cold-start claude mcp list ``` +If Claude Code launches the server from an unexpected cwd, pin the project +explicitly: + +```bash +pyro host connect claude-code --mode cold-start --project-path /abs/path/to/repo +claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start --project-path /abs/path/to/repo +``` + +If the local config drifts later: + +```bash +pyro host repair claude-code --mode cold-start +``` + Move to `workspace-full` only when the chat truly needs shells, services, snapshots, secrets, network policy, or disk tools: diff --git a/examples/codex_mcp.md b/examples/codex_mcp.md index 6838a7d..53f3c7b 100644 --- a/examples/codex_mcp.md +++ b/examples/codex_mcp.md @@ -1,21 +1,49 @@ # Codex MCP Setup -Recommended profile: `workspace-core`. +Recommended modes: + +- `repro-fix` +- `inspect` + +Preferred helper flow: + +```bash +pyro host connect codex --mode repro-fix +pyro host connect codex --mode inspect +pyro host doctor --mode repro-fix +``` Package without install: ```bash -codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve +codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix codex mcp list ``` +Run that from the repo root when you want the first `workspace_create` to start +from the current checkout automatically. + Already installed: ```bash -codex mcp add pyro -- pyro mcp serve +codex mcp add pyro -- pyro mcp serve --mode repro-fix codex mcp list ``` +If Codex launches the server from an unexpected cwd, pin the project +explicitly: + +```bash +pyro host connect codex --mode repro-fix --project-path /abs/path/to/repo +codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix --project-path /abs/path/to/repo +``` + +If the local config drifts later: + +```bash +pyro host repair codex --mode repro-fix +``` + Move to `workspace-full` only when the chat truly needs shells, services, snapshots, secrets, network policy, or disk tools: diff --git a/examples/mcp_client_config.md b/examples/mcp_client_config.md index 51d7419..f4de5f0 100644 --- a/examples/mcp_client_config.md +++ b/examples/mcp_client_config.md @@ -1,6 +1,11 @@ # MCP Client Config Example -Default for most chat hosts in `4.x`: `workspace-core`. +Recommended named modes for most chat hosts in `4.x`: + +- `repro-fix` +- `inspect` +- `cold-start` +- `review-eval` Use the host-specific examples first when they apply: @@ -8,8 +13,18 @@ Use the host-specific examples first when they apply: - Codex: [examples/codex_mcp.md](codex_mcp.md) - OpenCode: [examples/opencode_mcp_config.json](opencode_mcp_config.json) +Preferred repair/bootstrap helpers: + +- `pyro host connect codex --mode repro-fix` +- `pyro host connect codex --mode inspect` +- `pyro host connect claude-code --mode cold-start` +- `pyro host connect claude-code --mode review-eval` +- `pyro host print-config opencode --mode repro-fix` +- `pyro host doctor --mode repro-fix` +- `pyro host repair opencode --mode repro-fix` + Use this generic config only when the host expects a plain `mcpServers` JSON -shape. +shape or when the named modes are too narrow for the workflow. `pyro-mcp` is intended to be exposed to LLM clients through the public `pyro` CLI. @@ -20,7 +35,7 @@ Generic stdio MCP configuration using `uvx`: "mcpServers": { "pyro": { "command": "uvx", - "args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"] + "args": ["--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"] } } } @@ -33,21 +48,28 @@ If `pyro-mcp` is already installed locally, the same server can be configured wi "mcpServers": { "pyro": { "command": "pyro", - "args": ["mcp", "serve"] + "args": ["mcp", "serve", "--mode", "repro-fix"] } } } ``` -Profile progression: +If the host does not preserve the server working directory and you want the +first `workspace_create` to start from a specific checkout, add +`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same args list. -- `workspace-core`: the default and recommended first persistent chat profile -- `vm-run`: expose only `vm_run` +Mode progression: + +- `repro-fix`: patch, rerun, diff, export, reset +- `inspect`: narrow offline-by-default inspection +- `cold-start`: validation plus service readiness +- `review-eval`: shell and snapshot-driven review +- generic no-mode path: the fallback when the named mode is too narrow - `workspace-full`: explicit advanced opt-in for shells, services, snapshots, secrets, network policy, and disk tools -Primary profile for most agents: +Primary mode for most agents: -- `workspace-core` +- `repro-fix` Use lifecycle tools only when the agent needs persistent VM state across multiple tool calls. diff --git a/examples/opencode_mcp_config.json b/examples/opencode_mcp_config.json index c060946..518dc7d 100644 --- a/examples/opencode_mcp_config.json +++ b/examples/opencode_mcp_config.json @@ -3,7 +3,7 @@ "pyro": { "type": "local", "enabled": true, - "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"] + "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"] } } } diff --git a/pyproject.toml b/pyproject.toml index 2f078d7..5e9b649 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "pyro-mcp" -version = "4.0.0" -description = "Stable Firecracker workspaces, one-shot sandboxes, and MCP tools for coding agents." +version = "4.5.0" +description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM." readme = "README.md" license = { file = "LICENSE" } authors = [ @@ -9,7 +9,7 @@ authors = [ ] requires-python = ">=3.12" classifiers = [ - "Development Status :: 5 - Production/Stable", + "Development Status :: 4 - Beta", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", diff --git a/scripts/daily_loop_smoke.py b/scripts/daily_loop_smoke.py new file mode 100644 index 0000000..dc40980 --- /dev/null +++ b/scripts/daily_loop_smoke.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 + +"""Run the real guest-backed daily-loop smoke.""" + +from pyro_mcp.daily_loop_smoke import main + +if __name__ == "__main__": + main() diff --git a/src/pyro_mcp/api.py b/src/pyro_mcp/api.py index 6dfc5fb..967b05c 100644 --- a/src/pyro_mcp/api.py +++ b/src/pyro_mcp/api.py @@ -8,11 +8,22 @@ from typing import Any, Literal, cast from mcp.server.fastmcp import FastMCP from pyro_mcp.contract import ( + PUBLIC_MCP_COLD_START_MODE_TOOLS, + PUBLIC_MCP_INSPECT_MODE_TOOLS, + PUBLIC_MCP_MODES, PUBLIC_MCP_PROFILES, + PUBLIC_MCP_REPRO_FIX_MODE_TOOLS, + PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS, PUBLIC_MCP_VM_RUN_PROFILE_TOOLS, PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS, PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS, ) +from pyro_mcp.project_startup import ( + ProjectStartupSource, + describe_project_startup_source, + materialize_project_startup_source, + resolve_project_startup_source, +) from pyro_mcp.vm_manager import ( DEFAULT_ALLOW_HOST_COMPAT, DEFAULT_MEM_MIB, @@ -24,12 +35,77 @@ from pyro_mcp.vm_manager import ( ) McpToolProfile = Literal["vm-run", "workspace-core", "workspace-full"] +WorkspaceUseCaseMode = Literal["repro-fix", "inspect", "cold-start", "review-eval"] _PROFILE_TOOLS: dict[str, tuple[str, ...]] = { "vm-run": PUBLIC_MCP_VM_RUN_PROFILE_TOOLS, "workspace-core": PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS, "workspace-full": PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS, } +_MODE_TOOLS: dict[str, tuple[str, ...]] = { + "repro-fix": PUBLIC_MCP_REPRO_FIX_MODE_TOOLS, + "inspect": PUBLIC_MCP_INSPECT_MODE_TOOLS, + "cold-start": PUBLIC_MCP_COLD_START_MODE_TOOLS, + "review-eval": PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS, +} +_MODE_CREATE_INTENT: dict[str, str] = { + "repro-fix": "to reproduce a failure, patch files, rerun, diff, export, and reset", + "inspect": "to inspect suspicious or unfamiliar code with the smallest persistent surface", + "cold-start": ( + "to validate a fresh repo, keep one service alive, and export a " + "validation report" + ), + "review-eval": "to review interactively, checkpoint work, and export the final report", +} +_MODE_TOOL_DESCRIPTIONS: dict[str, dict[str, str]] = { + "repro-fix": { + "workspace_file_read": "Read one workspace file while investigating the broken state.", + "workspace_file_write": "Write one workspace file directly as part of the fix loop.", + "workspace_patch_apply": "Apply a structured text patch inside the workspace fix loop.", + "workspace_export": "Export the fixed result or patch-ready file back to the host.", + "workspace_summary": ( + "Summarize the current repro/fix session for review before export " + "or reset." + ), + }, + "inspect": { + "workspace_file_list": "List suspicious files under the current workspace path.", + "workspace_file_read": "Read one suspicious or unfamiliar workspace file.", + "workspace_export": ( + "Export only the inspection report or artifact you chose to " + "materialize." + ), + "workspace_summary": "Summarize the current inspection session and its exported results.", + }, + "cold-start": { + "workspace_create": "Create and start a persistent workspace for cold-start validation.", + "workspace_export": "Export the validation report or other final host-visible result.", + "workspace_summary": ( + "Summarize the current validation session, service state, and " + "exports." + ), + "service_start": "Start a named validation or app service and wait for typed readiness.", + "service_list": "List running and exited validation services in the current workspace.", + "service_status": "Inspect one validation service and its readiness outcome.", + "service_logs": "Read stdout and stderr from one validation service.", + "service_stop": "Stop one validation service in the workspace.", + }, + "review-eval": { + "workspace_create": ( + "Create and start a persistent workspace for interactive review " + "or evaluation." + ), + "workspace_summary": "Summarize the current review session before exporting or resetting.", + "shell_open": "Open an interactive review shell inside the workspace.", + "shell_read": "Read chat-friendly PTY output from the current review shell.", + "shell_write": "Send one line of input to the current review shell.", + "shell_signal": "Interrupt or terminate the current review shell process group.", + "shell_close": "Close the current review shell.", + "snapshot_create": "Create a checkpoint before the review branch diverges.", + "snapshot_list": "List the baseline plus named review checkpoints.", + "snapshot_delete": "Delete one named review checkpoint.", + }, +} def _validate_mcp_profile(profile: str) -> McpToolProfile: @@ -39,6 +115,44 @@ def _validate_mcp_profile(profile: str) -> McpToolProfile: return cast(McpToolProfile, profile) +def _validate_workspace_mode(mode: str) -> WorkspaceUseCaseMode: + if mode not in PUBLIC_MCP_MODES: + expected = ", ".join(PUBLIC_MCP_MODES) + raise ValueError(f"unknown workspace mode {mode!r}; expected one of: {expected}") + return cast(WorkspaceUseCaseMode, mode) + + +def _workspace_create_description( + startup_source: ProjectStartupSource | None, + *, + mode: WorkspaceUseCaseMode | None = None, +) -> str: + if mode is not None: + prefix = ( + "Create and start a persistent workspace " + f"{_MODE_CREATE_INTENT[mode]}." + ) + else: + prefix = "Create and start a persistent workspace." + if startup_source is None: + return prefix + described_source = describe_project_startup_source(startup_source) + if described_source is None: + return prefix + return f"{prefix} If `seed_path` is omitted, the server seeds from {described_source}." + + +def _tool_description( + tool_name: str, + *, + mode: WorkspaceUseCaseMode | None, + fallback: str, +) -> str: + if mode is None: + return fallback + return _MODE_TOOL_DESCRIPTIONS.get(mode, {}).get(tool_name, fallback) + + class Pyro: """High-level facade over the ephemeral VM runtime.""" @@ -186,6 +300,9 @@ class Pyro: def logs_workspace(self, workspace_id: str) -> dict[str, Any]: return self._manager.logs_workspace(workspace_id) + def summarize_workspace(self, workspace_id: str) -> dict[str, Any]: + return self._manager.summarize_workspace(workspace_id) + def export_workspace( self, workspace_id: str, @@ -462,15 +579,40 @@ class Pyro: allow_host_compat=allow_host_compat, ) - def create_server(self, *, profile: McpToolProfile = "workspace-core") -> FastMCP: + def create_server( + self, + *, + profile: McpToolProfile = "workspace-core", + mode: WorkspaceUseCaseMode | None = None, + project_path: str | Path | None = None, + repo_url: str | None = None, + repo_ref: str | None = None, + no_project_source: bool = False, + ) -> FastMCP: """Create an MCP server for one of the stable public tool profiles. `workspace-core` is the default stable chat-host profile in 4.x. Use `profile="workspace-full"` only when the host truly needs the full - advanced workspace surface. + advanced workspace surface. By default, the server auto-detects the + nearest Git worktree root from its current working directory and uses + that source when `workspace_create` omits `seed_path`. `project_path`, + `repo_url`, and `no_project_source` override that behavior explicitly. """ normalized_profile = _validate_mcp_profile(profile) - enabled_tools = set(_PROFILE_TOOLS[normalized_profile]) + normalized_mode = _validate_workspace_mode(mode) if mode is not None else None + if normalized_mode is not None and normalized_profile != "workspace-core": + raise ValueError("mode and profile are mutually exclusive") + startup_source = resolve_project_startup_source( + project_path=project_path, + repo_url=repo_url, + repo_ref=repo_ref, + no_project_source=no_project_source, + ) + enabled_tools = set( + _MODE_TOOLS[normalized_mode] + if normalized_mode is not None + else _PROFILE_TOOLS[normalized_profile] + ) server = FastMCP(name="pyro_mcp") def _enabled(tool_name: str) -> bool: @@ -583,9 +725,59 @@ class Pyro: return self.reap_expired() if _enabled("workspace_create"): - if normalized_profile == "workspace-core": + workspace_create_description = _workspace_create_description( + startup_source, + mode=normalized_mode, + ) - @server.tool(name="workspace_create") + def _create_workspace_from_server_defaults( + *, + environment: str, + vcpu_count: int, + mem_mib: int, + ttl_seconds: int, + network_policy: str, + allow_host_compat: bool, + seed_path: str | None, + secrets: list[dict[str, str]] | None, + name: str | None, + labels: dict[str, str] | None, + ) -> dict[str, Any]: + if seed_path is not None or startup_source is None: + return self.create_workspace( + environment=environment, + vcpu_count=vcpu_count, + mem_mib=mem_mib, + ttl_seconds=ttl_seconds, + network_policy=network_policy, + allow_host_compat=allow_host_compat, + seed_path=seed_path, + secrets=secrets, + name=name, + labels=labels, + ) + with materialize_project_startup_source(startup_source) as resolved_seed_path: + prepared_seed = self._manager._prepare_workspace_seed( # noqa: SLF001 + resolved_seed_path, + origin_kind=startup_source.kind, + origin_ref=startup_source.origin_ref, + ) + return self._manager.create_workspace( + environment=environment, + vcpu_count=vcpu_count, + mem_mib=mem_mib, + ttl_seconds=ttl_seconds, + network_policy=network_policy, + allow_host_compat=allow_host_compat, + secrets=secrets, + name=name, + labels=labels, + _prepared_seed=prepared_seed, + ) + + if normalized_mode is not None or normalized_profile == "workspace-core": + + @server.tool(name="workspace_create", description=workspace_create_description) async def workspace_create_core( environment: str, vcpu_count: int = DEFAULT_VCPU_COUNT, @@ -596,8 +788,7 @@ class Pyro: name: str | None = None, labels: dict[str, str] | None = None, ) -> dict[str, Any]: - """Create and start a persistent workspace.""" - return self.create_workspace( + return _create_workspace_from_server_defaults( environment=environment, vcpu_count=vcpu_count, mem_mib=mem_mib, @@ -612,7 +803,7 @@ class Pyro: else: - @server.tool(name="workspace_create") + @server.tool(name="workspace_create", description=workspace_create_description) async def workspace_create_full( environment: str, vcpu_count: int = DEFAULT_VCPU_COUNT, @@ -625,8 +816,7 @@ class Pyro: name: str | None = None, labels: dict[str, str] | None = None, ) -> dict[str, Any]: - """Create and start a persistent workspace.""" - return self.create_workspace( + return _create_workspace_from_server_defaults( environment=environment, vcpu_count=vcpu_count, mem_mib=mem_mib, @@ -666,15 +856,21 @@ class Pyro: ) if _enabled("workspace_exec"): - if normalized_profile == "workspace-core": + if normalized_mode is not None or normalized_profile == "workspace-core": - @server.tool(name="workspace_exec") + @server.tool( + name="workspace_exec", + description=_tool_description( + "workspace_exec", + mode=normalized_mode, + fallback="Run one command inside an existing persistent workspace.", + ), + ) async def workspace_exec_core( workspace_id: str, command: str, timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS, ) -> dict[str, Any]: - """Run one command inside an existing persistent workspace.""" return self.exec_workspace( workspace_id, command=command, @@ -684,14 +880,20 @@ class Pyro: else: - @server.tool(name="workspace_exec") + @server.tool( + name="workspace_exec", + description=_tool_description( + "workspace_exec", + mode=normalized_mode, + fallback="Run one command inside an existing persistent workspace.", + ), + ) async def workspace_exec_full( workspace_id: str, command: str, timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS, secret_env: dict[str, str] | None = None, ) -> dict[str, Any]: - """Run one command inside an existing persistent workspace.""" return self.exec_workspace( workspace_id, command=command, @@ -738,15 +940,32 @@ class Pyro: """Return persisted command history for one workspace.""" return self.logs_workspace(workspace_id) + if _enabled("workspace_summary"): + + @server.tool( + description=_tool_description( + "workspace_summary", + mode=normalized_mode, + fallback="Summarize the current workspace session for human review.", + ) + ) + async def workspace_summary(workspace_id: str) -> dict[str, Any]: + return self.summarize_workspace(workspace_id) + if _enabled("workspace_export"): - @server.tool() + @server.tool( + description=_tool_description( + "workspace_export", + mode=normalized_mode, + fallback="Export one file or directory from `/workspace` back to the host.", + ) + ) async def workspace_export( workspace_id: str, path: str, output_path: str, ) -> dict[str, Any]: - """Export one file or directory from `/workspace` back to the host.""" return self.export_workspace(workspace_id, path, output_path=output_path) if _enabled("workspace_diff"): @@ -758,13 +977,21 @@ class Pyro: if _enabled("workspace_file_list"): - @server.tool() + @server.tool( + description=_tool_description( + "workspace_file_list", + mode=normalized_mode, + fallback=( + "List metadata for files and directories under one " + "live workspace path." + ), + ) + ) async def workspace_file_list( workspace_id: str, path: str = "/workspace", recursive: bool = False, ) -> dict[str, Any]: - """List metadata for files and directories under one live workspace path.""" return self.list_workspace_files( workspace_id, path=path, @@ -773,13 +1000,18 @@ class Pyro: if _enabled("workspace_file_read"): - @server.tool() + @server.tool( + description=_tool_description( + "workspace_file_read", + mode=normalized_mode, + fallback="Read one regular text file from a live workspace path.", + ) + ) async def workspace_file_read( workspace_id: str, path: str, max_bytes: int = 65536, ) -> dict[str, Any]: - """Read one regular text file from a live workspace path.""" return self.read_workspace_file( workspace_id, path, @@ -788,13 +1020,18 @@ class Pyro: if _enabled("workspace_file_write"): - @server.tool() + @server.tool( + description=_tool_description( + "workspace_file_write", + mode=normalized_mode, + fallback="Create or replace one regular text file under `/workspace`.", + ) + ) async def workspace_file_write( workspace_id: str, path: str, text: str, ) -> dict[str, Any]: - """Create or replace one regular text file under `/workspace`.""" return self.write_workspace_file( workspace_id, path, @@ -803,12 +1040,17 @@ class Pyro: if _enabled("workspace_patch_apply"): - @server.tool() + @server.tool( + description=_tool_description( + "workspace_patch_apply", + mode=normalized_mode, + fallback="Apply a unified text patch inside one live workspace.", + ) + ) async def workspace_patch_apply( workspace_id: str, patch: str, ) -> dict[str, Any]: - """Apply a unified text patch inside one live workspace.""" return self.apply_workspace_patch( workspace_id, patch=patch, @@ -886,27 +1128,62 @@ class Pyro: return self.reset_workspace(workspace_id, snapshot=snapshot) if _enabled("shell_open"): + if normalized_mode == "review-eval": - @server.tool() - async def shell_open( - workspace_id: str, - cwd: str = "/workspace", - cols: int = 120, - rows: int = 30, - secret_env: dict[str, str] | None = None, - ) -> dict[str, Any]: - """Open a persistent interactive shell inside one workspace.""" - return self.open_shell( - workspace_id, - cwd=cwd, - cols=cols, - rows=rows, - secret_env=secret_env, + @server.tool( + description=_tool_description( + "shell_open", + mode=normalized_mode, + fallback="Open a persistent interactive shell inside one workspace.", + ) ) + async def shell_open( + workspace_id: str, + cwd: str = "/workspace", + cols: int = 120, + rows: int = 30, + ) -> dict[str, Any]: + return self.open_shell( + workspace_id, + cwd=cwd, + cols=cols, + rows=rows, + secret_env=None, + ) + + else: + + @server.tool( + description=_tool_description( + "shell_open", + mode=normalized_mode, + fallback="Open a persistent interactive shell inside one workspace.", + ) + ) + async def shell_open( + workspace_id: str, + cwd: str = "/workspace", + cols: int = 120, + rows: int = 30, + secret_env: dict[str, str] | None = None, + ) -> dict[str, Any]: + return self.open_shell( + workspace_id, + cwd=cwd, + cols=cols, + rows=rows, + secret_env=secret_env, + ) if _enabled("shell_read"): - @server.tool() + @server.tool( + description=_tool_description( + "shell_read", + mode=normalized_mode, + fallback="Read merged PTY output from a workspace shell.", + ) + ) async def shell_read( workspace_id: str, shell_id: str, @@ -915,7 +1192,6 @@ class Pyro: plain: bool = False, wait_for_idle_ms: int | None = None, ) -> dict[str, Any]: - """Read merged PTY output from a workspace shell.""" return self.read_shell( workspace_id, shell_id, @@ -927,14 +1203,19 @@ class Pyro: if _enabled("shell_write"): - @server.tool() + @server.tool( + description=_tool_description( + "shell_write", + mode=normalized_mode, + fallback="Write text input to a persistent workspace shell.", + ) + ) async def shell_write( workspace_id: str, shell_id: str, input: str, append_newline: bool = True, ) -> dict[str, Any]: - """Write text input to a persistent workspace shell.""" return self.write_shell( workspace_id, shell_id, @@ -944,13 +1225,18 @@ class Pyro: if _enabled("shell_signal"): - @server.tool() + @server.tool( + description=_tool_description( + "shell_signal", + mode=normalized_mode, + fallback="Send a signal to the shell process group.", + ) + ) async def shell_signal( workspace_id: str, shell_id: str, signal_name: str = "INT", ) -> dict[str, Any]: - """Send a signal to the shell process group.""" return self.signal_shell( workspace_id, shell_id, @@ -959,74 +1245,142 @@ class Pyro: if _enabled("shell_close"): - @server.tool() + @server.tool( + description=_tool_description( + "shell_close", + mode=normalized_mode, + fallback="Close a persistent workspace shell.", + ) + ) async def shell_close(workspace_id: str, shell_id: str) -> dict[str, Any]: - """Close a persistent workspace shell.""" return self.close_shell(workspace_id, shell_id) if _enabled("service_start"): + if normalized_mode == "cold-start": - @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, - secret_env: dict[str, str] | None = None, - published_ports: list[dict[str, int | None]] | None = None, - ) -> 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, - secret_env=secret_env, - published_ports=published_ports, + @server.tool( + description=_tool_description( + "service_start", + mode=normalized_mode, + fallback="Start a named long-running service inside a workspace.", + ) ) + 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]: + 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, + secret_env=None, + published_ports=None, + ) + + else: + + @server.tool( + description=_tool_description( + "service_start", + mode=normalized_mode, + fallback="Start a named long-running service inside a workspace.", + ) + ) + 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, + secret_env: dict[str, str] | None = None, + published_ports: list[dict[str, int | None]] | None = None, + ) -> dict[str, Any]: + 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, + secret_env=secret_env, + published_ports=published_ports, + ) if _enabled("service_list"): - @server.tool() + @server.tool( + description=_tool_description( + "service_list", + mode=normalized_mode, + fallback="List named services in one workspace.", + ) + ) async def service_list(workspace_id: str) -> dict[str, Any]: - """List named services in one workspace.""" return self.list_services(workspace_id) if _enabled("service_status"): - @server.tool() + @server.tool( + description=_tool_description( + "service_status", + mode=normalized_mode, + fallback="Inspect one named workspace service.", + ) + ) 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) if _enabled("service_logs"): - @server.tool() + @server.tool( + description=_tool_description( + "service_logs", + mode=normalized_mode, + fallback="Read persisted stdout/stderr for one workspace service.", + ) + ) 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, @@ -1036,9 +1390,14 @@ class Pyro: if _enabled("service_stop"): - @server.tool() + @server.tool( + description=_tool_description( + "service_stop", + mode=normalized_mode, + fallback="Stop one running service in a workspace.", + ) + ) 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) if _enabled("workspace_delete"): diff --git a/src/pyro_mcp/cli.py b/src/pyro_mcp/cli.py index 47df4ee..79eb3a9 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -8,12 +8,21 @@ import shlex import sys from pathlib import Path from textwrap import dedent -from typing import Any +from typing import Any, cast from pyro_mcp import __version__ -from pyro_mcp.api import Pyro -from pyro_mcp.contract import PUBLIC_MCP_PROFILES +from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode +from pyro_mcp.contract import PUBLIC_MCP_MODES, PUBLIC_MCP_PROFILES +from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT from pyro_mcp.demo import run_demo +from pyro_mcp.host_helpers import ( + HostDoctorEntry, + HostServerConfig, + connect_cli_host, + doctor_hosts, + print_or_write_opencode_config, + repair_host, +) from pyro_mcp.ollama_demo import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL, run_ollama_tool_demo from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report from pyro_mcp.vm_environments import DEFAULT_CATALOG_VERSION @@ -146,6 +155,7 @@ def _print_doctor_human(payload: dict[str, Any]) -> None: ) runtime = payload.get("runtime") if isinstance(runtime, dict): + print(f"Catalog version: {str(runtime.get('catalog_version', 'unknown'))}") print(f"Environment cache: {str(runtime.get('cache_dir', 'unknown'))}") capabilities = runtime.get("capabilities") if isinstance(capabilities, dict): @@ -163,12 +173,108 @@ def _print_doctor_human(payload: dict[str, Any]) -> None: f"tun={'yes' if bool(networking.get('tun_available')) else 'no'} " f"ip_forward={'yes' if bool(networking.get('ip_forward_enabled')) else 'no'}" ) + daily_loop = payload.get("daily_loop") + if isinstance(daily_loop, dict): + status = str(daily_loop.get("status", "cold")).upper() + environment = str(daily_loop.get("environment", DEFAULT_PREPARE_ENVIRONMENT)) + print(f"Daily loop: {status} ({environment})") + print( + " " + f"installed={'yes' if bool(daily_loop.get('installed')) else 'no'} " + f"network_prepared={'yes' if bool(daily_loop.get('network_prepared')) else 'no'}" + ) + prepared_at = daily_loop.get("prepared_at") + if prepared_at is not None: + print(f" prepared_at={prepared_at}") + reason = daily_loop.get("reason") + if isinstance(reason, str) and reason != "": + print(f" reason={reason}") + if str(daily_loop.get("status", "cold")) != "warm": + print(f" Run: pyro prepare {environment}") if isinstance(issues, list) and issues: print("Issues:") for issue in issues: print(f"- {issue}") +def _print_prepare_human(payload: dict[str, Any]) -> None: + environment = str(payload.get("environment", DEFAULT_PREPARE_ENVIRONMENT)) + status = str(payload.get("status", "cold")).upper() + print(f"Prepare: {environment}") + print(f"Daily loop: {status}") + print( + "Result: " + f"{'reused' if bool(payload.get('reused')) else 'prepared'} " + f"network_prepared={'yes' if bool(payload.get('network_prepared')) else 'no'}" + ) + print(f"Cache dir: {str(payload.get('cache_dir', 'unknown'))}") + print(f"Manifest: {str(payload.get('manifest_path', 'unknown'))}") + prepared_at = payload.get("prepared_at") + if prepared_at is not None: + print(f"Prepared at: {prepared_at}") + print(f"Duration: {int(payload.get('last_prepare_duration_ms', 0))} ms") + reason = payload.get("reason") + if isinstance(reason, str) and reason != "": + print(f"Reason: {reason}") + + +def _build_host_server_config(args: argparse.Namespace) -> HostServerConfig: + return HostServerConfig( + installed_package=bool(getattr(args, "installed_package", False)), + profile=cast(McpToolProfile, str(getattr(args, "profile", "workspace-core"))), + mode=cast(WorkspaceUseCaseMode | None, getattr(args, "mode", None)), + project_path=getattr(args, "project_path", None), + repo_url=getattr(args, "repo_url", None), + repo_ref=getattr(args, "repo_ref", None), + no_project_source=bool(getattr(args, "no_project_source", False)), + ) + + +def _print_host_connect_human(payload: dict[str, Any]) -> None: + host = str(payload.get("host", "unknown")) + server_command = payload.get("server_command") + verification_command = payload.get("verification_command") + print(f"Connected pyro to {host}.") + if isinstance(server_command, list): + print("Server command: " + shlex.join(str(item) for item in server_command)) + if isinstance(verification_command, list): + print("Verify with: " + shlex.join(str(item) for item in verification_command)) + + +def _print_host_print_config_human(payload: dict[str, Any]) -> None: + rendered_config = payload.get("rendered_config") + if isinstance(rendered_config, str): + _write_stream(rendered_config, stream=sys.stdout) + return + output_path = payload.get("output_path") + if isinstance(output_path, str): + print(f"Wrote OpenCode config to {output_path}") + + +def _print_host_repair_human(payload: dict[str, Any]) -> None: + host = str(payload.get("host", "unknown")) + if host == "opencode": + print(f"Repaired OpenCode config at {str(payload.get('config_path', 'unknown'))}.") + backup_path = payload.get("backup_path") + if isinstance(backup_path, str): + print(f"Backed up the previous config to {backup_path}.") + return + _print_host_connect_human(payload) + + +def _print_host_doctor_human(entries: list[HostDoctorEntry]) -> None: + for index, entry in enumerate(entries): + print( + f"{entry.host}: {entry.status} " + f"installed={'yes' if entry.installed else 'no'} " + f"configured={'yes' if entry.configured else 'no'}" + ) + print(f" details: {entry.details}") + print(f" repair: {entry.repair_command}") + if index != len(entries) - 1: + print() + + def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> None: print(f"{action} ID: {str(payload.get('workspace_id', 'unknown'))}") name = payload.get("name") @@ -191,7 +297,16 @@ def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> N if isinstance(workspace_seed, dict): mode = str(workspace_seed.get("mode", "empty")) seed_path = workspace_seed.get("seed_path") - if isinstance(seed_path, str) and seed_path != "": + origin_kind = workspace_seed.get("origin_kind") + origin_ref = workspace_seed.get("origin_ref") + if isinstance(origin_kind, str) and isinstance(origin_ref, str) and origin_ref != "": + if origin_kind == "project_path": + print(f"Workspace seed: {mode} from project {origin_ref}") + elif origin_kind == "repo_url": + print(f"Workspace seed: {mode} from clean clone {origin_ref}") + else: + print(f"Workspace seed: {mode} from {origin_ref}") + elif isinstance(seed_path, str) and seed_path != "": print(f"Workspace seed: {mode} from {seed_path}") else: print(f"Workspace seed: {mode}") @@ -477,6 +592,147 @@ def _print_workspace_logs_human(payload: dict[str, Any]) -> None: print(stderr, end="" if stderr.endswith("\n") else "\n", file=sys.stderr) +def _print_workspace_review_summary_human(payload: dict[str, Any]) -> None: + print(f"Workspace review: {str(payload.get('workspace_id', 'unknown'))}") + name = payload.get("name") + if isinstance(name, str) and name != "": + print(f"Name: {name}") + labels = payload.get("labels") + if isinstance(labels, dict) and labels: + rendered_labels = ", ".join( + f"{str(key)}={str(value)}" for key, value in sorted(labels.items()) + ) + print(f"Labels: {rendered_labels}") + print(f"Environment: {str(payload.get('environment', 'unknown'))}") + print(f"State: {str(payload.get('state', 'unknown'))}") + print(f"Last activity at: {payload.get('last_activity_at')}") + print(f"Session started at: {payload.get('session_started_at')}") + + outcome = payload.get("outcome") + if isinstance(outcome, dict): + print( + "Outcome: " + f"commands={int(outcome.get('command_count', 0))} " + f"services={int(outcome.get('running_service_count', 0))}/" + f"{int(outcome.get('service_count', 0))} " + f"exports={int(outcome.get('export_count', 0))} " + f"snapshots={int(outcome.get('snapshot_count', 0))} " + f"resets={int(outcome.get('reset_count', 0))}" + ) + last_command = outcome.get("last_command") + if isinstance(last_command, dict): + print( + "Last command: " + f"{str(last_command.get('command', 'unknown'))} " + f"(exit_code={int(last_command.get('exit_code', -1))})" + ) + + def _print_events(title: str, events: object, *, formatter: Any) -> None: + if not isinstance(events, list) or not events: + return + print(f"{title}:") + for event in events: + if not isinstance(event, dict): + continue + print(f"- {formatter(event)}") + + commands = payload.get("commands") + if isinstance(commands, dict): + _print_events( + "Recent commands", + commands.get("recent"), + formatter=lambda event: ( + f"#{int(event.get('sequence', 0))} " + f"exit={int(event.get('exit_code', -1))} " + f"cwd={str(event.get('cwd', WORKSPACE_GUEST_PATH))} " + f"cmd={str(event.get('command', ''))}" + ), + ) + + edits = payload.get("edits") + if isinstance(edits, dict): + _print_events( + "Recent edits", + edits.get("recent"), + formatter=lambda event: ( + f"{str(event.get('event_kind', 'edit'))} " + f"at={event.get('recorded_at')} " + f"path={event.get('path') or event.get('destination') or 'n/a'}" + ), + ) + + changes = payload.get("changes") + if isinstance(changes, dict): + if not bool(changes.get("available")): + print(f"Changes: unavailable ({str(changes.get('reason', 'unknown reason'))})") + elif not bool(changes.get("changed")): + print("Changes: no current workspace changes.") + else: + summary = changes.get("summary") + if isinstance(summary, dict): + print( + "Changes: " + f"total={int(summary.get('total', 0))} " + f"added={int(summary.get('added', 0))} " + f"modified={int(summary.get('modified', 0))} " + f"deleted={int(summary.get('deleted', 0))} " + f"type_changed={int(summary.get('type_changed', 0))} " + f"non_text={int(summary.get('non_text', 0))}" + ) + _print_events( + "Top changed paths", + changes.get("entries"), + formatter=lambda event: ( + f"{str(event.get('status', 'changed'))} " + f"{str(event.get('path', 'unknown'))} " + f"[{str(event.get('artifact_type', 'unknown'))}]" + ), + ) + + services = payload.get("services") + if isinstance(services, dict): + _print_events( + "Current services", + services.get("current"), + formatter=lambda event: ( + f"{str(event.get('service_name', 'unknown'))} " + f"state={str(event.get('state', 'unknown'))}" + ), + ) + _print_events( + "Recent service events", + services.get("recent"), + formatter=lambda event: ( + f"{str(event.get('event_kind', 'service'))} " + f"{str(event.get('service_name', 'unknown'))} " + f"state={str(event.get('state', 'unknown'))}" + ), + ) + + artifacts = payload.get("artifacts") + if isinstance(artifacts, dict): + _print_events( + "Recent exports", + artifacts.get("exports"), + formatter=lambda event: ( + f"{str(event.get('workspace_path', 'unknown'))} -> " + f"{str(event.get('output_path', 'unknown'))}" + ), + ) + + snapshots = payload.get("snapshots") + if isinstance(snapshots, dict): + print(f"Named snapshots: {int(snapshots.get('named_count', 0))}") + _print_events( + "Recent snapshot events", + snapshots.get("recent"), + formatter=lambda event: ( + f"{str(event.get('event_kind', 'snapshot'))} " + f"{str(event.get('snapshot_name', 'unknown'))}" + ), + ) + + def _print_workspace_snapshot_human(payload: dict[str, Any], *, prefix: str) -> None: snapshot = payload.get("snapshot") if not isinstance(snapshot, dict): @@ -636,24 +892,73 @@ class _HelpFormatter( return help_string +def _add_host_server_source_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--installed-package", + action="store_true", + help="Use `pyro mcp serve` instead of the default `uvx --from pyro-mcp pyro mcp serve`.", + ) + profile_group = parser.add_mutually_exclusive_group() + profile_group.add_argument( + "--profile", + choices=PUBLIC_MCP_PROFILES, + default="workspace-core", + help="Explicit profile for the host helper flow when not using a named mode.", + ) + profile_group.add_argument( + "--mode", + choices=PUBLIC_MCP_MODES, + help="Opinionated use-case mode for the host helper flow.", + ) + source_group = parser.add_mutually_exclusive_group() + source_group.add_argument( + "--project-path", + help="Pin the server to this local project path instead of relying on host cwd.", + ) + source_group.add_argument( + "--repo-url", + help="Seed default workspaces from a clean clone of this repository URL.", + ) + source_group.add_argument( + "--no-project-source", + action="store_true", + help="Disable automatic Git checkout detection from the current working directory.", + ) + parser.add_argument( + "--repo-ref", + help="Optional branch, tag, or commit to checkout after cloning --repo-url.", + ) + + def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=( - "Run stable one-shot and persistent workspace workflows on supported " - "Linux x86_64 KVM hosts." + "Validate the host and serve disposable MCP workspaces for chat-based " + "coding agents on supported Linux x86_64 KVM hosts." ), epilog=dedent( """ - Suggested first run: + Suggested zero-to-hero path: pyro doctor - pyro env list - pyro env pull debian:12 + pyro prepare debian:12 pyro run debian:12 -- git --version + pyro host connect claude-code - Continue into the stable workspace path after that: + Connect a chat host after that: + pyro host connect claude-code + pyro host connect codex + pyro host print-config opencode + + Daily local loop after the first warmup: + pyro doctor --environment debian:12 + pyro prepare debian:12 + pyro workspace reset WORKSPACE_ID + + If you want terminal-level visibility into the workspace model: pyro workspace create debian:12 --seed-path ./repo --id-only pyro workspace sync push WORKSPACE_ID ./changes pyro workspace exec WORKSPACE_ID -- cat note.txt + pyro workspace summary WORKSPACE_ID pyro workspace diff WORKSPACE_ID pyro workspace snapshot create WORKSPACE_ID checkpoint pyro workspace reset WORKSPACE_ID --snapshot checkpoint @@ -661,8 +966,6 @@ def _build_parser() -> argparse.ArgumentParser: pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \ sh -lc 'touch .ready && while true; do sleep 60; done' pyro workspace export WORKSPACE_ID note.txt --output ./note.txt - - Use `pyro mcp serve` only after the CLI validation path works. """ ), formatter_class=_HelpFormatter, @@ -670,6 +973,51 @@ def _build_parser() -> argparse.ArgumentParser: parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") subparsers = parser.add_subparsers(dest="command", required=True, metavar="COMMAND") + prepare_parser = subparsers.add_parser( + "prepare", + help="Warm the local machine for the daily workspace loop.", + description=( + "Warm the recommended guest-backed daily loop by ensuring the " + "environment is installed and proving create, exec, reset, and " + "delete on one throwaway workspace." + ), + epilog=dedent( + f""" + Examples: + pyro prepare + pyro prepare {DEFAULT_PREPARE_ENVIRONMENT} + pyro prepare {DEFAULT_PREPARE_ENVIRONMENT} --network + pyro prepare {DEFAULT_PREPARE_ENVIRONMENT} --force + """ + ), + formatter_class=_HelpFormatter, + ) + prepare_parser.add_argument( + "environment", + nargs="?", + default=DEFAULT_PREPARE_ENVIRONMENT, + metavar="ENVIRONMENT", + help=( + "Curated environment to warm for the daily loop. Defaults to " + f"`{DEFAULT_PREPARE_ENVIRONMENT}`." + ), + ) + prepare_parser.add_argument( + "--network", + action="store_true", + help="Also warm guest networking by proving one egress-enabled workspace cycle.", + ) + prepare_parser.add_argument( + "--force", + action="store_true", + help="Rerun warmup even when a compatible warm manifest already exists.", + ) + prepare_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + env_parser = subparsers.add_parser( "env", help="Inspect and manage curated environments.", @@ -755,18 +1103,151 @@ def _build_parser() -> argparse.ArgumentParser: help="Print structured JSON instead of human-readable output.", ) + host_parser = subparsers.add_parser( + "host", + help="Bootstrap and repair supported chat-host configs.", + description=( + "Connect or repair the supported Claude Code, Codex, and OpenCode " + "host setups without hand-writing MCP commands or config." + ), + epilog=dedent( + """ + Examples: + pyro host connect claude-code + pyro host connect claude-code --mode cold-start + pyro host connect codex --project-path /abs/path/to/repo + pyro host print-config opencode + pyro host repair opencode + pyro host doctor + """ + ), + formatter_class=_HelpFormatter, + ) + host_subparsers = host_parser.add_subparsers(dest="host_command", required=True, metavar="HOST") + host_connect_parser = host_subparsers.add_parser( + "connect", + help="Connect Claude Code or Codex in one step.", + description=( + "Ensure the supported host has a `pyro` MCP server entry that wraps " + "the canonical `pyro mcp serve` command." + ), + epilog=dedent( + """ + Examples: + pyro host connect claude-code + pyro host connect claude-code --mode cold-start + pyro host connect codex --installed-package + pyro host connect codex --mode repro-fix + pyro host connect codex --project-path /abs/path/to/repo + """ + ), + formatter_class=_HelpFormatter, + ) + host_connect_parser.add_argument( + "host", + choices=("claude-code", "codex"), + help="Chat host to connect and update in place.", + ) + _add_host_server_source_args(host_connect_parser) + + host_print_config_parser = host_subparsers.add_parser( + "print-config", + help="Print or write the canonical OpenCode config snippet.", + description=( + "Render the canonical OpenCode `mcp.pyro` config entry so it can be " + "copied into or written to `opencode.json`." + ), + epilog=dedent( + """ + Examples: + pyro host print-config opencode + pyro host print-config opencode --mode repro-fix + pyro host print-config opencode --output ./opencode.json + pyro host print-config opencode --project-path /abs/path/to/repo + """ + ), + formatter_class=_HelpFormatter, + ) + host_print_config_parser.add_argument( + "host", + choices=("opencode",), + help="Host config shape to render.", + ) + _add_host_server_source_args(host_print_config_parser) + host_print_config_parser.add_argument( + "--output", + help="Write the rendered JSON to this path instead of printing it to stdout.", + ) + + host_doctor_parser = host_subparsers.add_parser( + "doctor", + help="Inspect supported host setup status.", + description=( + "Report whether Claude Code, Codex, and OpenCode are installed, " + "configured, missing, or drifted relative to the canonical `pyro` MCP setup." + ), + epilog=dedent( + """ + Examples: + pyro host doctor + pyro host doctor --mode inspect + pyro host doctor --project-path /abs/path/to/repo + pyro host doctor --installed-package + """ + ), + formatter_class=_HelpFormatter, + ) + _add_host_server_source_args(host_doctor_parser) + host_doctor_parser.add_argument( + "--config-path", + help="Override the OpenCode config path when inspecting or repairing that host.", + ) + + host_repair_parser = host_subparsers.add_parser( + "repair", + help="Repair one supported host to the canonical `pyro` setup.", + description=( + "Repair a stale or broken host config by reapplying the canonical " + "`pyro mcp serve` setup for that host." + ), + epilog=dedent( + """ + Examples: + pyro host repair claude-code + pyro host repair claude-code --mode review-eval + pyro host repair codex --project-path /abs/path/to/repo + pyro host repair opencode + """ + ), + formatter_class=_HelpFormatter, + ) + host_repair_parser.add_argument( + "host", + choices=("claude-code", "codex", "opencode"), + help="Host config to repair.", + ) + _add_host_server_source_args(host_repair_parser) + host_repair_parser.add_argument( + "--config-path", + help="Override the OpenCode config path when repairing that host.", + ) + mcp_parser = subparsers.add_parser( "mcp", help="Run the MCP server.", description=( "Run the MCP server after you have already validated the host and " - "guest execution with `pyro doctor` and `pyro run`. Bare `pyro " - "mcp serve` now starts the recommended `workspace-core` profile." + "guest execution with `pyro doctor` and `pyro run`. This is the " + "main product path for Claude Code, Codex, and OpenCode." ), epilog=dedent( """ Examples: pyro mcp serve + pyro mcp serve --mode repro-fix + pyro mcp serve --mode inspect + pyro mcp serve --project-path . + pyro mcp serve --repo-url https://github.com/example/project.git pyro mcp serve --profile vm-run pyro mcp serve --profile workspace-full """ @@ -779,36 +1260,83 @@ def _build_parser() -> argparse.ArgumentParser: help="Run the MCP server over stdio.", description=( "Expose pyro tools over stdio for an MCP client. Bare `pyro mcp " - "serve` now starts `workspace-core`, the recommended first profile " - "for most chat hosts." + "serve` starts the generic `workspace-core` path. Use `--mode` to " + "start from an opinionated use-case flow, or `--profile` to choose " + "a generic profile directly. When launched from inside a Git " + "checkout, it also seeds the first workspace from that repo by default." ), epilog=dedent( """ - Default and recommended first start: + Generic default path: pyro mcp serve + pyro mcp serve --project-path . + pyro mcp serve --repo-url https://github.com/example/project.git + + Named modes: + repro-fix: structured edit / diff / export / reset loop + inspect: smallest persistent inspection surface + cold-start: validation plus service readiness + review-eval: shell plus snapshots for review workflows Profiles: - workspace-core: default for normal persistent chat editing + workspace-core: default for normal persistent chat editing and the + recommended first profile for most chat hosts vm-run: smallest one-shot-only surface - workspace-full: advanced 4.x opt-in surface for shells, services, + workspace-full: larger opt-in surface for shells, services, snapshots, secrets, network policy, and disk tools - Use --profile workspace-full only when the host truly needs the full - advanced workspace surface. + Project-aware startup: + - bare `pyro mcp serve` auto-detects the nearest Git checkout + from the current working directory + - use --project-path when the host does not preserve cwd + - use --repo-url for a clean-clone source outside a local checkout + + Use --mode when one named use case already matches the job. Fall + back to the generic no-mode path when the mode feels too narrow. """ ), formatter_class=_HelpFormatter, ) - mcp_serve_parser.add_argument( + mcp_profile_group = mcp_serve_parser.add_mutually_exclusive_group() + mcp_profile_group.add_argument( "--profile", choices=PUBLIC_MCP_PROFILES, default="workspace-core", help=( - "Expose only one model-facing tool profile. `workspace-core` is " - "the default and recommended first profile for most chat hosts; " - "`workspace-full` is the explicit advanced opt-in surface." + "Expose one generic model-facing tool profile instead of a named mode. " + "`workspace-core` is the generic default and `workspace-full` is the " + "larger opt-in profile." ), ) + mcp_profile_group.add_argument( + "--mode", + choices=PUBLIC_MCP_MODES, + help="Expose one opinionated use-case mode instead of the generic profile path.", + ) + mcp_source_group = mcp_serve_parser.add_mutually_exclusive_group() + mcp_source_group.add_argument( + "--project-path", + help=( + "Seed default workspaces from this local project path. If the path " + "is inside a Git checkout, pyro uses that repo root." + ), + ) + mcp_source_group.add_argument( + "--repo-url", + help=( + "Seed default workspaces from a clean host-side clone of this repo URL " + "when `workspace_create` omits `seed_path`." + ), + ) + mcp_serve_parser.add_argument( + "--repo-ref", + help="Optional branch, tag, or commit to checkout after cloning --repo-url.", + ) + mcp_serve_parser.add_argument( + "--no-project-source", + action="store_true", + help=("Disable automatic Git checkout detection from the current working directory."), + ) run_parser = subparsers.add_parser( "run", @@ -865,8 +1393,7 @@ def _build_parser() -> argparse.ArgumentParser: "--allow-host-compat", action="store_true", help=( - "Opt into host-side compatibility execution if guest boot or guest exec " - "is unavailable." + "Opt into host-side compatibility execution if guest boot or guest exec is unavailable." ), ) run_parser.add_argument( @@ -888,7 +1415,7 @@ def _build_parser() -> argparse.ArgumentParser: "workspace", help="Manage persistent workspaces.", description=( - "Use the stable workspace contract when you need one sandbox to stay alive " + "Use the workspace model when you need one sandbox to stay alive " "across repeated exec, shell, service, diff, export, snapshot, and reset calls." ), epilog=dedent( @@ -908,6 +1435,7 @@ def _build_parser() -> argparse.ArgumentParser: pyro workspace start WORKSPACE_ID pyro workspace snapshot create WORKSPACE_ID checkpoint pyro workspace reset WORKSPACE_ID --snapshot checkpoint + pyro workspace summary WORKSPACE_ID pyro workspace diff WORKSPACE_ID pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt pyro workspace shell open WORKSPACE_ID --id-only @@ -986,8 +1514,7 @@ def _build_parser() -> argparse.ArgumentParser: "--allow-host-compat", action="store_true", help=( - "Opt into host-side compatibility execution if guest boot or guest exec " - "is unavailable." + "Opt into host-side compatibility execution if guest boot or guest exec is unavailable." ), ) workspace_create_parser.add_argument( @@ -1037,8 +1564,7 @@ def _build_parser() -> argparse.ArgumentParser: "exec", help="Run one command inside an existing workspace.", description=( - "Run one non-interactive command in the persistent `/workspace` " - "for a workspace." + "Run one non-interactive command in the persistent `/workspace` for a workspace." ), epilog=dedent( """ @@ -1274,8 +1800,7 @@ def _build_parser() -> argparse.ArgumentParser: "created automatically." ), epilog=( - "Example:\n" - " pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py" + "Example:\n pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py" ), formatter_class=_HelpFormatter, ) @@ -1467,8 +1992,7 @@ def _build_parser() -> argparse.ArgumentParser: "start", help="Start one stopped workspace without resetting it.", description=( - "Start a previously stopped workspace from its preserved rootfs and " - "workspace state." + "Start a previously stopped workspace from its preserved rootfs and workspace state." ), epilog="Example:\n pyro workspace start WORKSPACE_ID", formatter_class=_HelpFormatter, @@ -1594,8 +2118,7 @@ def _build_parser() -> argparse.ArgumentParser: "shell", help="Open and manage persistent interactive shells.", description=( - "Open one or more persistent interactive PTY shell sessions inside a started " - "workspace." + "Open one or more persistent interactive PTY shell sessions inside a started workspace." ), epilog=dedent( """ @@ -1818,7 +2341,7 @@ def _build_parser() -> argparse.ArgumentParser: 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. + remains available when the workflow needs a custom readiness check. """ ), formatter_class=_HelpFormatter, @@ -2047,12 +2570,38 @@ while true; do sleep 60; done' action="store_true", help="Print structured JSON instead of human-readable output.", ) + workspace_summary_parser = workspace_subparsers.add_parser( + "summary", + help="Summarize the current workspace session for review.", + description=( + "Summarize the current workspace session since the last reset, including recent " + "commands, edits, services, exports, snapshots, and current change status." + ), + epilog=dedent( + """ + Example: + pyro workspace summary WORKSPACE_ID + + Use `workspace logs`, `workspace diff`, and `workspace export` for drill-down. + """ + ), + formatter_class=_HelpFormatter, + ) + workspace_summary_parser.add_argument( + "workspace_id", + metavar="WORKSPACE_ID", + help="Persistent workspace identifier.", + ) + workspace_summary_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) workspace_logs_parser = workspace_subparsers.add_parser( "logs", help="Show command history for one workspace.", description=( - "Show persisted command history, including stdout and stderr, " - "for one workspace." + "Show persisted command history, including stdout and stderr, for one workspace." ), epilog="Example:\n pyro workspace logs WORKSPACE_ID", formatter_class=_HelpFormatter, @@ -2088,11 +2637,16 @@ while true; do sleep 60; done' doctor_parser = subparsers.add_parser( "doctor", help="Inspect runtime and host diagnostics.", - description="Check host prerequisites and embedded runtime health before your first run.", + description=( + "Check host prerequisites and embedded runtime health, plus " + "daily-loop warmth before your first run or before reconnecting a " + "chat host." + ), epilog=dedent( """ Examples: pyro doctor + pyro doctor --environment debian:12 pyro doctor --json """ ), @@ -2103,6 +2657,14 @@ while true; do sleep 60; done' default=DEFAULT_PLATFORM, help="Runtime platform to inspect.", ) + doctor_parser.add_argument( + "--environment", + default=DEFAULT_PREPARE_ENVIRONMENT, + help=( + "Environment to inspect for the daily-loop warm manifest. " + f"Defaults to `{DEFAULT_PREPARE_ENVIRONMENT}`." + ), + ) doctor_parser.add_argument( "--json", action="store_true", @@ -2265,6 +2827,24 @@ def _parse_workspace_publish_options(values: list[str]) -> list[dict[str, int | def main() -> None: args = _build_parser().parse_args() pyro = Pyro() + if args.command == "prepare": + try: + payload = pyro.manager.prepare_daily_loop( + args.environment, + network=bool(args.network), + force=bool(args.force), + ) + 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_prepare_human(payload) + return if args.command == "env": if args.env_command == "list": list_payload: dict[str, Any] = { @@ -2300,8 +2880,66 @@ def main() -> None: else: _print_prune_human(prune_payload) return + if args.command == "host": + config = _build_host_server_config(args) + if args.host_command == "connect": + try: + payload = connect_cli_host(args.host, config=config) + except Exception as exc: # noqa: BLE001 + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + _print_host_connect_human(payload) + return + if args.host_command == "print-config": + try: + output_path = ( + None if args.output is None else Path(args.output).expanduser().resolve() + ) + payload = print_or_write_opencode_config(config=config, output_path=output_path) + except Exception as exc: # noqa: BLE001 + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + _print_host_print_config_human(payload) + return + if args.host_command == "doctor": + try: + config_path = ( + None + if args.config_path is None + else Path(args.config_path).expanduser().resolve() + ) + entries = doctor_hosts(config=config, config_path=config_path) + except Exception as exc: # noqa: BLE001 + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + _print_host_doctor_human(entries) + return + if args.host_command == "repair": + try: + if args.host != "opencode" and args.config_path is not None: + raise ValueError( + "--config-path is only supported for `pyro host repair opencode`" + ) + config_path = ( + None + if args.config_path is None + else Path(args.config_path).expanduser().resolve() + ) + payload = repair_host(args.host, config=config, config_path=config_path) + except Exception as exc: # noqa: BLE001 + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + _print_host_repair_human(payload) + return if args.command == "mcp": - pyro.create_server(profile=args.profile).run(transport="stdio") + pyro.create_server( + profile=args.profile, + mode=getattr(args, "mode", None), + project_path=args.project_path, + repo_url=args.repo_url, + repo_ref=args.repo_ref, + no_project_source=bool(args.no_project_source), + ).run(transport="stdio") return if args.command == "run": command = _require_command(args.command_args) @@ -2354,10 +2992,7 @@ def main() -> None: if args.command == "workspace": if args.workspace_command == "create": secrets = [ - *( - _parse_workspace_secret_option(value) - for value in getattr(args, "secret", []) - ), + *(_parse_workspace_secret_option(value) for value in getattr(args, "secret", [])), *( _parse_workspace_secret_file_option(value) for value in getattr(args, "secret_file", []) @@ -2392,9 +3027,7 @@ def main() -> None: return if args.workspace_command == "update": labels = _parse_workspace_label_options(getattr(args, "label", [])) - clear_labels = _parse_workspace_clear_label_options( - getattr(args, "clear_label", []) - ) + clear_labels = _parse_workspace_clear_label_options(getattr(args, "clear_label", [])) try: payload = pyro.update_workspace( args.workspace_id, @@ -2441,7 +3074,8 @@ def main() -> None: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc _print_workspace_exec_human(payload) - exit_code = int(payload.get("exit_code", 1)) + exit_code_raw = payload.get("exit_code", 1) + exit_code = exit_code_raw if isinstance(exit_code_raw, int) else 1 if exit_code != 0: raise SystemExit(exit_code) return @@ -2977,6 +3611,13 @@ def main() -> None: else: _print_workspace_summary_human(payload, action="Workspace") return + if args.workspace_command == "summary": + payload = pyro.summarize_workspace(args.workspace_id) + if bool(args.json): + _print_json(payload) + else: + _print_workspace_review_summary_human(payload) + return if args.workspace_command == "logs": payload = pyro.logs_workspace(args.workspace_id) if bool(args.json): @@ -2992,7 +3633,17 @@ def main() -> None: print(f"Deleted workspace: {str(payload.get('workspace_id', 'unknown'))}") return if args.command == "doctor": - payload = doctor_report(platform=args.platform) + try: + payload = doctor_report( + platform=args.platform, + environment=args.environment, + ) + 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: @@ -3023,3 +3674,7 @@ def main() -> None: return result = run_demo(network=bool(args.network)) _print_json(result) + + +if __name__ == "__main__": + main() diff --git a/src/pyro_mcp/contract.py b/src/pyro_mcp/contract.py index 714acf7..a7b2ba1 100644 --- a/src/pyro_mcp/contract.py +++ b/src/pyro_mcp/contract.py @@ -2,11 +2,34 @@ from __future__ import annotations -PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "mcp", "run", "workspace") +PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "host", "mcp", "prepare", "run", "workspace") PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",) PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune") +PUBLIC_CLI_DOCTOR_FLAGS = ("--platform", "--environment", "--json") +PUBLIC_CLI_HOST_SUBCOMMANDS = ("connect", "doctor", "print-config", "repair") +PUBLIC_CLI_HOST_COMMON_FLAGS = ( + "--installed-package", + "--mode", + "--profile", + "--project-path", + "--repo-url", + "--repo-ref", + "--no-project-source", +) +PUBLIC_CLI_HOST_CONNECT_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS +PUBLIC_CLI_HOST_DOCTOR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",) +PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--output",) +PUBLIC_CLI_HOST_REPAIR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",) PUBLIC_CLI_MCP_SUBCOMMANDS = ("serve",) -PUBLIC_CLI_MCP_SERVE_FLAGS = ("--profile",) +PUBLIC_CLI_MCP_SERVE_FLAGS = ( + "--mode", + "--profile", + "--project-path", + "--repo-url", + "--repo-ref", + "--no-project-source", +) +PUBLIC_CLI_PREPARE_FLAGS = ("--network", "--force", "--json") PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = ( "create", "delete", @@ -25,6 +48,7 @@ PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = ( "start", "status", "stop", + "summary", "sync", "update", ) @@ -101,6 +125,7 @@ PUBLIC_CLI_WORKSPACE_SNAPSHOT_LIST_FLAGS = ("--json",) PUBLIC_CLI_WORKSPACE_START_FLAGS = ("--json",) PUBLIC_CLI_WORKSPACE_STATUS_FLAGS = ("--json",) PUBLIC_CLI_WORKSPACE_STOP_FLAGS = ("--json",) +PUBLIC_CLI_WORKSPACE_SUMMARY_FLAGS = ("--json",) PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS = ("--dest", "--json") PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS = ( "--name", @@ -119,6 +144,7 @@ PUBLIC_CLI_RUN_FLAGS = ( "--json", ) PUBLIC_MCP_PROFILES = ("vm-run", "workspace-core", "workspace-full") +PUBLIC_MCP_MODES = ("repro-fix", "inspect", "cold-start", "review-eval") PUBLIC_SDK_METHODS = ( "apply_workspace_patch", @@ -165,6 +191,7 @@ PUBLIC_SDK_METHODS = ( "stop_service", "stop_vm", "stop_workspace", + "summarize_workspace", "update_workspace", "write_shell", "write_workspace_file", @@ -209,6 +236,7 @@ PUBLIC_MCP_TOOLS = ( "workspace_logs", "workspace_patch_apply", "workspace_reset", + "workspace_summary", "workspace_start", "workspace_status", "workspace_stop", @@ -230,8 +258,82 @@ PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS = ( "workspace_logs", "workspace_patch_apply", "workspace_reset", + "workspace_summary", "workspace_status", "workspace_sync_push", "workspace_update", ) +PUBLIC_MCP_REPRO_FIX_MODE_TOOLS = ( + "workspace_create", + "workspace_delete", + "workspace_diff", + "workspace_exec", + "workspace_export", + "workspace_file_list", + "workspace_file_read", + "workspace_file_write", + "workspace_list", + "workspace_logs", + "workspace_patch_apply", + "workspace_reset", + "workspace_summary", + "workspace_status", + "workspace_sync_push", + "workspace_update", +) +PUBLIC_MCP_INSPECT_MODE_TOOLS = ( + "workspace_create", + "workspace_delete", + "workspace_exec", + "workspace_export", + "workspace_file_list", + "workspace_file_read", + "workspace_list", + "workspace_logs", + "workspace_summary", + "workspace_status", + "workspace_update", +) +PUBLIC_MCP_COLD_START_MODE_TOOLS = ( + "service_list", + "service_logs", + "service_start", + "service_status", + "service_stop", + "workspace_create", + "workspace_delete", + "workspace_exec", + "workspace_export", + "workspace_file_list", + "workspace_file_read", + "workspace_list", + "workspace_logs", + "workspace_reset", + "workspace_summary", + "workspace_status", + "workspace_update", +) +PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS = ( + "shell_close", + "shell_open", + "shell_read", + "shell_signal", + "shell_write", + "snapshot_create", + "snapshot_delete", + "snapshot_list", + "workspace_create", + "workspace_delete", + "workspace_diff", + "workspace_exec", + "workspace_export", + "workspace_file_list", + "workspace_file_read", + "workspace_list", + "workspace_logs", + "workspace_reset", + "workspace_summary", + "workspace_status", + "workspace_update", +) PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS = PUBLIC_MCP_TOOLS diff --git a/src/pyro_mcp/daily_loop.py b/src/pyro_mcp/daily_loop.py new file mode 100644 index 0000000..164e1ba --- /dev/null +++ b/src/pyro_mcp/daily_loop.py @@ -0,0 +1,152 @@ +"""Machine-level daily-loop warmup state for the CLI prepare flow.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +DEFAULT_PREPARE_ENVIRONMENT = "debian:12" +PREPARE_MANIFEST_LAYOUT_VERSION = 1 +DailyLoopStatus = Literal["cold", "warm", "stale"] + + +def _environment_key(environment: str) -> str: + return environment.replace("/", "_").replace(":", "_") + + +@dataclass(frozen=True) +class DailyLoopManifest: + """Persisted machine-readiness proof for one environment on one platform.""" + + environment: str + environment_version: str + platform: str + catalog_version: str + bundle_version: str | None + prepared_at: float + network_prepared: bool + last_prepare_duration_ms: int + + def to_payload(self) -> dict[str, Any]: + return { + "layout_version": PREPARE_MANIFEST_LAYOUT_VERSION, + "environment": self.environment, + "environment_version": self.environment_version, + "platform": self.platform, + "catalog_version": self.catalog_version, + "bundle_version": self.bundle_version, + "prepared_at": self.prepared_at, + "network_prepared": self.network_prepared, + "last_prepare_duration_ms": self.last_prepare_duration_ms, + } + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> "DailyLoopManifest": + return cls( + environment=str(payload["environment"]), + environment_version=str(payload["environment_version"]), + platform=str(payload["platform"]), + catalog_version=str(payload["catalog_version"]), + bundle_version=( + None if payload.get("bundle_version") is None else str(payload["bundle_version"]) + ), + prepared_at=float(payload["prepared_at"]), + network_prepared=bool(payload.get("network_prepared", False)), + last_prepare_duration_ms=int(payload.get("last_prepare_duration_ms", 0)), + ) + + +def prepare_manifest_path(cache_dir: Path, *, platform: str, environment: str) -> Path: + return cache_dir / ".prepare" / platform / f"{_environment_key(environment)}.json" + + +def load_prepare_manifest(path: Path) -> tuple[DailyLoopManifest | None, str | None]: + if not path.exists(): + return None, None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return None, f"prepare manifest is unreadable: {exc}" + if not isinstance(payload, dict): + return None, "prepare manifest is not a JSON object" + try: + manifest = DailyLoopManifest.from_payload(payload) + except (KeyError, TypeError, ValueError) as exc: + return None, f"prepare manifest is invalid: {exc}" + return manifest, None + + +def write_prepare_manifest(path: Path, manifest: DailyLoopManifest) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(manifest.to_payload(), indent=2, sort_keys=True), + encoding="utf-8", + ) + + +def evaluate_daily_loop_status( + *, + environment: str, + environment_version: str, + platform: str, + catalog_version: str, + bundle_version: str | None, + installed: bool, + manifest: DailyLoopManifest | None, + manifest_error: str | None = None, +) -> tuple[DailyLoopStatus, str | None]: + if manifest_error is not None: + return "stale", manifest_error + if manifest is None: + if not installed: + return "cold", "environment is not installed" + return "cold", "daily loop has not been prepared yet" + if not installed: + return "stale", "environment install is missing" + if manifest.environment != environment: + return "stale", "prepare manifest environment does not match the selected environment" + if manifest.environment_version != environment_version: + return "stale", "environment version changed since the last prepare run" + if manifest.platform != platform: + return "stale", "platform changed since the last prepare run" + if manifest.catalog_version != catalog_version: + return "stale", "catalog version changed since the last prepare run" + if manifest.bundle_version != bundle_version: + return "stale", "runtime bundle version changed since the last prepare run" + return "warm", None + + +def prepare_request_is_satisfied( + manifest: DailyLoopManifest | None, + *, + require_network: bool, +) -> bool: + if manifest is None: + return False + if require_network and not manifest.network_prepared: + return False + return True + + +def serialize_daily_loop_report( + *, + environment: str, + status: DailyLoopStatus, + installed: bool, + cache_dir: Path, + manifest_path: Path, + reason: str | None, + manifest: DailyLoopManifest | None, +) -> dict[str, Any]: + return { + "environment": environment, + "status": status, + "installed": installed, + "network_prepared": bool(manifest.network_prepared) if manifest is not None else False, + "prepared_at": None if manifest is None else manifest.prepared_at, + "manifest_path": str(manifest_path), + "reason": reason, + "cache_dir": str(cache_dir), + } diff --git a/src/pyro_mcp/daily_loop_smoke.py b/src/pyro_mcp/daily_loop_smoke.py new file mode 100644 index 0000000..4cd82c7 --- /dev/null +++ b/src/pyro_mcp/daily_loop_smoke.py @@ -0,0 +1,131 @@ +"""Real guest-backed smoke for the daily local prepare and reset loop.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +from pyro_mcp.api import Pyro +from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT + + +def _log(message: str) -> None: + print(f"[daily-loop] {message}", flush=True) + + +def _write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def _run_prepare(environment: str) -> dict[str, object]: + proc = subprocess.run( # noqa: S603 + [sys.executable, "-m", "pyro_mcp.cli", "prepare", environment, "--json"], + text=True, + capture_output=True, + check=False, + ) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "pyro prepare failed") + payload = json.loads(proc.stdout) + if not isinstance(payload, dict): + raise RuntimeError("pyro prepare did not return a JSON object") + return payload + + +def run_daily_loop_smoke(*, environment: str = DEFAULT_PREPARE_ENVIRONMENT) -> None: + _log(f"prepare environment={environment}") + first_prepare = _run_prepare(environment) + assert bool(first_prepare["prepared"]) is True, first_prepare + second_prepare = _run_prepare(environment) + assert bool(second_prepare["reused"]) is True, second_prepare + + pyro = Pyro() + with tempfile.TemporaryDirectory(prefix="pyro-daily-loop-") as temp_dir: + root = Path(temp_dir) + seed_dir = root / "seed" + export_dir = root / "export" + _write_text(seed_dir / "message.txt", "broken\n") + _write_text( + seed_dir / "check.sh", + "#!/bin/sh\n" + "set -eu\n" + "value=$(cat message.txt)\n" + '[ "$value" = "fixed" ] || {\n' + " printf 'expected fixed got %s\\n' \"$value\" >&2\n" + " exit 1\n" + "}\n" + "printf '%s\\n' \"$value\"\n", + ) + + workspace_id: str | None = None + try: + created = pyro.create_workspace( + environment=environment, + seed_path=seed_dir, + name="daily-loop", + labels={"suite": "daily-loop-smoke"}, + ) + workspace_id = str(created["workspace_id"]) + _log(f"workspace_id={workspace_id}") + + failing = pyro.exec_workspace(workspace_id, command="sh check.sh") + assert int(failing["exit_code"]) != 0, failing + + patched = pyro.apply_workspace_patch( + workspace_id, + patch=("--- a/message.txt\n+++ b/message.txt\n@@ -1 +1 @@\n-broken\n+fixed\n"), + ) + assert bool(patched["changed"]) is True, patched + + passing = pyro.exec_workspace(workspace_id, command="sh check.sh") + assert int(passing["exit_code"]) == 0, passing + assert str(passing["stdout"]) == "fixed\n", passing + + export_path = export_dir / "message.txt" + exported = pyro.export_workspace( + workspace_id, + "message.txt", + output_path=export_path, + ) + assert export_path.read_text(encoding="utf-8") == "fixed\n" + assert str(exported["artifact_type"]) == "file", exported + + reset = pyro.reset_workspace(workspace_id) + assert int(reset["reset_count"]) == 1, reset + + rerun = pyro.exec_workspace(workspace_id, command="sh check.sh") + assert int(rerun["exit_code"]) != 0, rerun + reset_read = pyro.read_workspace_file(workspace_id, "message.txt") + assert str(reset_read["content"]) == "broken\n", reset_read + finally: + if workspace_id is not None: + try: + pyro.delete_workspace(workspace_id) + except Exception: + pass + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Run the real guest-backed daily-loop prepare and reset smoke.", + ) + parser.add_argument( + "--environment", + default=DEFAULT_PREPARE_ENVIRONMENT, + help=f"Environment to warm and test. Defaults to `{DEFAULT_PREPARE_ENVIRONMENT}`.", + ) + return parser + + +def main() -> None: + args = build_arg_parser().parse_args() + run_daily_loop_smoke(environment=args.environment) + + +if __name__ == "__main__": + main() diff --git a/src/pyro_mcp/doctor.py b/src/pyro_mcp/doctor.py index 296fedb..1de3ba3 100644 --- a/src/pyro_mcp/doctor.py +++ b/src/pyro_mcp/doctor.py @@ -5,16 +5,18 @@ from __future__ import annotations import argparse import json +from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Inspect bundled runtime health for pyro-mcp.") parser.add_argument("--platform", default=DEFAULT_PLATFORM) + parser.add_argument("--environment", default=DEFAULT_PREPARE_ENVIRONMENT) return parser def main() -> None: args = _build_parser().parse_args() - report = doctor_report(platform=args.platform) + report = doctor_report(platform=args.platform, environment=args.environment) print(json.dumps(report, indent=2, sort_keys=True)) diff --git a/src/pyro_mcp/host_helpers.py b/src/pyro_mcp/host_helpers.py new file mode 100644 index 0000000..dc06654 --- /dev/null +++ b/src/pyro_mcp/host_helpers.py @@ -0,0 +1,370 @@ +"""Helpers for bootstrapping and repairing supported MCP chat hosts.""" + +from __future__ import annotations + +import json +import shlex +import shutil +import subprocess +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Literal + +from pyro_mcp.api import McpToolProfile, WorkspaceUseCaseMode + +SUPPORTED_HOST_CONNECT_TARGETS = ("claude-code", "codex") +SUPPORTED_HOST_REPAIR_TARGETS = ("claude-code", "codex", "opencode") +SUPPORTED_HOST_PRINT_CONFIG_TARGETS = ("opencode",) +DEFAULT_HOST_SERVER_NAME = "pyro" +DEFAULT_OPENCODE_CONFIG_PATH = Path.home() / ".config" / "opencode" / "opencode.json" + +HostStatus = Literal["drifted", "missing", "ok", "unavailable"] + + +@dataclass(frozen=True) +class HostServerConfig: + installed_package: bool = False + profile: McpToolProfile = "workspace-core" + mode: WorkspaceUseCaseMode | None = None + project_path: str | None = None + repo_url: str | None = None + repo_ref: str | None = None + no_project_source: bool = False + + +@dataclass(frozen=True) +class HostDoctorEntry: + host: str + installed: bool + configured: bool + status: HostStatus + details: str + repair_command: str + + +def _run_command(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( # noqa: S603 + command, + check=False, + capture_output=True, + text=True, + ) + + +def _host_binary(host: str) -> str: + if host == "claude-code": + return "claude" + if host == "codex": + return "codex" + raise ValueError(f"unsupported CLI host {host!r}") + + +def _canonical_server_command(config: HostServerConfig) -> list[str]: + if config.mode is not None and config.profile != "workspace-core": + raise ValueError("--mode and --profile are mutually exclusive") + if config.project_path is not None and config.repo_url is not None: + raise ValueError("--project-path and --repo-url are mutually exclusive") + if config.no_project_source and ( + config.project_path is not None + or config.repo_url is not None + or config.repo_ref is not None + ): + raise ValueError( + "--no-project-source cannot be combined with --project-path, --repo-url, or --repo-ref" + ) + if config.repo_ref is not None and config.repo_url is None: + raise ValueError("--repo-ref requires --repo-url") + + command = ["pyro", "mcp", "serve"] + if not config.installed_package: + command = ["uvx", "--from", "pyro-mcp", *command] + if config.mode is not None: + command.extend(["--mode", config.mode]) + elif config.profile != "workspace-core": + command.extend(["--profile", config.profile]) + if config.project_path is not None: + command.extend(["--project-path", config.project_path]) + elif config.repo_url is not None: + command.extend(["--repo-url", config.repo_url]) + if config.repo_ref is not None: + command.extend(["--repo-ref", config.repo_ref]) + elif config.no_project_source: + command.append("--no-project-source") + return command + + +def _render_cli_command(command: list[str]) -> str: + return shlex.join(command) + + +def _repair_command(host: str, config: HostServerConfig, *, config_path: Path | None = None) -> str: + command = ["pyro", "host", "repair", host] + if config.installed_package: + command.append("--installed-package") + if config.mode is not None: + command.extend(["--mode", config.mode]) + elif config.profile != "workspace-core": + command.extend(["--profile", config.profile]) + if config.project_path is not None: + command.extend(["--project-path", config.project_path]) + elif config.repo_url is not None: + command.extend(["--repo-url", config.repo_url]) + if config.repo_ref is not None: + command.extend(["--repo-ref", config.repo_ref]) + elif config.no_project_source: + command.append("--no-project-source") + if config_path is not None: + command.extend(["--config-path", str(config_path)]) + return _render_cli_command(command) + + +def _command_matches(output: str, expected: list[str]) -> bool: + normalized_output = output.strip() + if ":" in normalized_output: + normalized_output = normalized_output.split(":", 1)[1].strip() + try: + parsed = shlex.split(normalized_output) + except ValueError: + parsed = normalized_output.split() + return parsed == expected + + +def _upsert_opencode_config( + *, + config_path: Path, + config: HostServerConfig, +) -> tuple[dict[str, object], Path | None]: + existing_payload: dict[str, object] = {} + backup_path: Path | None = None + if config_path.exists(): + raw_text = config_path.read_text(encoding="utf-8") + try: + parsed = json.loads(raw_text) + except json.JSONDecodeError: + timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S") + backup_path = config_path.with_name(f"{config_path.name}.bak-{timestamp}") + shutil.move(str(config_path), str(backup_path)) + parsed = {} + if isinstance(parsed, dict): + existing_payload = parsed + else: + timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S") + backup_path = config_path.with_name(f"{config_path.name}.bak-{timestamp}") + shutil.move(str(config_path), str(backup_path)) + payload = dict(existing_payload) + mcp_payload = payload.get("mcp") + if not isinstance(mcp_payload, dict): + mcp_payload = {} + else: + mcp_payload = dict(mcp_payload) + mcp_payload[DEFAULT_HOST_SERVER_NAME] = canonical_opencode_entry(config) + payload["mcp"] = mcp_payload + return payload, backup_path + + +def canonical_opencode_entry(config: HostServerConfig) -> dict[str, object]: + return { + "type": "local", + "enabled": True, + "command": _canonical_server_command(config), + } + + +def render_opencode_config(config: HostServerConfig) -> str: + return ( + json.dumps( + {"mcp": {DEFAULT_HOST_SERVER_NAME: canonical_opencode_entry(config)}}, + indent=2, + ) + + "\n" + ) + + +def print_or_write_opencode_config( + *, + config: HostServerConfig, + output_path: Path | None = None, +) -> dict[str, object]: + rendered = render_opencode_config(config) + if output_path is None: + return { + "host": "opencode", + "rendered_config": rendered, + "server_command": _canonical_server_command(config), + } + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(rendered, encoding="utf-8") + return { + "host": "opencode", + "output_path": str(output_path), + "server_command": _canonical_server_command(config), + } + + +def connect_cli_host(host: str, *, config: HostServerConfig) -> dict[str, object]: + binary = _host_binary(host) + if shutil.which(binary) is None: + raise RuntimeError(f"{binary} CLI is not installed or not on PATH") + server_command = _canonical_server_command(config) + _run_command([binary, "mcp", "remove", DEFAULT_HOST_SERVER_NAME]) + result = _run_command([binary, "mcp", "add", DEFAULT_HOST_SERVER_NAME, "--", *server_command]) + if result.returncode != 0: + details = (result.stderr or result.stdout).strip() or f"{binary} mcp add failed" + raise RuntimeError(details) + return { + "host": host, + "server_command": server_command, + "verification_command": [binary, "mcp", "list"], + } + + +def repair_opencode_host( + *, + config: HostServerConfig, + config_path: Path | None = None, +) -> dict[str, object]: + resolved_path = ( + DEFAULT_OPENCODE_CONFIG_PATH + if config_path is None + else config_path.expanduser().resolve() + ) + resolved_path.parent.mkdir(parents=True, exist_ok=True) + payload, backup_path = _upsert_opencode_config(config_path=resolved_path, config=config) + resolved_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + result: dict[str, object] = { + "host": "opencode", + "config_path": str(resolved_path), + "server_command": _canonical_server_command(config), + } + if backup_path is not None: + result["backup_path"] = str(backup_path) + return result + + +def repair_host( + host: str, + *, + config: HostServerConfig, + config_path: Path | None = None, +) -> dict[str, object]: + if host == "opencode": + return repair_opencode_host(config=config, config_path=config_path) + return connect_cli_host(host, config=config) + + +def _doctor_cli_host(host: str, *, config: HostServerConfig) -> HostDoctorEntry: + binary = _host_binary(host) + repair_command = _repair_command(host, config) + if shutil.which(binary) is None: + return HostDoctorEntry( + host=host, + installed=False, + configured=False, + status="unavailable", + details=f"{binary} CLI was not found on PATH", + repair_command=repair_command, + ) + expected_command = _canonical_server_command(config) + get_result = _run_command([binary, "mcp", "get", DEFAULT_HOST_SERVER_NAME]) + combined_get_output = (get_result.stdout + get_result.stderr).strip() + if get_result.returncode == 0: + status: HostStatus = ( + "ok" if _command_matches(combined_get_output, expected_command) else "drifted" + ) + return HostDoctorEntry( + host=host, + installed=True, + configured=True, + status=status, + details=combined_get_output or f"{binary} MCP entry exists", + repair_command=repair_command, + ) + + list_result = _run_command([binary, "mcp", "list"]) + combined_list_output = (list_result.stdout + list_result.stderr).strip() + configured = DEFAULT_HOST_SERVER_NAME in combined_list_output.split() + return HostDoctorEntry( + host=host, + installed=True, + configured=configured, + status="drifted" if configured else "missing", + details=combined_get_output or combined_list_output or f"{binary} MCP entry missing", + repair_command=repair_command, + ) + + +def _doctor_opencode_host( + *, + config: HostServerConfig, + config_path: Path | None = None, +) -> HostDoctorEntry: + resolved_path = ( + DEFAULT_OPENCODE_CONFIG_PATH + if config_path is None + else config_path.expanduser().resolve() + ) + repair_command = _repair_command("opencode", config, config_path=config_path) + installed = shutil.which("opencode") is not None + if not resolved_path.exists(): + return HostDoctorEntry( + host="opencode", + installed=installed, + configured=False, + status="missing" if installed else "unavailable", + details=f"OpenCode config missing at {resolved_path}", + repair_command=repair_command, + ) + try: + payload = json.loads(resolved_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + return HostDoctorEntry( + host="opencode", + installed=installed, + configured=False, + status="drifted" if installed else "unavailable", + details=f"OpenCode config is invalid JSON: {exc}", + repair_command=repair_command, + ) + if not isinstance(payload, dict): + return HostDoctorEntry( + host="opencode", + installed=installed, + configured=False, + status="drifted" if installed else "unavailable", + details="OpenCode config must be a JSON object", + repair_command=repair_command, + ) + mcp_payload = payload.get("mcp") + if not isinstance(mcp_payload, dict) or DEFAULT_HOST_SERVER_NAME not in mcp_payload: + return HostDoctorEntry( + host="opencode", + installed=installed, + configured=False, + status="missing" if installed else "unavailable", + details=f"OpenCode config at {resolved_path} is missing mcp.pyro", + repair_command=repair_command, + ) + configured_entry = mcp_payload[DEFAULT_HOST_SERVER_NAME] + expected_entry = canonical_opencode_entry(config) + status: HostStatus = "ok" if configured_entry == expected_entry else "drifted" + return HostDoctorEntry( + host="opencode", + installed=installed, + configured=True, + status=status, + details=f"OpenCode config path: {resolved_path}", + repair_command=repair_command, + ) + + +def doctor_hosts( + *, + config: HostServerConfig, + config_path: Path | None = None, +) -> list[HostDoctorEntry]: + return [ + _doctor_cli_host("claude-code", config=config), + _doctor_cli_host("codex", config=config), + _doctor_opencode_host(config=config, config_path=config_path), + ] diff --git a/src/pyro_mcp/project_startup.py b/src/pyro_mcp/project_startup.py new file mode 100644 index 0000000..102d631 --- /dev/null +++ b/src/pyro_mcp/project_startup.py @@ -0,0 +1,149 @@ +"""Server-scoped project startup source helpers for MCP chat flows.""" + +from __future__ import annotations + +import shutil +import subprocess +import tempfile +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Iterator, Literal + +ProjectStartupSourceKind = Literal["project_path", "repo_url"] + + +@dataclass(frozen=True) +class ProjectStartupSource: + """Server-scoped default source for workspace creation.""" + + kind: ProjectStartupSourceKind + origin_ref: str + resolved_path: Path | None = None + repo_ref: str | None = None + + +def _run_git(command: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run( # noqa: S603 + command, + cwd=str(cwd) if cwd is not None else None, + check=False, + capture_output=True, + text=True, + ) + + +def _detect_git_root(start_dir: Path) -> Path | None: + result = _run_git(["git", "rev-parse", "--show-toplevel"], cwd=start_dir) + if result.returncode != 0: + return None + stdout = result.stdout.strip() + if stdout == "": + return None + return Path(stdout).expanduser().resolve() + + +def _resolve_project_path(project_path: str | Path, *, cwd: Path) -> Path: + resolved = Path(project_path).expanduser() + if not resolved.is_absolute(): + resolved = (cwd / resolved).resolve() + else: + resolved = resolved.resolve() + if not resolved.exists(): + raise ValueError(f"project_path {resolved} does not exist") + if not resolved.is_dir(): + raise ValueError(f"project_path {resolved} must be a directory") + git_root = _detect_git_root(resolved) + if git_root is not None: + return git_root + return resolved + + +def resolve_project_startup_source( + *, + project_path: str | Path | None = None, + repo_url: str | None = None, + repo_ref: str | None = None, + no_project_source: bool = False, + cwd: Path | None = None, +) -> ProjectStartupSource | None: + working_dir = Path.cwd() if cwd is None else cwd.resolve() + if no_project_source: + if project_path is not None or repo_url is not None or repo_ref is not None: + raise ValueError( + "--no-project-source cannot be combined with --project-path, " + "--repo-url, or --repo-ref" + ) + return None + if project_path is not None and repo_url is not None: + raise ValueError("--project-path and --repo-url are mutually exclusive") + if repo_ref is not None and repo_url is None: + raise ValueError("--repo-ref requires --repo-url") + if project_path is not None: + resolved_path = _resolve_project_path(project_path, cwd=working_dir) + return ProjectStartupSource( + kind="project_path", + origin_ref=str(resolved_path), + resolved_path=resolved_path, + ) + if repo_url is not None: + normalized_repo_url = repo_url.strip() + if normalized_repo_url == "": + raise ValueError("--repo-url must not be empty") + normalized_repo_ref = None if repo_ref is None else repo_ref.strip() + if normalized_repo_ref == "": + raise ValueError("--repo-ref must not be empty") + return ProjectStartupSource( + kind="repo_url", + origin_ref=normalized_repo_url, + repo_ref=normalized_repo_ref, + ) + detected_root = _detect_git_root(working_dir) + if detected_root is None: + return None + return ProjectStartupSource( + kind="project_path", + origin_ref=str(detected_root), + resolved_path=detected_root, + ) + + +@contextmanager +def materialize_project_startup_source(source: ProjectStartupSource) -> Iterator[Path]: + if source.kind == "project_path": + if source.resolved_path is None: + raise RuntimeError("project_path source is missing a resolved path") + yield source.resolved_path + return + + temp_dir = Path(tempfile.mkdtemp(prefix="pyro-project-source-")) + clone_dir = temp_dir / "clone" + try: + clone_result = _run_git(["git", "clone", "--quiet", source.origin_ref, str(clone_dir)]) + if clone_result.returncode != 0: + stderr = clone_result.stderr.strip() or "git clone failed" + raise RuntimeError(f"failed to clone repo_url {source.origin_ref!r}: {stderr}") + if source.repo_ref is not None: + checkout_result = _run_git( + ["git", "checkout", "--quiet", source.repo_ref], + cwd=clone_dir, + ) + if checkout_result.returncode != 0: + stderr = checkout_result.stderr.strip() or "git checkout failed" + raise RuntimeError( + f"failed to checkout repo_ref {source.repo_ref!r} for " + f"repo_url {source.origin_ref!r}: {stderr}" + ) + yield clone_dir + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + +def describe_project_startup_source(source: ProjectStartupSource | None) -> str | None: + if source is None: + return None + if source.kind == "project_path": + return f"the current project at {source.origin_ref}" + if source.repo_ref is None: + return f"the clean clone source {source.origin_ref}" + return f"the clean clone source {source.origin_ref} at ref {source.repo_ref}" diff --git a/src/pyro_mcp/runtime.py b/src/pyro_mcp/runtime.py index 832e666..24779a3 100644 --- a/src/pyro_mcp/runtime.py +++ b/src/pyro_mcp/runtime.py @@ -11,6 +11,13 @@ from dataclasses import dataclass from pathlib import Path from typing import Any +from pyro_mcp.daily_loop import ( + DEFAULT_PREPARE_ENVIRONMENT, + evaluate_daily_loop_status, + load_prepare_manifest, + prepare_manifest_path, + serialize_daily_loop_report, +) from pyro_mcp.vm_network import TapNetworkManager DEFAULT_PLATFORM = "linux-x86_64" @@ -200,7 +207,11 @@ def runtime_capabilities(paths: RuntimePaths) -> RuntimeCapabilities: ) -def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]: +def doctor_report( + *, + platform: str = DEFAULT_PLATFORM, + environment: str = DEFAULT_PREPARE_ENVIRONMENT, +) -> dict[str, Any]: """Build a runtime diagnostics report.""" report: dict[str, Any] = { "platform": platform, @@ -258,6 +269,36 @@ def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]: "cache_dir": str(environment_store.cache_dir), "environments": environment_store.list_environments(), } + environment_details = environment_store.inspect_environment(environment) + manifest_path = prepare_manifest_path( + environment_store.cache_dir, + platform=platform, + environment=environment, + ) + manifest, manifest_error = load_prepare_manifest(manifest_path) + status, reason = evaluate_daily_loop_status( + environment=environment, + environment_version=str(environment_details["version"]), + platform=platform, + catalog_version=environment_store.catalog_version, + bundle_version=( + None + if paths.manifest.get("bundle_version") is None + else str(paths.manifest["bundle_version"]) + ), + installed=bool(environment_details["installed"]), + manifest=manifest, + manifest_error=manifest_error, + ) + report["daily_loop"] = serialize_daily_loop_report( + environment=environment, + status=status, + installed=bool(environment_details["installed"]), + cache_dir=environment_store.cache_dir, + manifest_path=manifest_path, + reason=reason, + manifest=manifest, + ) if not report["kvm"]["exists"]: report["issues"] = ["/dev/kvm is not available on this host"] return report diff --git a/src/pyro_mcp/server.py b/src/pyro_mcp/server.py index daf1820..455e9d2 100644 --- a/src/pyro_mcp/server.py +++ b/src/pyro_mcp/server.py @@ -2,9 +2,11 @@ from __future__ import annotations +from pathlib import Path + from mcp.server.fastmcp import FastMCP -from pyro_mcp.api import McpToolProfile, Pyro +from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode from pyro_mcp.vm_manager import VmManager @@ -12,14 +14,29 @@ def create_server( manager: VmManager | None = None, *, profile: McpToolProfile = "workspace-core", + mode: WorkspaceUseCaseMode | None = None, + project_path: str | Path | None = None, + repo_url: str | None = None, + repo_ref: str | None = None, + no_project_source: bool = False, ) -> FastMCP: """Create and return a configured MCP server instance. - `workspace-core` is the default stable chat-host profile in 4.x. Use + Bare server creation uses the generic `workspace-core` path in 4.x. Use + `mode=...` for one of the named use-case surfaces, or `profile="workspace-full"` only when the host truly needs the full - advanced workspace surface. + advanced workspace surface. By default, the server auto-detects the + nearest Git worktree root from its current working directory for + project-aware `workspace_create` calls. """ - return Pyro(manager=manager).create_server(profile=profile) + return Pyro(manager=manager).create_server( + profile=profile, + mode=mode, + project_path=project_path, + repo_url=repo_url, + repo_ref=repo_ref, + no_project_source=no_project_source, + ) def main() -> None: diff --git a/src/pyro_mcp/vm_environments.py b/src/pyro_mcp/vm_environments.py index 6419792..bd65b56 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 = "4.0.0" +DEFAULT_CATALOG_VERSION = "4.5.0" OCI_MANIFEST_ACCEPT = ", ".join( ( "application/vnd.oci.image.index.v1+json", @@ -48,7 +48,7 @@ class VmEnvironment: oci_repository: str | None = None oci_reference: str | None = None source_digest: str | None = None - compatibility: str = ">=4.0.0,<5.0.0" + compatibility: str = ">=4.5.0,<5.0.0" @dataclass(frozen=True) diff --git a/src/pyro_mcp/vm_manager.py b/src/pyro_mcp/vm_manager.py index e25be5f..669b9cd 100644 --- a/src/pyro_mcp/vm_manager.py +++ b/src/pyro_mcp/vm_manager.py @@ -24,6 +24,15 @@ from dataclasses import dataclass, field from pathlib import Path, PurePosixPath from typing import Any, Literal, cast +from pyro_mcp.daily_loop import ( + DailyLoopManifest, + evaluate_daily_loop_status, + load_prepare_manifest, + prepare_manifest_path, + prepare_request_is_satisfied, + serialize_daily_loop_report, + write_prepare_manifest, +) from pyro_mcp.runtime import ( RuntimeCapabilities, RuntimePaths, @@ -79,12 +88,13 @@ DEFAULT_TIMEOUT_SECONDS = 30 DEFAULT_TTL_SECONDS = 600 DEFAULT_ALLOW_HOST_COMPAT = False -WORKSPACE_LAYOUT_VERSION = 8 +WORKSPACE_LAYOUT_VERSION = 9 WORKSPACE_BASELINE_DIRNAME = "baseline" WORKSPACE_BASELINE_ARCHIVE_NAME = "workspace.tar" WORKSPACE_SNAPSHOTS_DIRNAME = "snapshots" WORKSPACE_DIRNAME = "workspace" WORKSPACE_COMMANDS_DIRNAME = "commands" +WORKSPACE_REVIEW_DIRNAME = "review" WORKSPACE_SHELLS_DIRNAME = "shells" WORKSPACE_SERVICES_DIRNAME = "services" WORKSPACE_SECRETS_DIRNAME = "secrets" @@ -116,7 +126,18 @@ WORKSPACE_SECRET_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$") WORKSPACE_LABEL_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") WorkspaceSeedMode = Literal["empty", "directory", "tar_archive"] +WorkspaceSeedOriginKind = Literal["empty", "manual_seed_path", "project_path", "repo_url"] WorkspaceArtifactType = Literal["file", "directory", "symlink"] +WorkspaceReviewEventKind = Literal[ + "file_write", + "patch_apply", + "service_start", + "service_stop", + "snapshot_create", + "snapshot_delete", + "sync_push", + "workspace_export", +] WorkspaceServiceReadinessType = Literal["file", "tcp", "http", "command"] WorkspaceSnapshotKind = Literal["baseline", "named"] WorkspaceSecretSourceKind = Literal["literal", "file"] @@ -276,9 +297,7 @@ class WorkspaceRecord: network=_deserialize_network(payload.get("network")), name=_normalize_workspace_name(_optional_str(payload.get("name")), allow_none=True), labels=_normalize_workspace_labels(payload.get("labels")), - last_activity_at=float( - payload.get("last_activity_at", float(payload["created_at"])) - ), + last_activity_at=float(payload.get("last_activity_at", float(payload["created_at"]))), command_count=int(payload.get("command_count", 0)), last_command=_optional_dict(payload.get("last_command")), workspace_seed=_workspace_seed_dict(payload.get("workspace_seed")), @@ -316,6 +335,35 @@ class WorkspaceSecretRecord: ) +@dataclass(frozen=True) +class WorkspaceReviewEventRecord: + """Persistent concise review event metadata stored on disk per workspace.""" + + workspace_id: str + event_kind: WorkspaceReviewEventKind + recorded_at: float + payload: dict[str, Any] + + def to_payload(self) -> dict[str, Any]: + return { + "layout_version": WORKSPACE_LAYOUT_VERSION, + "workspace_id": self.workspace_id, + "event_kind": self.event_kind, + "recorded_at": self.recorded_at, + "payload": self.payload, + } + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> WorkspaceReviewEventRecord: + raw_payload = payload.get("payload") + return cls( + workspace_id=str(payload["workspace_id"]), + event_kind=cast(WorkspaceReviewEventKind, str(payload["event_kind"])), + recorded_at=float(payload["recorded_at"]), + payload=dict(raw_payload) if isinstance(raw_payload, dict) else {}, + ) + + @dataclass class WorkspaceSnapshotRecord: """Persistent snapshot metadata stored on disk per workspace.""" @@ -503,9 +551,7 @@ class WorkspacePublishedPortRecord: host=str(payload.get("host", DEFAULT_PUBLISHED_PORT_HOST)), protocol=str(payload.get("protocol", "tcp")), proxy_pid=( - None - if payload.get("proxy_pid") is None - else int(payload.get("proxy_pid", 0)) + None if payload.get("proxy_pid") is None else int(payload.get("proxy_pid", 0)) ), ) @@ -524,6 +570,8 @@ class PreparedWorkspaceSeed: mode: WorkspaceSeedMode source_path: str | None + origin_kind: WorkspaceSeedOriginKind = "empty" + origin_ref: str | None = None archive_path: Path | None = None entry_count: int = 0 bytes_written: int = 0 @@ -534,14 +582,19 @@ class PreparedWorkspaceSeed: *, destination: str = WORKSPACE_GUEST_PATH, path_key: str = "seed_path", + include_origin: bool = True, ) -> dict[str, Any]: - return { + payload = { "mode": self.mode, path_key: self.source_path, "destination": destination, "entry_count": self.entry_count, "bytes_written": self.bytes_written, } + if include_origin: + payload["origin_kind"] = self.origin_kind + payload["origin_ref"] = self.origin_ref + return payload def cleanup(self) -> None: if self.cleanup_dir is not None: @@ -614,6 +667,8 @@ def _empty_workspace_seed_payload() -> dict[str, Any]: return { "mode": "empty", "seed_path": None, + "origin_kind": "empty", + "origin_ref": None, "destination": WORKSPACE_GUEST_PATH, "entry_count": 0, "bytes_written": 0, @@ -628,6 +683,8 @@ def _workspace_seed_dict(value: object) -> dict[str, Any]: { "mode": str(value.get("mode", payload["mode"])), "seed_path": _optional_str(value.get("seed_path")), + "origin_kind": str(value.get("origin_kind", payload["origin_kind"])), + "origin_ref": _optional_str(value.get("origin_ref")), "destination": str(value.get("destination", payload["destination"])), "entry_count": int(value.get("entry_count", payload["entry_count"])), "bytes_written": int(value.get("bytes_written", payload["bytes_written"])), @@ -869,9 +926,7 @@ def _validate_workspace_file_read_max_bytes(max_bytes: int) -> int: if max_bytes <= 0: raise ValueError("max_bytes must be positive") if max_bytes > WORKSPACE_FILE_MAX_BYTES: - raise ValueError( - f"max_bytes must be at most {WORKSPACE_FILE_MAX_BYTES} bytes" - ) + raise ValueError(f"max_bytes must be at most {WORKSPACE_FILE_MAX_BYTES} bytes") return max_bytes @@ -899,9 +954,7 @@ def _decode_workspace_patch_text(path: str, content_bytes: bytes) -> str: try: return content_bytes.decode("utf-8") except UnicodeDecodeError as exc: - raise RuntimeError( - f"workspace patch only supports UTF-8 text files: {path}" - ) from exc + raise RuntimeError(f"workspace patch only supports UTF-8 text files: {path}") from exc def _normalize_archive_member_name(name: str) -> PurePosixPath: @@ -991,9 +1044,7 @@ def _prepare_workspace_secrets( has_value = "value" in item has_file_path = "file_path" in item if has_value == has_file_path: - raise ValueError( - f"secret {name!r} must provide exactly one of 'value' or 'file_path'" - ) + raise ValueError(f"secret {name!r} must provide exactly one of 'value' or 'file_path'") source_kind: WorkspaceSecretSourceKind if has_value: value = _validate_workspace_secret_value(name, str(item["value"])) @@ -1473,9 +1524,7 @@ def _normalize_workspace_published_port_specs( ) dedupe_key = (spec.host_port, spec.guest_port) if dedupe_key in seen_guest_ports: - raise ValueError( - "published ports must not repeat the same host/guest port mapping" - ) + raise ValueError("published ports must not repeat the same host/guest port mapping") seen_guest_ports.add(dedupe_key) normalized.append(spec) return normalized @@ -1738,7 +1787,7 @@ def _start_local_service( ), "status=$?", f"printf '%s' \"$status\" > {shlex.quote(str(status_path))}", - "exit \"$status\"", + 'exit "$status"', ] ) + "\n", @@ -1921,9 +1970,7 @@ def _patch_rootfs_runtime_file( ) -> None: debugfs_path = shutil.which("debugfs") if debugfs_path is None: - raise RuntimeError( - "debugfs is required to seed workspaces on guest-backed runtimes" - ) + raise RuntimeError("debugfs is required to seed workspaces on guest-backed runtimes") with tempfile.TemporaryDirectory(prefix=f"pyro-{asset_label}-") as temp_dir: staged_path = Path(temp_dir) / Path(destination_path).name shutil.copy2(source_path, staged_path) @@ -3582,6 +3629,152 @@ class VmManager: def prune_environments(self) -> dict[str, object]: return self._environment_store.prune_environments() + def prepare_daily_loop( + self, + environment: str, + *, + network: bool = False, + force: bool = False, + ) -> dict[str, Any]: + spec = get_environment(environment, runtime_paths=self._runtime_paths) + if self._backend_name != "firecracker": + raise RuntimeError("pyro prepare requires a guest-backed runtime and is unavailable") + if not self._runtime_capabilities.supports_vm_boot: + reason = self._runtime_capabilities.reason or "runtime does not support guest boot" + raise RuntimeError( + f"pyro prepare requires guest-backed workspace boot and is unavailable: {reason}" + ) + if not self._runtime_capabilities.supports_guest_exec: + reason = self._runtime_capabilities.reason or ( + "runtime does not support guest command execution" + ) + raise RuntimeError( + f"pyro prepare requires guest command execution and is unavailable: {reason}" + ) + if network and not self._runtime_capabilities.supports_guest_network: + reason = self._runtime_capabilities.reason or ( + "runtime does not support guest networking" + ) + raise RuntimeError( + f"pyro prepare --network requires guest networking and is unavailable: {reason}" + ) + + runtime_paths = self._runtime_paths + if runtime_paths is None: + raise RuntimeError("runtime paths are unavailable for pyro prepare") + platform = str(runtime_paths.manifest.get("platform", "linux-x86_64")) + bundle_version = cast(str | None, runtime_paths.manifest.get("bundle_version")) + manifest_path = prepare_manifest_path( + self._environment_store.cache_dir, + platform=platform, + environment=environment, + ) + manifest, manifest_error = load_prepare_manifest(manifest_path) + status, status_reason = evaluate_daily_loop_status( + environment=environment, + environment_version=spec.version, + platform=platform, + catalog_version=self._environment_store.catalog_version, + bundle_version=bundle_version, + installed=bool(self.inspect_environment(environment)["installed"]), + manifest=manifest, + manifest_error=manifest_error, + ) + if ( + not force + and status == "warm" + and prepare_request_is_satisfied(manifest, require_network=network) + ): + if manifest is None: + raise AssertionError("warm prepare state requires a manifest") + payload = serialize_daily_loop_report( + environment=environment, + status="warm", + installed=True, + cache_dir=self._environment_store.cache_dir, + manifest_path=manifest_path, + reason="reused existing warm manifest", + manifest=manifest, + ) + payload.update( + { + "prepared": True, + "reused": True, + "executed": False, + "forced": force, + "network_requested": network, + "last_prepare_duration_ms": manifest.last_prepare_duration_ms, + } + ) + return payload + + self._environment_store.ensure_installed(environment) + started = time.monotonic() + workspace_id: str | None = None + execution_mode = "pending" + try: + created = self.create_workspace( + environment=environment, + network_policy="egress" if network else "off", + allow_host_compat=False, + ) + workspace_id = str(created["workspace_id"]) + exec_result = self.exec_workspace( + workspace_id, + command="pwd", + timeout_seconds=DEFAULT_TIMEOUT_SECONDS, + ) + execution_mode = str(exec_result.get("execution_mode", "unknown")) + if int(exec_result.get("exit_code", 1)) != 0: + raise RuntimeError("prepare guest exec failed") + if str(exec_result.get("stdout", "")) != f"{WORKSPACE_GUEST_PATH}\n": + raise RuntimeError("prepare guest exec returned an unexpected working directory") + self.reset_workspace(workspace_id) + finally: + if workspace_id is not None: + try: + self.delete_workspace(workspace_id, reason="prepare_cleanup") + except Exception: + pass + + duration_ms = int((time.monotonic() - started) * 1000) + prepared_at = time.time() + preserved_network_prepared = bool( + manifest is not None and status == "warm" and manifest.network_prepared + ) + prepared_manifest = DailyLoopManifest( + environment=environment, + environment_version=spec.version, + platform=platform, + catalog_version=self._environment_store.catalog_version, + bundle_version=bundle_version, + prepared_at=prepared_at, + network_prepared=network or preserved_network_prepared, + last_prepare_duration_ms=duration_ms, + ) + write_prepare_manifest(manifest_path, prepared_manifest) + payload = serialize_daily_loop_report( + environment=environment, + status="warm", + installed=True, + cache_dir=self._environment_store.cache_dir, + manifest_path=manifest_path, + reason=status_reason, + manifest=prepared_manifest, + ) + payload.update( + { + "prepared": True, + "reused": False, + "executed": True, + "forced": force, + "network_requested": network, + "last_prepare_duration_ms": duration_ms, + "execution_mode": execution_mode, + } + ) + return payload + def create_vm( self, *, @@ -3747,19 +3940,23 @@ class VmManager: secrets: list[dict[str, str]] | None = None, name: str | None = None, labels: dict[str, str] | None = None, + _prepared_seed: PreparedWorkspaceSeed | None = None, ) -> dict[str, Any]: self._validate_limits(vcpu_count=vcpu_count, mem_mib=mem_mib, ttl_seconds=ttl_seconds) get_environment(environment, runtime_paths=self._runtime_paths) normalized_network_policy = _normalize_workspace_network_policy(str(network_policy)) normalized_name = None if name is None else _normalize_workspace_name(name) normalized_labels = _normalize_workspace_labels(labels) - prepared_seed = self._prepare_workspace_seed(seed_path) + if _prepared_seed is not None and seed_path is not None: + raise ValueError("_prepared_seed and seed_path are mutually exclusive") + prepared_seed = _prepared_seed or self._prepare_workspace_seed(seed_path) now = time.time() workspace_id = uuid.uuid4().hex[:12] workspace_dir = self._workspace_dir(workspace_id) runtime_dir = self._workspace_runtime_dir(workspace_id) host_workspace_dir = self._workspace_host_dir(workspace_id) commands_dir = self._workspace_commands_dir(workspace_id) + review_dir = self._workspace_review_dir(workspace_id) shells_dir = self._workspace_shells_dir(workspace_id) services_dir = self._workspace_services_dir(workspace_id) secrets_dir = self._workspace_secrets_dir(workspace_id) @@ -3768,6 +3965,7 @@ class VmManager: 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) + review_dir.mkdir(parents=True, exist_ok=True) shells_dir.mkdir(parents=True, exist_ok=True) services_dir.mkdir(parents=True, exist_ok=True) secrets_dir.mkdir(parents=True, exist_ok=True) @@ -3802,9 +4000,7 @@ class VmManager: raise RuntimeError( f"max active VMs reached ({self._max_active_vms}); delete old VMs first" ) - self._require_workspace_network_policy_support( - network_policy=normalized_network_policy - ) + self._require_workspace_network_policy_support(network_policy=normalized_network_policy) self._backend.create(instance) if self._runtime_capabilities.supports_guest_exec: self._ensure_workspace_guest_bootstrap_support(instance) @@ -3885,6 +4081,7 @@ class VmManager: workspace_sync = prepared_seed.to_payload( destination=normalized_destination, path_key="source_path", + include_origin=False, ) workspace_sync["entry_count"] = int(import_summary["entry_count"]) workspace_sync["bytes_written"] = int(import_summary["bytes_written"]) @@ -3896,6 +4093,18 @@ class VmManager: workspace.last_error = instance.last_error workspace.metadata = dict(instance.metadata) self._touch_workspace_activity_locked(workspace) + self._record_workspace_review_event_locked( + workspace_id, + event_kind="sync_push", + payload={ + "mode": str(workspace_sync["mode"]), + "source_path": str(workspace_sync["source_path"]), + "destination": str(workspace_sync["destination"]), + "entry_count": int(workspace_sync["entry_count"]), + "bytes_written": int(workspace_sync["bytes_written"]), + "execution_mode": str(instance.metadata.get("execution_mode", "pending")), + }, + ) self._save_workspace_locked(workspace) return { "workspace_id": workspace_id, @@ -3977,8 +4186,8 @@ class VmManager: def export_workspace( self, workspace_id: str, - *, path: str, + *, output_path: str | Path, ) -> dict[str, Any]: normalized_path, _ = _normalize_workspace_destination(path) @@ -4010,6 +4219,23 @@ class VmManager: workspace.firecracker_pid = instance.firecracker_pid workspace.last_error = instance.last_error workspace.metadata = dict(instance.metadata) + self._record_workspace_review_event_locked( + workspace_id, + event_kind="workspace_export", + payload={ + "workspace_path": normalized_path, + "output_path": str(Path(str(extracted["output_path"]))), + "artifact_type": str(extracted["artifact_type"]), + "entry_count": int(extracted["entry_count"]), + "bytes_written": int(extracted["bytes_written"]), + "execution_mode": str( + exported.get( + "execution_mode", + instance.metadata.get("execution_mode", "pending"), + ) + ), + }, + ) self._save_workspace_locked(workspace) return { "workspace_id": workspace_id, @@ -4169,6 +4395,22 @@ class VmManager: workspace.firecracker_pid = instance.firecracker_pid workspace.last_error = instance.last_error workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) + self._record_workspace_review_event_locked( + workspace_id, + event_kind="file_write", + payload={ + "path": str(payload["path"]), + "size_bytes": int(payload["size_bytes"]), + "bytes_written": int(payload["bytes_written"]), + "execution_mode": str( + payload.get( + "execution_mode", + instance.metadata.get("execution_mode", "pending"), + ) + ), + }, + ) self._save_workspace_locked(workspace) return { "workspace_id": workspace_id, @@ -4286,6 +4528,15 @@ class VmManager: workspace.last_error = instance.last_error workspace.metadata = dict(instance.metadata) self._touch_workspace_activity_locked(workspace) + self._record_workspace_review_event_locked( + workspace_id, + event_kind="patch_apply", + payload={ + "summary": dict(summary), + "entries": [dict(entry) for entry in entries[:10]], + "execution_mode": str(instance.metadata.get("execution_mode", "pending")), + }, + ) self._save_workspace_locked(workspace) return { "workspace_id": workspace_id, @@ -4363,6 +4614,17 @@ class VmManager: self._touch_workspace_activity_locked(workspace) self._save_workspace_locked(workspace) self._save_workspace_snapshot_locked(snapshot) + self._record_workspace_review_event_locked( + workspace_id, + event_kind="snapshot_create", + payload={ + "snapshot_name": snapshot.snapshot_name, + "kind": snapshot.kind, + "entry_count": snapshot.entry_count, + "bytes_written": snapshot.bytes_written, + "created_at": snapshot.created_at, + }, + ) return { "workspace_id": workspace_id, "snapshot": self._serialize_workspace_snapshot(snapshot), @@ -4396,6 +4658,11 @@ class VmManager: self._load_workspace_snapshot_locked(workspace_id, normalized_snapshot_name) self._delete_workspace_snapshot_locked(workspace_id, normalized_snapshot_name) self._touch_workspace_activity_locked(workspace) + self._record_workspace_review_event_locked( + workspace_id, + event_kind="snapshot_delete", + payload={"snapshot_name": normalized_snapshot_name}, + ) self._save_workspace_locked(workspace) return { "workspace_id": workspace_id, @@ -4436,9 +4703,7 @@ class VmManager: recreated = workspace.to_instance( workdir=self._workspace_runtime_dir(workspace.workspace_id) ) - self._require_workspace_network_policy_support( - network_policy=workspace.network_policy - ) + self._require_workspace_network_policy_support(network_policy=workspace.network_policy) self._backend.create(recreated) if self._runtime_capabilities.supports_guest_exec: self._ensure_workspace_guest_bootstrap_support(recreated) @@ -4632,9 +4897,7 @@ class VmManager: if wait_for_idle_ms is not None and ( wait_for_idle_ms <= 0 or wait_for_idle_ms > MAX_SHELL_WAIT_FOR_IDLE_MS ): - raise ValueError( - f"wait_for_idle_ms must be between 1 and {MAX_SHELL_WAIT_FOR_IDLE_MS}" - ) + raise ValueError(f"wait_for_idle_ms must be between 1 and {MAX_SHELL_WAIT_FOR_IDLE_MS}") with self._lock: workspace = self._load_workspace_locked(workspace_id) instance = self._workspace_instance_for_live_shell_locked(workspace) @@ -4909,8 +5172,7 @@ class VmManager: if normalized_published_ports: if workspace.network_policy != "egress+published-ports": raise RuntimeError( - "published ports require workspace network_policy " - "'egress+published-ports'" + "published ports require workspace network_policy 'egress+published-ports'" ) if instance.network is None: raise RuntimeError( @@ -4997,6 +5259,24 @@ class VmManager: self._touch_workspace_activity_locked(workspace) self._save_workspace_locked(workspace) self._save_workspace_service_locked(service) + self._record_workspace_review_event_locked( + workspace_id, + event_kind="service_start", + payload={ + "service_name": service.service_name, + "state": service.state, + "command": service.command, + "cwd": service.cwd, + "readiness": ( + dict(service.readiness) if service.readiness is not None else None + ), + "ready_at": service.ready_at, + "published_ports": [ + _serialize_workspace_published_port_public(published_port) + for published_port in service.published_ports + ], + }, + ) return self._serialize_workspace_service(service) def list_services(self, workspace_id: str) -> dict[str, Any]: @@ -5116,6 +5396,18 @@ class VmManager: workspace.firecracker_pid = instance.firecracker_pid workspace.last_error = instance.last_error workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) + self._record_workspace_review_event_locked( + workspace_id, + event_kind="service_stop", + payload={ + "service_name": service.service_name, + "state": service.state, + "exit_code": service.exit_code, + "stop_reason": service.stop_reason, + "ended_at": service.ended_at, + }, + ) self._save_workspace_locked(workspace) self._save_workspace_service_locked(service) return self._serialize_workspace_service(service) @@ -5149,6 +5441,153 @@ class VmManager: "entries": redacted_entries, } + def summarize_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) + + command_entries = self._read_workspace_logs_locked(workspace.workspace_id) + recent_commands = [ + { + "sequence": int(entry["sequence"]), + "command": str(entry["command"]), + "cwd": str(entry["cwd"]), + "exit_code": int(entry["exit_code"]), + "duration_ms": int(entry["duration_ms"]), + "execution_mode": str(entry["execution_mode"]), + "recorded_at": float(entry["recorded_at"]), + } + for entry in command_entries[-5:] + ] + recent_commands.reverse() + + review_events = self._list_workspace_review_events_locked(workspace.workspace_id) + + def _recent_events( + kinds: set[WorkspaceReviewEventKind], + *, + limit: int = 5, + ) -> list[dict[str, Any]]: + matched = [ + { + "event_kind": event.event_kind, + "recorded_at": event.recorded_at, + **event.payload, + } + for event in review_events + if event.event_kind in kinds + ] + matched = matched[-limit:] + matched.reverse() + return matched + + current_services = [ + self._serialize_workspace_service(service) + for service in self._list_workspace_services_locked(workspace.workspace_id) + ] + current_services.sort(key=lambda item: str(item["service_name"])) + try: + snapshots = self._list_workspace_snapshots_locked(workspace) + named_snapshot_count = max(len(snapshots) - 1, 0) + except RuntimeError: + named_snapshot_count = 0 + + service_count = len(current_services) + running_service_count = sum( + 1 for service in current_services if service["state"] == "running" + ) + execution_mode = str(workspace.metadata.get("execution_mode", "pending")) + payload: dict[str, Any] = { + "workspace_id": workspace.workspace_id, + "name": workspace.name, + "labels": dict(workspace.labels), + "environment": workspace.environment, + "state": workspace.state, + "workspace_path": WORKSPACE_GUEST_PATH, + "execution_mode": execution_mode, + "last_activity_at": workspace.last_activity_at, + "session_started_at": ( + workspace.last_reset_at + if workspace.last_reset_at is not None + else workspace.created_at + ), + "outcome": { + "command_count": workspace.command_count, + "last_command": workspace.last_command, + "service_count": service_count, + "running_service_count": running_service_count, + "export_count": sum( + 1 for event in review_events if event.event_kind == "workspace_export" + ), + "snapshot_count": named_snapshot_count, + "reset_count": workspace.reset_count, + }, + "commands": { + "total": workspace.command_count, + "recent": recent_commands, + }, + "edits": { + "recent": _recent_events({"sync_push", "file_write", "patch_apply"}), + }, + "services": { + "current": current_services, + "recent": _recent_events({"service_start", "service_stop"}), + }, + "artifacts": { + "exports": _recent_events({"workspace_export"}), + }, + "snapshots": { + "named_count": named_snapshot_count, + "recent": _recent_events({"snapshot_create", "snapshot_delete"}), + }, + } + + if payload["state"] != "started": + payload["changes"] = { + "available": False, + "reason": ( + f"workspace {workspace_id!r} must be in 'started' state before " + "workspace_summary can compute current changes" + ), + "changed": False, + "summary": None, + "entries": [], + } + return payload + + try: + diff_payload = self.diff_workspace(workspace_id) + except Exception as exc: + payload["changes"] = { + "available": False, + "reason": str(exc), + "changed": False, + "summary": None, + "entries": [], + } + return payload + + diff_entries: list[dict[str, Any]] = [] + raw_entries = diff_payload.get("entries") + if isinstance(raw_entries, list): + for entry in raw_entries[:10]: + if not isinstance(entry, dict): + continue + diff_entries.append( + {key: value for key, value in entry.items() if key != "text_patch"} + ) + payload["changes"] = { + "available": True, + "reason": None, + "changed": bool(diff_payload.get("changed", False)), + "summary": diff_payload.get("summary"), + "entries": diff_entries, + } + return payload + def stop_workspace(self, workspace_id: str) -> dict[str, Any]: with self._lock: workspace = self._load_workspace_locked(workspace_id) @@ -5196,9 +5635,7 @@ class VmManager: self._stop_workspace_services_locked(workspace, instance) self._close_workspace_shells_locked(workspace, instance) try: - self._require_workspace_network_policy_support( - network_policy=workspace.network_policy - ) + self._require_workspace_network_policy_support(network_policy=workspace.network_policy) if self._runtime_capabilities.supports_guest_exec: self._ensure_workspace_guest_bootstrap_support(instance) with self._lock: @@ -5381,9 +5818,7 @@ class VmManager: "execution_mode": workspace.metadata.get("execution_mode", "pending"), "workspace_path": WORKSPACE_GUEST_PATH, "workspace_seed": _workspace_seed_dict(workspace.workspace_seed), - "secrets": [ - _serialize_workspace_secret_public(secret) for secret in workspace.secrets - ], + "secrets": [_serialize_workspace_secret_public(secret) for secret in workspace.secrets], "command_count": workspace.command_count, "last_command": workspace.last_command, "reset_count": workspace.reset_count, @@ -5554,9 +5989,7 @@ class VmManager: env_values: dict[str, str] = {} for secret_name, env_name in secret_env.items(): if secret_name not in secret_values: - raise ValueError( - f"secret_env references unknown workspace secret {secret_name!r}" - ) + raise ValueError(f"secret_env references unknown workspace secret {secret_name!r}") env_values[env_name] = secret_values[secret_name] return env_values @@ -5663,12 +6096,30 @@ class VmManager: execution_mode = instance.metadata.get("execution_mode", "unknown") return exec_result, execution_mode - def _prepare_workspace_seed(self, seed_path: str | Path | None) -> PreparedWorkspaceSeed: + def _prepare_workspace_seed( + self, + seed_path: str | Path | None, + *, + origin_kind: WorkspaceSeedOriginKind | None = None, + origin_ref: str | None = None, + ) -> PreparedWorkspaceSeed: if seed_path is None: - return PreparedWorkspaceSeed(mode="empty", source_path=None) + return PreparedWorkspaceSeed( + mode="empty", + source_path=None, + origin_kind="empty" if origin_kind is None else origin_kind, + origin_ref=origin_ref, + ) resolved_source_path = Path(seed_path).expanduser().resolve() if not resolved_source_path.exists(): raise ValueError(f"seed_path {resolved_source_path} does not exist") + effective_origin_kind: WorkspaceSeedOriginKind = ( + "manual_seed_path" if origin_kind is None else origin_kind + ) + effective_origin_ref = str(resolved_source_path) if origin_ref is None else origin_ref + public_source_path = ( + None if effective_origin_kind == "repo_url" else str(resolved_source_path) + ) if resolved_source_path.is_dir(): cleanup_dir = Path(tempfile.mkdtemp(prefix="pyro-workspace-seed-")) archive_path = cleanup_dir / "workspace-seed.tar" @@ -5680,23 +6131,24 @@ class VmManager: raise return PreparedWorkspaceSeed( mode="directory", - source_path=str(resolved_source_path), + source_path=public_source_path, + origin_kind=effective_origin_kind, + origin_ref=effective_origin_ref, archive_path=archive_path, entry_count=entry_count, bytes_written=bytes_written, cleanup_dir=cleanup_dir, ) - if ( - not resolved_source_path.is_file() - or not _is_supported_seed_archive(resolved_source_path) + if not resolved_source_path.is_file() or not _is_supported_seed_archive( + resolved_source_path ): - raise ValueError( - "seed_path must be a directory or a .tar/.tar.gz/.tgz archive" - ) + raise ValueError("seed_path must be a directory or a .tar/.tar.gz/.tgz archive") entry_count, bytes_written = _inspect_seed_archive(resolved_source_path) return PreparedWorkspaceSeed( mode="tar_archive", - source_path=str(resolved_source_path), + source_path=public_source_path, + origin_kind=effective_origin_kind, + origin_ref=effective_origin_ref, archive_path=resolved_source_path, entry_count=entry_count, bytes_written=bytes_written, @@ -5757,6 +6209,9 @@ class VmManager: def _workspace_commands_dir(self, workspace_id: str) -> Path: return self._workspace_dir(workspace_id) / WORKSPACE_COMMANDS_DIRNAME + def _workspace_review_dir(self, workspace_id: str) -> Path: + return self._workspace_dir(workspace_id) / WORKSPACE_REVIEW_DIRNAME + def _workspace_shells_dir(self, workspace_id: str) -> Path: return self._workspace_dir(workspace_id) / WORKSPACE_SHELLS_DIRNAME @@ -5772,6 +6227,9 @@ class VmManager: 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_review_record_path(self, workspace_id: str, event_id: str) -> Path: + return self._workspace_review_dir(workspace_id) / f"{event_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" @@ -5787,8 +6245,7 @@ class VmManager: rootfs_path = Path(raw_rootfs_image) if not rootfs_path.exists(): raise RuntimeError( - f"workspace {workspace.workspace_id!r} rootfs image is unavailable at " - f"{rootfs_path}" + f"workspace {workspace.workspace_id!r} rootfs image is unavailable at {rootfs_path}" ) return rootfs_path @@ -5805,9 +6262,7 @@ class VmManager: f"workspace {workspace.workspace_id!r} must be stopped before {operation_name}" ) if workspace.metadata.get("execution_mode") == "host_compat": - raise RuntimeError( - f"{operation_name} is unavailable for host_compat workspaces" - ) + raise RuntimeError(f"{operation_name} is unavailable for host_compat workspaces") return self._workspace_rootfs_image_path_locked(workspace) def _scrub_workspace_runtime_state_locked( @@ -5966,6 +6421,46 @@ class VmManager: entries.append(entry) return entries + def _record_workspace_review_event_locked( + self, + workspace_id: str, + *, + event_kind: WorkspaceReviewEventKind, + payload: dict[str, Any], + when: float | None = None, + ) -> WorkspaceReviewEventRecord: + recorded_at = time.time() if when is None else when + event = WorkspaceReviewEventRecord( + workspace_id=workspace_id, + event_kind=event_kind, + recorded_at=recorded_at, + payload=dict(payload), + ) + event_id = f"{int(recorded_at * 1_000_000_000):020d}-{uuid.uuid4().hex[:8]}" + record_path = self._workspace_review_record_path(workspace_id, event_id) + record_path.parent.mkdir(parents=True, exist_ok=True) + record_path.write_text( + json.dumps(event.to_payload(), indent=2, sort_keys=True), + encoding="utf-8", + ) + return event + + def _list_workspace_review_events_locked( + self, + workspace_id: str, + ) -> list[WorkspaceReviewEventRecord]: + review_dir = self._workspace_review_dir(workspace_id) + if not review_dir.exists(): + return [] + events: list[WorkspaceReviewEventRecord] = [] + for record_path in sorted(review_dir.glob("*.json")): + payload = json.loads(record_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + continue + events.append(WorkspaceReviewEventRecord.from_payload(payload)) + events.sort(key=lambda item: (item.recorded_at, item.event_kind)) + return events + def _workspace_instance_for_live_shell_locked(self, workspace: WorkspaceRecord) -> VmInstance: instance = self._workspace_instance_for_live_operation_locked( workspace, @@ -6322,10 +6817,12 @@ class VmManager: shutil.rmtree(self._workspace_runtime_dir(workspace_id), ignore_errors=True) shutil.rmtree(self._workspace_host_dir(workspace_id), ignore_errors=True) shutil.rmtree(self._workspace_commands_dir(workspace_id), ignore_errors=True) + shutil.rmtree(self._workspace_review_dir(workspace_id), ignore_errors=True) shutil.rmtree(self._workspace_shells_dir(workspace_id), ignore_errors=True) shutil.rmtree(self._workspace_services_dir(workspace_id), ignore_errors=True) self._workspace_host_dir(workspace_id).mkdir(parents=True, exist_ok=True) self._workspace_commands_dir(workspace_id).mkdir(parents=True, exist_ok=True) + self._workspace_review_dir(workspace_id).mkdir(parents=True, exist_ok=True) self._workspace_shells_dir(workspace_id).mkdir(parents=True, exist_ok=True) self._workspace_services_dir(workspace_id).mkdir(parents=True, exist_ok=True) diff --git a/src/pyro_mcp/workspace_use_case_smokes.py b/src/pyro_mcp/workspace_use_case_smokes.py index 01c1338..b69a90d 100644 --- a/src/pyro_mcp/workspace_use_case_smokes.py +++ b/src/pyro_mcp/workspace_use_case_smokes.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import asyncio import tempfile import time from dataclasses import dataclass @@ -28,7 +29,7 @@ USE_CASE_CHOICES: Final[tuple[str, ...]] = USE_CASE_SCENARIOS + (USE_CASE_ALL_SC class WorkspaceUseCaseRecipe: scenario: str title: str - profile: Literal["workspace-core", "workspace-full"] + mode: Literal["repro-fix", "inspect", "cold-start", "review-eval"] smoke_target: str doc_path: str summary: str @@ -38,7 +39,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = ( WorkspaceUseCaseRecipe( scenario="cold-start-validation", title="Cold-Start Repo Validation", - profile="workspace-full", + mode="cold-start", smoke_target="smoke-cold-start-validation", doc_path="docs/use-cases/cold-start-repo-validation.md", summary=( @@ -49,7 +50,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = ( WorkspaceUseCaseRecipe( scenario="repro-fix-loop", title="Repro Plus Fix Loop", - profile="workspace-core", + mode="repro-fix", smoke_target="smoke-repro-fix-loop", doc_path="docs/use-cases/repro-fix-loop.md", summary=( @@ -60,7 +61,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = ( WorkspaceUseCaseRecipe( scenario="parallel-workspaces", title="Parallel Isolated Workspaces", - profile="workspace-core", + mode="repro-fix", smoke_target="smoke-parallel-workspaces", doc_path="docs/use-cases/parallel-workspaces.md", summary=( @@ -71,7 +72,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = ( WorkspaceUseCaseRecipe( scenario="untrusted-inspection", title="Unsafe Or Untrusted Code Inspection", - profile="workspace-core", + mode="inspect", smoke_target="smoke-untrusted-inspection", doc_path="docs/use-cases/untrusted-inspection.md", summary=( @@ -82,7 +83,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = ( WorkspaceUseCaseRecipe( scenario="review-eval", title="Review And Evaluation Workflows", - profile="workspace-full", + mode="review-eval", smoke_target="smoke-review-eval", doc_path="docs/use-cases/review-eval-workflows.md", summary=( @@ -107,6 +108,15 @@ def _log(message: str) -> None: print(f"[smoke] {message}", flush=True) +def _extract_structured_tool_result(raw_result: object) -> dict[str, object]: + if not isinstance(raw_result, tuple) or len(raw_result) != 2: + raise TypeError("unexpected MCP tool result shape") + _, structured = raw_result + if not isinstance(structured, dict): + raise TypeError("expected structured dictionary result") + return structured + + def _create_workspace( pyro: Pyro, *, @@ -126,6 +136,31 @@ def _create_workspace( return str(created["workspace_id"]) +def _create_project_aware_workspace( + pyro: Pyro, + *, + environment: str, + project_path: Path, + mode: Literal["repro-fix", "cold-start"], + name: str, + labels: dict[str, str], +) -> dict[str, object]: + async def _run() -> dict[str, object]: + server = pyro.create_server(mode=mode, project_path=project_path) + return _extract_structured_tool_result( + await server.call_tool( + "workspace_create", + { + "environment": environment, + "name": name, + "labels": labels, + }, + ) + ) + + return asyncio.run(_run()) + + def _safe_delete_workspace(pyro: Pyro, workspace_id: str | None) -> None: if workspace_id is None: return @@ -160,14 +195,19 @@ def _scenario_cold_start_validation(pyro: Pyro, *, root: Path, environment: str) ) workspace_id: str | None = None try: - workspace_id = _create_workspace( + created = _create_project_aware_workspace( pyro, environment=environment, - seed_path=seed_dir, + project_path=seed_dir, + mode="cold-start", name="cold-start-validation", labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "cold-start-validation"}, ) + workspace_id = str(created["workspace_id"]) _log(f"cold-start-validation workspace_id={workspace_id}") + workspace_seed = created["workspace_seed"] + assert isinstance(workspace_seed, dict), created + assert workspace_seed["origin_kind"] == "project_path", created validation = pyro.exec_workspace(workspace_id, command="sh validate.sh") assert int(validation["exit_code"]) == 0, validation assert str(validation["stdout"]) == "validated\n", validation @@ -221,14 +261,20 @@ def _scenario_repro_fix_loop(pyro: Pyro, *, root: Path, environment: str) -> Non ) workspace_id: str | None = None try: - workspace_id = _create_workspace( + created = _create_project_aware_workspace( pyro, environment=environment, - seed_path=seed_dir, + project_path=seed_dir, + mode="repro-fix", name="repro-fix-loop", labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "repro-fix-loop"}, ) + workspace_id = str(created["workspace_id"]) _log(f"repro-fix-loop workspace_id={workspace_id}") + workspace_seed = created["workspace_seed"] + assert isinstance(workspace_seed, dict), created + assert workspace_seed["origin_kind"] == "project_path", created + assert workspace_seed["origin_ref"] == str(seed_dir.resolve()), created initial_read = pyro.read_workspace_file(workspace_id, "message.txt") assert str(initial_read["content"]) == "broken\n", initial_read failing = pyro.exec_workspace(workspace_id, command="sh check.sh") @@ -418,6 +464,11 @@ def _scenario_review_eval(pyro: Pyro, *, root: Path, environment: str) -> None: assert int(rerun["exit_code"]) == 0, rerun pyro.export_workspace(workspace_id, "review-report.txt", output_path=export_path) assert export_path.read_text(encoding="utf-8") == "review=pass\n" + summary = pyro.summarize_workspace(workspace_id) + assert summary["workspace_id"] == workspace_id, summary + assert summary["changes"]["available"] is True, summary + assert summary["artifacts"]["exports"], summary + assert summary["snapshots"]["named_count"] >= 1, summary finally: if shell_id is not None and workspace_id is not None: try: @@ -451,7 +502,7 @@ def run_workspace_use_case_scenario( scenario_names = USE_CASE_SCENARIOS if scenario == USE_CASE_ALL_SCENARIO else (scenario,) for scenario_name in scenario_names: recipe = _RECIPE_BY_SCENARIO[scenario_name] - _log(f"starting {recipe.scenario} ({recipe.title}) profile={recipe.profile}") + _log(f"starting {recipe.scenario} ({recipe.title}) mode={recipe.mode}") scenario_root = root / scenario_name scenario_root.mkdir(parents=True, exist_ok=True) runner = _SCENARIO_RUNNERS[scenario_name] diff --git a/tests/test_api.py b/tests/test_api.py index 8772754..56b461f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,12 +1,19 @@ from __future__ import annotations import asyncio +import subprocess import time from pathlib import Path from typing import Any, cast +import pytest + from pyro_mcp.api import Pyro from pyro_mcp.contract import ( + PUBLIC_MCP_COLD_START_MODE_TOOLS, + PUBLIC_MCP_INSPECT_MODE_TOOLS, + PUBLIC_MCP_REPRO_FIX_MODE_TOOLS, + PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS, PUBLIC_MCP_VM_RUN_PROFILE_TOOLS, PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS, PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS, @@ -15,6 +22,28 @@ from pyro_mcp.vm_manager import VmManager from pyro_mcp.vm_network import TapNetworkManager +def _git(repo: Path, *args: str) -> str: + result = subprocess.run( # noqa: S603 + ["git", "-c", "commit.gpgsign=false", *args], + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def _make_repo(root: Path, *, content: str = "hello\n") -> Path: + root.mkdir() + _git(root, "init") + _git(root, "config", "user.name", "Pyro Tests") + _git(root, "config", "user.email", "pyro-tests@example.com") + (root / "note.txt").write_text(content, encoding="utf-8") + _git(root, "add", "note.txt") + _git(root, "commit", "-m", "init") + return root + + def test_pyro_run_in_vm_delegates_to_manager(tmp_path: Path) -> None: pyro = Pyro( manager=VmManager( @@ -134,6 +163,172 @@ def test_pyro_create_server_workspace_core_profile_registers_expected_tools_and_ assert "workspace_disk_export" not in tool_map +def test_pyro_create_server_repro_fix_mode_registers_expected_tools_and_schemas( + tmp_path: Path, +) -> None: + pyro = Pyro( + manager=VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + ) + + async def _run() -> tuple[list[str], dict[str, dict[str, Any]]]: + server = pyro.create_server(mode="repro-fix") + tools = await server.list_tools() + tool_map = {tool.name: tool.model_dump() for tool in tools} + return sorted(tool_map), tool_map + + tool_names, tool_map = asyncio.run(_run()) + assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_REPRO_FIX_MODE_TOOLS)) + create_properties = tool_map["workspace_create"]["inputSchema"]["properties"] + assert "network_policy" not in create_properties + assert "secrets" not in create_properties + exec_properties = tool_map["workspace_exec"]["inputSchema"]["properties"] + assert "secret_env" not in exec_properties + assert "service_start" not in tool_map + assert "shell_open" not in tool_map + assert "snapshot_create" not in tool_map + assert "reproduce a failure" in str(tool_map["workspace_create"]["description"]) + + +def test_pyro_create_server_cold_start_mode_registers_expected_tools_and_schemas( + tmp_path: Path, +) -> None: + pyro = Pyro( + manager=VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + ) + + async def _run() -> tuple[list[str], dict[str, dict[str, Any]]]: + server = pyro.create_server(mode="cold-start") + tools = await server.list_tools() + tool_map = {tool.name: tool.model_dump() for tool in tools} + return sorted(tool_map), tool_map + + tool_names, tool_map = asyncio.run(_run()) + assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_COLD_START_MODE_TOOLS)) + assert "shell_open" not in tool_map + assert "snapshot_create" not in tool_map + service_start_properties = tool_map["service_start"]["inputSchema"]["properties"] + assert "secret_env" not in service_start_properties + assert "published_ports" not in service_start_properties + create_properties = tool_map["workspace_create"]["inputSchema"]["properties"] + assert "network_policy" not in create_properties + + +def test_pyro_create_server_review_eval_mode_registers_expected_tools_and_schemas( + tmp_path: Path, +) -> None: + pyro = Pyro( + manager=VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + ) + + async def _run() -> tuple[list[str], dict[str, dict[str, Any]]]: + server = pyro.create_server(mode="review-eval") + tools = await server.list_tools() + tool_map = {tool.name: tool.model_dump() for tool in tools} + return sorted(tool_map), tool_map + + tool_names, tool_map = asyncio.run(_run()) + assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS)) + assert "service_start" not in tool_map + assert "shell_open" in tool_map + assert "snapshot_create" in tool_map + shell_open_properties = tool_map["shell_open"]["inputSchema"]["properties"] + assert "secret_env" not in shell_open_properties + + +def test_pyro_create_server_inspect_mode_registers_expected_tools(tmp_path: Path) -> None: + pyro = Pyro( + manager=VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + ) + + async def _run() -> list[str]: + server = pyro.create_server(mode="inspect") + tools = await server.list_tools() + return sorted(tool.name for tool in tools) + + assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_INSPECT_MODE_TOOLS)) + + +def test_pyro_create_server_rejects_mode_and_non_default_profile(tmp_path: Path) -> None: + pyro = Pyro( + manager=VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + ) + + with pytest.raises(ValueError, match="mutually exclusive"): + pyro.create_server(profile="workspace-full", mode="repro-fix") + + +def test_pyro_create_server_project_path_updates_workspace_create_description_and_default_seed( + tmp_path: Path, +) -> None: + repo = _make_repo(tmp_path / "repo", content="project-aware\n") + pyro = Pyro( + manager=VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + ) + + def _extract_structured(raw_result: object) -> dict[str, Any]: + if not isinstance(raw_result, tuple) or len(raw_result) != 2: + raise TypeError("unexpected call_tool result shape") + _, structured = raw_result + if not isinstance(structured, dict): + 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]]: + server = pyro.create_server(project_path=repo) + tools = await server.list_tools() + tool_map = {tool.name: tool.model_dump() for tool in tools} + created = _extract_structured( + await server.call_tool( + "workspace_create", + { + "environment": "debian:12-base", + "allow_host_compat": True, + }, + ) + ) + executed = _extract_structured( + await server.call_tool( + "workspace_exec", + { + "workspace_id": created["workspace_id"], + "command": "cat note.txt", + }, + ) + ) + return tool_map["workspace_create"], created, executed + + workspace_create_tool, created, executed = asyncio.run(_run()) + assert "If `seed_path` is omitted" in str(workspace_create_tool["description"]) + assert str(repo.resolve()) in str(workspace_create_tool["description"]) + assert created["workspace_seed"]["origin_kind"] == "project_path" + assert created["workspace_seed"]["origin_ref"] == str(repo.resolve()) + assert executed["stdout"] == "project-aware\n" + + def test_pyro_vm_run_tool_executes(tmp_path: Path) -> None: pyro = Pyro( manager=VmManager( @@ -380,6 +575,7 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None: services = pyro.list_services(workspace_id) service_status = pyro.status_service(workspace_id, "app") service_logs = pyro.logs_service(workspace_id, "app", all=True) + summary = pyro.summarize_workspace(workspace_id) reset = pyro.reset_workspace(workspace_id, snapshot="checkpoint") deleted_snapshot = pyro.delete_snapshot(workspace_id, "checkpoint") status = pyro.status_workspace(workspace_id) @@ -416,6 +612,9 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None: assert service_status["state"] == "running" assert service_logs["stderr"].count("[REDACTED]") >= 1 assert service_logs["tail_lines"] is None + assert summary["workspace_id"] == workspace_id + assert summary["commands"]["total"] >= 1 + assert summary["changes"]["available"] is True assert reset["workspace_reset"]["snapshot_name"] == "checkpoint" assert reset["secrets"] == created["secrets"] assert deleted_snapshot["deleted"] is True @@ -979,6 +1178,14 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non calls.append(("logs_workspace", {"workspace_id": workspace_id})) return {"workspace_id": workspace_id, "count": 0, "entries": []} + def summarize_workspace(self, workspace_id: str) -> dict[str, Any]: + calls.append(("summarize_workspace", {"workspace_id": workspace_id})) + return { + "workspace_id": workspace_id, + "state": "started", + "changes": {"available": True, "changed": False, "summary": None, "entries": []}, + } + def open_shell( self, workspace_id: str, @@ -1110,6 +1317,9 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non status = _extract_structured( await server.call_tool("workspace_status", {"workspace_id": "workspace-123"}) ) + summary = _extract_structured( + await server.call_tool("workspace_summary", {"workspace_id": "workspace-123"}) + ) logs = _extract_structured( await server.call_tool("workspace_logs", {"workspace_id": "workspace-123"}) ) @@ -1211,6 +1421,7 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non ) return ( status, + summary, logs, opened, read, @@ -1225,13 +1436,15 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non results = asyncio.run(_run()) assert results[0]["state"] == "started" - assert results[1]["count"] == 0 - assert results[2]["shell_id"] == "shell-1" - assert results[6]["closed"] is True - assert results[7]["state"] == "running" - assert results[10]["state"] == "running" + assert results[1]["workspace_id"] == "workspace-123" + assert results[2]["count"] == 0 + assert results[3]["shell_id"] == "shell-1" + assert results[7]["closed"] is True + assert results[8]["state"] == "running" + assert results[11]["state"] == "running" assert calls == [ ("status_workspace", {"workspace_id": "workspace-123"}), + ("summarize_workspace", {"workspace_id": "workspace-123"}), ("logs_workspace", {"workspace_id": "workspace-123"}), ( "open_shell", diff --git a/tests/test_cli.py b/tests/test_cli.py index aaef8d1..ecb6e74 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,6 +9,7 @@ from typing import Any, cast import pytest import pyro_mcp.cli as cli +from pyro_mcp.host_helpers import HostDoctorEntry def _subparser_choice(parser: argparse.ArgumentParser, name: str) -> argparse.ArgumentParser: @@ -26,17 +27,24 @@ def test_cli_help_guides_first_run() -> None: parser = cli._build_parser() help_text = parser.format_help() - assert "Suggested first run:" in help_text + assert "Suggested zero-to-hero path:" in help_text assert "pyro doctor" in help_text - assert "pyro env list" in help_text - assert "pyro env pull debian:12" in help_text + assert "pyro prepare debian:12" in help_text assert "pyro run debian:12 -- git --version" in help_text - assert "Continue into the stable workspace path after that:" in help_text + assert "pyro host connect claude-code" in help_text + assert "Connect a chat host after that:" in help_text + assert "pyro host connect claude-code" in help_text + assert "pyro host connect codex" in help_text + assert "pyro host print-config opencode" in help_text + assert "Daily local loop after the first warmup:" in help_text + assert "pyro doctor --environment debian:12" in help_text + assert "pyro workspace reset WORKSPACE_ID" in help_text + assert "If you want terminal-level visibility into the workspace model:" in help_text assert "pyro workspace exec WORKSPACE_ID -- cat note.txt" in help_text + assert "pyro workspace summary WORKSPACE_ID" in help_text assert "pyro workspace snapshot create WORKSPACE_ID checkpoint" in help_text assert "pyro workspace reset WORKSPACE_ID --snapshot checkpoint" in help_text assert "pyro workspace sync push WORKSPACE_ID ./changes" in help_text - assert "Use `pyro mcp serve` only after the CLI validation path works." in help_text def test_cli_subcommand_help_includes_examples_and_guidance() -> None: @@ -54,8 +62,41 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None: assert "pyro env pull debian:12" in env_help assert "downloads from public Docker Hub" in env_help + prepare_help = _subparser_choice(parser, "prepare").format_help() + assert "Warm the recommended guest-backed daily loop" in prepare_help + assert "pyro prepare debian:12 --network" in prepare_help + assert "--network" in prepare_help + assert "--force" in prepare_help + + host_help = _subparser_choice(parser, "host").format_help() + assert "Connect or repair the supported Claude Code, Codex, and OpenCode" in host_help + assert "pyro host connect claude-code" in host_help + assert "pyro host repair opencode" in host_help + + host_connect_help = _subparser_choice( + _subparser_choice(parser, "host"), "connect" + ).format_help() + assert "--installed-package" in host_connect_help + assert "--project-path" in host_connect_help + assert "--repo-url" in host_connect_help + assert "--repo-ref" in host_connect_help + assert "--no-project-source" in host_connect_help + + host_print_config_help = _subparser_choice( + _subparser_choice(parser, "host"), "print-config" + ).format_help() + assert "--output" in host_print_config_help + + host_doctor_help = _subparser_choice(_subparser_choice(parser, "host"), "doctor").format_help() + assert "--config-path" in host_doctor_help + + host_repair_help = _subparser_choice(_subparser_choice(parser, "host"), "repair").format_help() + assert "--config-path" in host_repair_help + doctor_help = _subparser_choice(parser, "doctor").format_help() assert "Check host prerequisites and embedded runtime health" in doctor_help + assert "--environment" in doctor_help + assert "pyro doctor --environment debian:12" in doctor_help assert "pyro doctor --json" in doctor_help demo_help = _subparser_choice(parser, "demo").format_help() @@ -69,17 +110,22 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None: assert "vm-run" in mcp_help assert "recommended first profile for most chat hosts" in mcp_help assert "workspace-core: default for normal persistent chat editing" in mcp_help - assert "workspace-full: advanced 4.x opt-in surface" in mcp_help + assert "workspace-full: larger opt-in surface" in mcp_help + assert "--project-path" in mcp_help + assert "--repo-url" in mcp_help + assert "--repo-ref" in mcp_help + assert "--no-project-source" in mcp_help + assert "pyro mcp serve --project-path ." in mcp_help + assert "pyro mcp serve --repo-url https://github.com/example/project.git" in mcp_help workspace_help = _subparser_choice(parser, "workspace").format_help() - assert "stable workspace contract" in workspace_help + assert "Use the workspace model when you need one sandbox to stay alive" in workspace_help assert "pyro workspace create debian:12 --seed-path ./repo" in workspace_help assert "--id-only" in workspace_help assert "pyro workspace create debian:12 --name repro-fix --label issue=123" in workspace_help assert "pyro workspace list" in workspace_help assert ( - "pyro workspace update WORKSPACE_ID --name retry-run --label owner=codex" - in workspace_help + "pyro workspace update WORKSPACE_ID --name retry-run --label owner=codex" in workspace_help ) assert "pyro workspace sync push WORKSPACE_ID ./repo --dest src" in workspace_help assert "pyro workspace exec WORKSPACE_ID" in workspace_help @@ -91,6 +137,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None: assert "pyro workspace start WORKSPACE_ID" in workspace_help assert "pyro workspace snapshot create WORKSPACE_ID checkpoint" in workspace_help assert "pyro workspace reset WORKSPACE_ID --snapshot checkpoint" in workspace_help + assert "pyro workspace summary WORKSPACE_ID" in workspace_help assert "pyro workspace shell open WORKSPACE_ID --id-only" in workspace_help workspace_create_help = _subparser_choice( @@ -145,6 +192,12 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None: assert "--label" in workspace_update_help assert "--clear-label" in workspace_update_help + workspace_summary_help = _subparser_choice( + _subparser_choice(parser, "workspace"), "summary" + ).format_help() + assert "Summarize the current workspace session since the last reset" in workspace_summary_help + assert "pyro workspace summary WORKSPACE_ID" in workspace_summary_help + workspace_file_help = _subparser_choice( _subparser_choice(parser, "workspace"), "file" ).format_help() @@ -307,6 +360,94 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None: assert "Close a persistent workspace shell" in workspace_shell_close_help +def test_cli_host_connect_dispatch( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + pass + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="host", + host_command="connect", + host="codex", + installed_package=False, + profile="workspace-core", + project_path=None, + repo_url=None, + repo_ref=None, + no_project_source=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + monkeypatch.setattr( + cli, + "connect_cli_host", + lambda host, *, config: { + "host": host, + "server_command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"], + "verification_command": ["codex", "mcp", "list"], + }, + ) + + cli.main() + captured = capsys.readouterr() + assert captured.out == ( + "Connected pyro to codex.\n" + "Server command: uvx --from pyro-mcp pyro mcp serve\n" + "Verify with: codex mcp list\n" + ) + assert captured.err == "" + + +def test_cli_host_doctor_prints_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + pass + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="host", + host_command="doctor", + installed_package=False, + profile="workspace-core", + project_path=None, + repo_url=None, + repo_ref=None, + no_project_source=False, + config_path=None, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + monkeypatch.setattr( + cli, + "doctor_hosts", + lambda **_: [ + HostDoctorEntry( + host="codex", + installed=True, + configured=False, + status="missing", + details="codex entry missing", + repair_command="pyro host repair codex", + ) + ], + ) + + cli.main() + captured = capsys.readouterr() + assert "codex: missing installed=yes configured=no" in captured.out + assert "repair: pyro host repair codex" in captured.out + assert captured.err == "" + + def test_cli_run_prints_json( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], @@ -344,13 +485,22 @@ def test_cli_doctor_prints_json( ) -> None: class StubParser: def parse_args(self) -> argparse.Namespace: - return argparse.Namespace(command="doctor", platform="linux-x86_64", json=True) + return argparse.Namespace( + command="doctor", + platform="linux-x86_64", + environment="debian:12", + json=True, + ) monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) monkeypatch.setattr( cli, "doctor_report", - lambda platform: {"platform": platform, "runtime_ok": True}, + lambda *, platform, environment: { + "platform": platform, + "environment": environment, + "runtime_ok": True, + }, ) cli.main() output = json.loads(capsys.readouterr().out) @@ -569,7 +719,7 @@ def test_cli_requires_command_preserves_shell_argument_boundaries() -> None: command = cli._require_command( ["--", "sh", "-lc", 'printf "hello from workspace\\n" > note.txt'] ) - assert command == 'sh -lc \'printf "hello from workspace\\n" > note.txt\'' + assert command == "sh -lc 'printf \"hello from workspace\\n\" > note.txt'" def test_cli_read_utf8_text_file_rejects_non_utf8(tmp_path: Path) -> None: @@ -845,10 +995,7 @@ def test_cli_workspace_exec_prints_human_output( cli.main() captured = capsys.readouterr() assert captured.out == "hello\n" - assert ( - "[workspace-exec] workspace_id=workspace-123 sequence=2 cwd=/workspace" - in captured.err - ) + assert "[workspace-exec] workspace_id=workspace-123 sequence=2 cwd=/workspace" in captured.err def test_print_workspace_summary_human_handles_last_command_and_secret_filtering( @@ -1319,13 +1466,7 @@ def test_cli_workspace_patch_apply_reads_patch_file( tmp_path: Path, ) -> None: patch_path = tmp_path / "fix.patch" - patch_text = ( - "--- a/src/app.py\n" - "+++ b/src/app.py\n" - "@@ -1 +1 @@\n" - "-print('hi')\n" - "+print('hello')\n" - ) + patch_text = "--- a/src/app.py\n+++ b/src/app.py\n@@ -1 +1 @@\n-print('hi')\n+print('hello')\n" patch_path.write_text(patch_text, encoding="utf-8") class StubPyro: @@ -1773,10 +1914,7 @@ def test_cli_workspace_diff_prints_human_output( monkeypatch.setattr(cli, "Pyro", StubPyro) cli.main() output = capsys.readouterr().out - assert ( - "[workspace-diff] workspace_id=workspace-123 total=1 added=0 modified=1" - in output - ) + assert "[workspace-diff] workspace_id=workspace-123 total=1 added=0 modified=1" in output assert "--- a/note.txt" in output @@ -2223,8 +2361,7 @@ def test_cli_workspace_sync_push_prints_human( output = capsys.readouterr().out assert "[workspace-sync] workspace_id=workspace-123 mode=directory source=/tmp/repo" in output assert ( - "destination=/workspace entry_count=2 bytes_written=12 " - "execution_mode=guest_vsock" + "destination=/workspace entry_count=2 bytes_written=12 execution_mode=guest_vsock" ) in output @@ -2391,6 +2528,168 @@ def test_cli_workspace_logs_prints_json( assert payload["count"] == 0 +def test_cli_workspace_summary_prints_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def summarize_workspace(self, workspace_id: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + return { + "workspace_id": workspace_id, + "name": "review-eval", + "labels": {"suite": "smoke"}, + "environment": "debian:12", + "state": "started", + "last_activity_at": 2.0, + "session_started_at": 1.0, + "outcome": { + "command_count": 1, + "last_command": {"command": "cat note.txt", "exit_code": 0}, + "service_count": 0, + "running_service_count": 0, + "export_count": 1, + "snapshot_count": 1, + "reset_count": 0, + }, + "commands": {"total": 1, "recent": []}, + "edits": {"recent": []}, + "changes": {"available": True, "changed": False, "summary": None, "entries": []}, + "services": {"current": [], "recent": []}, + "artifacts": {"exports": []}, + "snapshots": {"named_count": 1, "recent": []}, + } + + class SummaryParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="summary", + workspace_id="workspace-123", + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: SummaryParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + payload = json.loads(capsys.readouterr().out) + assert payload["workspace_id"] == "workspace-123" + assert payload["outcome"]["export_count"] == 1 + + +def test_cli_workspace_summary_prints_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def summarize_workspace(self, workspace_id: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + return { + "workspace_id": workspace_id, + "name": "review-eval", + "labels": {"suite": "smoke", "use_case": "review-eval"}, + "environment": "debian:12", + "state": "started", + "last_activity_at": 3.0, + "session_started_at": 1.0, + "outcome": { + "command_count": 2, + "last_command": {"command": "sh review.sh", "exit_code": 0}, + "service_count": 1, + "running_service_count": 0, + "export_count": 1, + "snapshot_count": 1, + "reset_count": 0, + }, + "commands": { + "total": 2, + "recent": [ + { + "sequence": 2, + "command": "sh review.sh", + "cwd": "/workspace", + "exit_code": 0, + "duration_ms": 12, + "execution_mode": "guest_vsock", + "recorded_at": 3.0, + } + ], + }, + "edits": { + "recent": [ + { + "event_kind": "patch_apply", + "recorded_at": 2.0, + "path": "/workspace/note.txt", + } + ] + }, + "changes": { + "available": True, + "changed": True, + "summary": { + "total": 1, + "added": 0, + "modified": 1, + "deleted": 0, + "type_changed": 0, + "text_patched": 1, + "non_text": 0, + }, + "entries": [ + { + "path": "/workspace/note.txt", + "status": "modified", + "artifact_type": "file", + } + ], + }, + "services": { + "current": [{"service_name": "app", "state": "stopped"}], + "recent": [ + { + "event_kind": "service_stop", + "service_name": "app", + "state": "stopped", + } + ], + }, + "artifacts": { + "exports": [ + { + "workspace_path": "review-report.txt", + "output_path": "/tmp/review-report.txt", + } + ] + }, + "snapshots": { + "named_count": 1, + "recent": [{"event_kind": "snapshot_create", "snapshot_name": "checkpoint"}], + }, + } + + class SummaryParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="summary", + workspace_id="workspace-123", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: SummaryParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = capsys.readouterr().out + assert "Workspace review: workspace-123" in output + assert "Outcome: commands=2 services=0/1 exports=1 snapshots=1 resets=0" in output + assert "Recent commands:" in output + assert "Recent edits:" in output + assert "Changes: total=1 added=0 modified=1 deleted=0 type_changed=0 non_text=0" in output + assert "Recent exports:" in output + assert "Recent snapshot events:" in output + + def test_cli_workspace_delete_prints_human( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], @@ -2805,7 +3104,7 @@ def test_cli_workspace_shell_open_prints_id_only( assert captured.err == "" -def test_chat_host_docs_and_examples_recommend_workspace_core() -> None: +def test_chat_host_docs_and_examples_recommend_modes_first() -> None: readme = Path("README.md").read_text(encoding="utf-8") install = Path("docs/install.md").read_text(encoding="utf-8") first_run = Path("docs/first-run.md").read_text(encoding="utf-8") @@ -2814,47 +3113,81 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None: claude_code = Path("examples/claude_code_mcp.md").read_text(encoding="utf-8") codex = Path("examples/codex_mcp.md").read_text(encoding="utf-8") opencode = json.loads(Path("examples/opencode_mcp_config.json").read_text(encoding="utf-8")) - claude_cmd = "claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve" - codex_cmd = "codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve" + claude_helper = "pyro host connect claude-code --mode cold-start" + codex_helper = "pyro host connect codex --mode repro-fix" + inspect_helper = "pyro host connect codex --mode inspect" + review_helper = "pyro host connect claude-code --mode review-eval" + opencode_helper = "pyro host print-config opencode --mode repro-fix" + claude_cmd = "claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start" + codex_cmd = "codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix" assert "## Chat Host Quickstart" in readme - assert "uvx --from pyro-mcp pyro mcp serve" in readme - assert claude_cmd in readme - assert codex_cmd in readme + assert claude_helper in readme + assert codex_helper in readme + assert inspect_helper in readme + assert review_helper in readme + assert opencode_helper in readme assert "examples/opencode_mcp_config.json" in readme - assert "recommended first profile for normal persistent chat editing" in readme + assert "pyro host doctor" in readme + assert "pyro mcp serve --mode repro-fix" in readme + assert "generic no-mode path" in readme + assert "auto-detects the current Git checkout" in readme.replace("\n", " ") + assert "--project-path /abs/path/to/repo" in readme + assert "--repo-url https://github.com/example/project.git" in readme - assert "## Chat Host Quickstart" in install - assert "uvx --from pyro-mcp pyro mcp serve" in install - assert claude_cmd in install - assert codex_cmd in install + assert "## 6. Connect a chat host" in install + assert claude_helper in install + assert codex_helper in install + assert inspect_helper in install + assert review_helper in install + assert opencode_helper in install assert "workspace-full" in install + assert "--project-path /abs/path/to/repo" in install + assert "pyro mcp serve --mode cold-start" in install - assert claude_cmd in first_run - assert codex_cmd in first_run + assert claude_helper in first_run + assert codex_helper in first_run + assert inspect_helper in first_run + assert review_helper in first_run + assert opencode_helper in first_run + assert "--project-path /abs/path/to/repo" in first_run + assert "pyro mcp serve --mode review-eval" in first_run - assert "Bare `pyro mcp serve` now starts `workspace-core`." in integrations + assert claude_helper in integrations + assert codex_helper in integrations + assert inspect_helper in integrations + assert review_helper in integrations + assert opencode_helper in integrations + assert "## Recommended Modes" in integrations + assert "pyro mcp serve --mode inspect" in integrations + assert "auto-detects the current Git checkout" in integrations assert "examples/claude_code_mcp.md" in integrations assert "examples/codex_mcp.md" in integrations assert "examples/opencode_mcp_config.json" in integrations - assert ( - '`Pyro.create_server()` for most chat hosts now that `workspace-core` ' - "is the default profile" in integrations - ) + assert "generic no-mode path" in integrations + assert "--project-path /abs/path/to/repo" in integrations + assert "--repo-url https://github.com/example/project.git" in integrations - assert "Default for most chat hosts in `4.x`: `workspace-core`." in mcp_config + assert "Recommended named modes for most chat hosts in `4.x`:" in mcp_config assert "Use the host-specific examples first when they apply:" in mcp_config assert "claude_code_mcp.md" in mcp_config assert "codex_mcp.md" in mcp_config assert "opencode_mcp_config.json" in mcp_config + assert '"serve", "--mode", "repro-fix"' in mcp_config + assert claude_helper in claude_code assert claude_cmd in claude_code assert "claude mcp list" in claude_code + assert "pyro host repair claude-code --mode cold-start" in claude_code assert "workspace-full" in claude_code + assert "--project-path /abs/path/to/repo" in claude_code + assert codex_helper in codex assert codex_cmd in codex assert "codex mcp list" in codex + assert "pyro host repair codex --mode repro-fix" in codex assert "workspace-full" in codex + assert "--project-path /abs/path/to/repo" in codex assert opencode == { "mcp": { @@ -2868,6 +3201,8 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None: "pyro", "mcp", "serve", + "--mode", + "repro-fix", ], } } @@ -2882,7 +3217,32 @@ def test_content_only_read_docs_are_aligned() -> None: assert 'workspace file read "$WORKSPACE_ID" note.txt --content-only' in readme assert 'workspace file read "$WORKSPACE_ID" note.txt --content-only' in install assert 'workspace file read "$WORKSPACE_ID" note.txt --content-only' in first_run - assert 'workspace disk read "$WORKSPACE_ID" note.txt --content-only' in first_run + assert 'workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch' in first_run + + +def test_daily_loop_docs_are_aligned() -> None: + readme = Path("README.md").read_text(encoding="utf-8") + install = Path("docs/install.md").read_text(encoding="utf-8") + first_run = Path("docs/first-run.md").read_text(encoding="utf-8") + integrations = Path("docs/integrations.md").read_text(encoding="utf-8") + + assert "pyro prepare debian:12" in readme + assert "pyro prepare debian:12" in install + assert "pyro prepare debian:12" in first_run + assert "pyro prepare debian:12" in integrations + assert "pyro doctor --environment debian:12" in readme + assert "pyro doctor --environment debian:12" in install + assert "pyro doctor --environment debian:12" in first_run + + +def test_workspace_summary_docs_are_aligned() -> None: + readme = Path("README.md").read_text(encoding="utf-8") + install = Path("docs/install.md").read_text(encoding="utf-8") + first_run = Path("docs/first-run.md").read_text(encoding="utf-8") + + assert 'workspace summary "$WORKSPACE_ID"' in readme + assert 'workspace summary "$WORKSPACE_ID"' in install + assert 'workspace summary "$WORKSPACE_ID"' in first_run def test_cli_workspace_shell_write_signal_close_json( @@ -3965,22 +4325,163 @@ def test_cli_doctor_prints_human( ) -> None: class StubParser: def parse_args(self) -> argparse.Namespace: - return argparse.Namespace(command="doctor", platform="linux-x86_64", json=False) + return argparse.Namespace( + command="doctor", + platform="linux-x86_64", + environment="debian:12", + json=False, + ) monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) monkeypatch.setattr( cli, "doctor_report", - lambda platform: { + lambda *, platform, environment: { "platform": platform, "runtime_ok": True, "issues": [], "kvm": {"exists": True, "readable": True, "writable": True}, + "runtime": {"catalog_version": "4.5.0", "cache_dir": "/cache"}, + "daily_loop": { + "environment": environment, + "status": "cold", + "installed": False, + "network_prepared": False, + "prepared_at": None, + "manifest_path": "/cache/.prepare/linux-x86_64/debian_12.json", + "reason": "daily loop has not been prepared yet", + "cache_dir": "/cache", + }, }, ) cli.main() output = capsys.readouterr().out assert "Runtime: PASS" in output + assert "Daily loop: COLD (debian:12)" in output + assert "Run: pyro prepare debian:12" in output + + +def test_cli_prepare_prints_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubManager: + def prepare_daily_loop( + self, + environment: str, + *, + network: bool, + force: bool, + ) -> dict[str, object]: + assert environment == "debian:12" + assert network is True + assert force is False + return { + "environment": environment, + "status": "warm", + "prepared": True, + "reused": False, + "executed": True, + "network_prepared": True, + "prepared_at": 123.0, + "manifest_path": "/cache/.prepare/linux-x86_64/debian_12.json", + "cache_dir": "/cache", + "last_prepare_duration_ms": 456, + "reason": None, + } + + class StubPyro: + def __init__(self) -> None: + self.manager = StubManager() + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="prepare", + environment="debian:12", + network=True, + force=False, + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = capsys.readouterr().out + assert "Prepare: debian:12" in output + assert "Daily loop: WARM" in output + assert "Result: prepared network_prepared=yes" in output + + +def test_cli_prepare_prints_json_and_errors( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class SuccessManager: + def prepare_daily_loop( + self, + environment: str, + *, + network: bool, + force: bool, + ) -> dict[str, object]: + assert environment == "debian:12" + assert network is False + assert force is True + return {"environment": environment, "reused": True} + + class SuccessPyro: + def __init__(self) -> None: + self.manager = SuccessManager() + + class SuccessParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="prepare", + environment="debian:12", + network=False, + force=True, + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: SuccessParser()) + monkeypatch.setattr(cli, "Pyro", SuccessPyro) + cli.main() + payload = json.loads(capsys.readouterr().out) + assert payload["reused"] is True + + class ErrorManager: + def prepare_daily_loop( + self, + environment: str, + *, + network: bool, + force: bool, + ) -> dict[str, object]: + del environment, network, force + raise RuntimeError("prepare failed") + + class ErrorPyro: + def __init__(self) -> None: + self.manager = ErrorManager() + + class ErrorParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="prepare", + environment="debian:12", + network=False, + force=False, + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: ErrorParser()) + monkeypatch.setattr(cli, "Pyro", ErrorPyro) + with pytest.raises(SystemExit, match="1"): + cli.main() + error_payload = json.loads(capsys.readouterr().out) + assert error_payload["ok"] is False + assert error_payload["error"] == "prepare failed" def test_cli_run_json_error_exits_nonzero( @@ -4017,11 +4518,25 @@ def test_cli_run_json_error_exits_nonzero( def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None: - observed: dict[str, str] = {} + observed: dict[str, Any] = {} class StubPyro: - def create_server(self, *, profile: str) -> Any: + def create_server( + self, + *, + profile: str, + mode: str | None, + project_path: str | None, + repo_url: str | None, + repo_ref: str | None, + no_project_source: bool, + ) -> Any: observed["profile"] = profile + observed["mode"] = mode + observed["project_path"] = project_path + observed["repo_url"] = repo_url + observed["repo_ref"] = repo_ref + observed["no_project_source"] = no_project_source return type( "StubServer", (), @@ -4030,12 +4545,29 @@ def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None: class StubParser: def parse_args(self) -> argparse.Namespace: - return argparse.Namespace(command="mcp", mcp_command="serve", profile="workspace-core") + return argparse.Namespace( + command="mcp", + mcp_command="serve", + profile="workspace-core", + mode=None, + project_path="/repo", + repo_url=None, + repo_ref=None, + no_project_source=False, + ) monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) monkeypatch.setattr(cli, "Pyro", StubPyro) cli.main() - assert observed == {"profile": "workspace-core", "transport": "stdio"} + assert observed == { + "profile": "workspace-core", + "mode": None, + "project_path": "/repo", + "repo_url": None, + "repo_ref": None, + "no_project_source": False, + "transport": "stdio", + } def test_cli_demo_default_prints_json( @@ -4153,7 +4685,7 @@ def test_cli_workspace_exec_passes_secret_env( class StubPyro: def exec_workspace(self, workspace_id: str, **kwargs: Any) -> dict[str, Any]: assert workspace_id == "ws-123" - assert kwargs["command"] == "sh -lc 'test \"$API_TOKEN\" = \"expected\"'" + assert kwargs["command"] == 'sh -lc \'test "$API_TOKEN" = "expected"\'' assert kwargs["secret_env"] == {"API_TOKEN": "API_TOKEN", "TOKEN": "PIP_TOKEN"} return {"exit_code": 0, "stdout": "", "stderr": ""} diff --git a/tests/test_daily_loop.py b/tests/test_daily_loop.py new file mode 100644 index 0000000..b511158 --- /dev/null +++ b/tests/test_daily_loop.py @@ -0,0 +1,359 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from pyro_mcp.daily_loop import ( + DailyLoopManifest, + evaluate_daily_loop_status, + load_prepare_manifest, + prepare_manifest_path, + prepare_request_is_satisfied, +) +from pyro_mcp.runtime import RuntimeCapabilities +from pyro_mcp.vm_manager import VmManager + + +def test_prepare_daily_loop_executes_then_reuses( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "state", + cache_dir=tmp_path / "cache", + ) + monkeypatch.setattr(manager, "_backend_name", "firecracker") + monkeypatch.setattr( + manager, + "_runtime_capabilities", + RuntimeCapabilities( + supports_vm_boot=True, + supports_guest_exec=True, + supports_guest_network=True, + ), + ) + monkeypatch.setattr( + manager, + "inspect_environment", + lambda environment: {"installed": True}, + ) + monkeypatch.setattr( + manager._environment_store, + "ensure_installed", + lambda environment: object(), + ) + + observed: dict[str, object] = {} + + def fake_create_workspace(**kwargs: object) -> dict[str, object]: + observed["network_policy"] = kwargs["network_policy"] + return {"workspace_id": "ws-123"} + + def fake_exec_workspace( + workspace_id: str, + *, + command: str, + timeout_seconds: int = 30, + secret_env: dict[str, str] | None = None, + ) -> dict[str, object]: + observed["exec"] = { + "workspace_id": workspace_id, + "command": command, + "timeout_seconds": timeout_seconds, + "secret_env": secret_env, + } + return { + "workspace_id": workspace_id, + "stdout": "/workspace\n", + "stderr": "", + "exit_code": 0, + "duration_ms": 1, + "execution_mode": "guest_vsock", + } + + def fake_reset_workspace( + workspace_id: str, + *, + snapshot: str = "baseline", + ) -> dict[str, object]: + observed["reset"] = {"workspace_id": workspace_id, "snapshot": snapshot} + return {"workspace_id": workspace_id} + + def fake_delete_workspace( + workspace_id: str, + *, + reason: str = "explicit_delete", + ) -> dict[str, object]: + observed["delete"] = {"workspace_id": workspace_id, "reason": reason} + return {"workspace_id": workspace_id, "deleted": True} + + monkeypatch.setattr(manager, "create_workspace", fake_create_workspace) + monkeypatch.setattr(manager, "exec_workspace", fake_exec_workspace) + monkeypatch.setattr(manager, "reset_workspace", fake_reset_workspace) + monkeypatch.setattr(manager, "delete_workspace", fake_delete_workspace) + + first = manager.prepare_daily_loop("debian:12") + assert first["prepared"] is True + assert first["executed"] is True + assert first["reused"] is False + assert first["network_prepared"] is False + assert first["execution_mode"] == "guest_vsock" + assert observed["network_policy"] == "off" + assert observed["exec"] == { + "workspace_id": "ws-123", + "command": "pwd", + "timeout_seconds": 30, + "secret_env": None, + } + assert observed["reset"] == {"workspace_id": "ws-123", "snapshot": "baseline"} + assert observed["delete"] == {"workspace_id": "ws-123", "reason": "prepare_cleanup"} + + second = manager.prepare_daily_loop("debian:12") + assert second["prepared"] is True + assert second["executed"] is False + assert second["reused"] is True + assert second["reason"] == "reused existing warm manifest" + + +def test_prepare_daily_loop_force_and_network_upgrade_manifest( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "state", + cache_dir=tmp_path / "cache", + ) + monkeypatch.setattr(manager, "_backend_name", "firecracker") + monkeypatch.setattr( + manager, + "_runtime_capabilities", + RuntimeCapabilities( + supports_vm_boot=True, + supports_guest_exec=True, + supports_guest_network=True, + ), + ) + monkeypatch.setattr( + manager, + "inspect_environment", + lambda environment: {"installed": True}, + ) + monkeypatch.setattr( + manager._environment_store, + "ensure_installed", + lambda environment: object(), + ) + + observed_policies: list[str] = [] + + def fake_create_workspace(**kwargs: object) -> dict[str, object]: + observed_policies.append(str(kwargs["network_policy"])) + return {"workspace_id": "ws-1"} + + monkeypatch.setattr(manager, "create_workspace", fake_create_workspace) + monkeypatch.setattr( + manager, + "exec_workspace", + lambda workspace_id, **kwargs: { + "workspace_id": workspace_id, + "stdout": "/workspace\n", + "stderr": "", + "exit_code": 0, + "duration_ms": 1, + "execution_mode": "guest_vsock", + }, + ) + monkeypatch.setattr( + manager, + "reset_workspace", + lambda workspace_id, **kwargs: {"workspace_id": workspace_id}, + ) + monkeypatch.setattr( + manager, + "delete_workspace", + lambda workspace_id, **kwargs: {"workspace_id": workspace_id, "deleted": True}, + ) + + manager.prepare_daily_loop("debian:12") + payload = manager.prepare_daily_loop("debian:12", network=True, force=True) + assert payload["executed"] is True + assert payload["network_prepared"] is True + assert observed_policies == ["off", "egress"] + + manifest_path = prepare_manifest_path( + tmp_path / "cache", + platform="linux-x86_64", + environment="debian:12", + ) + manifest, manifest_error = load_prepare_manifest(manifest_path) + assert manifest_error is None + if manifest is None: + raise AssertionError("expected prepare manifest") + assert manifest.network_prepared is True + + +def test_prepare_daily_loop_requires_guest_capabilities(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "state", + cache_dir=tmp_path / "cache", + ) + with pytest.raises(RuntimeError, match="guest-backed runtime"): + manager.prepare_daily_loop("debian:12") + + +def test_load_prepare_manifest_reports_invalid_json(tmp_path: Path) -> None: + manifest_path = prepare_manifest_path( + tmp_path, + platform="linux-x86_64", + environment="debian:12", + ) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text("{broken", encoding="utf-8") + + manifest, error = load_prepare_manifest(manifest_path) + assert manifest is None + assert error is not None + + +def test_prepare_manifest_round_trip(tmp_path: Path) -> None: + manifest_path = prepare_manifest_path( + tmp_path, + platform="linux-x86_64", + environment="debian:12", + ) + manifest = DailyLoopManifest( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + prepared_at=123.0, + network_prepared=True, + last_prepare_duration_ms=456, + ) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text(json.dumps(manifest.to_payload()), encoding="utf-8") + + loaded, error = load_prepare_manifest(manifest_path) + assert error is None + assert loaded == manifest + + +def test_load_prepare_manifest_rejects_non_object(tmp_path: Path) -> None: + manifest_path = prepare_manifest_path( + tmp_path, + platform="linux-x86_64", + environment="debian:12", + ) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text('["not-an-object"]', encoding="utf-8") + + manifest, error = load_prepare_manifest(manifest_path) + assert manifest is None + assert error == "prepare manifest is not a JSON object" + + +def test_load_prepare_manifest_rejects_invalid_payload(tmp_path: Path) -> None: + manifest_path = prepare_manifest_path( + tmp_path, + platform="linux-x86_64", + environment="debian:12", + ) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text(json.dumps({"environment": "debian:12"}), encoding="utf-8") + + manifest, error = load_prepare_manifest(manifest_path) + assert manifest is None + assert error is not None + assert "prepare manifest is invalid" in error + + +def test_evaluate_daily_loop_status_edge_cases() -> None: + manifest = DailyLoopManifest( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + prepared_at=1.0, + network_prepared=False, + last_prepare_duration_ms=2, + ) + + assert evaluate_daily_loop_status( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + installed=True, + manifest=manifest, + manifest_error="broken manifest", + ) == ("stale", "broken manifest") + assert evaluate_daily_loop_status( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + installed=False, + manifest=manifest, + ) == ("stale", "environment install is missing") + assert evaluate_daily_loop_status( + environment="debian:12-build", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + installed=True, + manifest=manifest, + ) == ("stale", "prepare manifest environment does not match the selected environment") + assert evaluate_daily_loop_status( + environment="debian:12", + environment_version="2.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + installed=True, + manifest=manifest, + ) == ("stale", "environment version changed since the last prepare run") + assert evaluate_daily_loop_status( + environment="debian:12", + environment_version="1.0.0", + platform="linux-aarch64", + catalog_version="4.5.0", + bundle_version="bundle-1", + installed=True, + manifest=manifest, + ) == ("stale", "platform changed since the last prepare run") + assert evaluate_daily_loop_status( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-2", + installed=True, + manifest=manifest, + ) == ("stale", "runtime bundle version changed since the last prepare run") + + +def test_prepare_request_is_satisfied_network_gate() -> None: + manifest = DailyLoopManifest( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + prepared_at=1.0, + network_prepared=False, + last_prepare_duration_ms=2, + ) + + assert prepare_request_is_satisfied(None, require_network=False) is False + assert prepare_request_is_satisfied(manifest, require_network=True) is False + assert prepare_request_is_satisfied(manifest, require_network=False) is True diff --git a/tests/test_daily_loop_smoke.py b/tests/test_daily_loop_smoke.py new file mode 100644 index 0000000..2d75fbc --- /dev/null +++ b/tests/test_daily_loop_smoke.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import pyro_mcp.daily_loop_smoke as smoke_module + + +class _FakePyro: + def __init__(self) -> None: + self.workspace_id = "ws-1" + self.message = "broken\n" + self.deleted = False + + def create_workspace( + self, + *, + environment: str, + seed_path: Path, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> dict[str, object]: + assert environment == "debian:12" + assert seed_path.is_dir() + assert name == "daily-loop" + assert labels == {"suite": "daily-loop-smoke"} + return {"workspace_id": self.workspace_id} + + def exec_workspace(self, workspace_id: str, *, command: str) -> dict[str, object]: + assert workspace_id == self.workspace_id + if command != "sh check.sh": + raise AssertionError(f"unexpected command: {command}") + if self.message == "fixed\n": + return {"exit_code": 0, "stdout": "fixed\n"} + return {"exit_code": 1, "stderr": "expected fixed got broken\n"} + + def apply_workspace_patch(self, workspace_id: str, *, patch: str) -> dict[str, object]: + assert workspace_id == self.workspace_id + assert "+fixed" in patch + self.message = "fixed\n" + return {"changed": True} + + def export_workspace( + self, + workspace_id: str, + path: str, + *, + output_path: Path, + ) -> dict[str, object]: + assert workspace_id == self.workspace_id + assert path == "message.txt" + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(self.message, encoding="utf-8") + return {"artifact_type": "file"} + + def reset_workspace(self, workspace_id: str) -> dict[str, object]: + assert workspace_id == self.workspace_id + self.message = "broken\n" + return {"reset_count": 1} + + def read_workspace_file(self, workspace_id: str, path: str) -> dict[str, object]: + assert workspace_id == self.workspace_id + assert path == "message.txt" + return {"content": self.message} + + def delete_workspace(self, workspace_id: str) -> dict[str, object]: + assert workspace_id == self.workspace_id + self.deleted = True + return {"workspace_id": workspace_id, "deleted": True} + + +def test_run_prepare_parses_json(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace( + returncode=0, + stdout=json.dumps({"prepared": True}), + stderr="", + ), + ) + payload = smoke_module._run_prepare("debian:12") + assert payload == {"prepared": True} + + +def test_run_prepare_raises_on_failure(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace( + returncode=1, + stdout="", + stderr="prepare failed", + ), + ) + with pytest.raises(RuntimeError, match="prepare failed"): + smoke_module._run_prepare("debian:12") + + +def test_run_daily_loop_smoke_happy_path(monkeypatch: pytest.MonkeyPatch) -> None: + prepare_calls: list[str] = [] + fake_pyro = _FakePyro() + + def fake_run_prepare(environment: str) -> dict[str, object]: + prepare_calls.append(environment) + return {"prepared": True, "reused": len(prepare_calls) > 1} + + monkeypatch.setattr(smoke_module, "_run_prepare", fake_run_prepare) + monkeypatch.setattr(smoke_module, "Pyro", lambda: fake_pyro) + + smoke_module.run_daily_loop_smoke(environment="debian:12") + + assert prepare_calls == ["debian:12", "debian:12"] + assert fake_pyro.deleted is True + + +def test_main_runs_selected_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + observed: list[str] = [] + monkeypatch.setattr( + smoke_module, + "run_daily_loop_smoke", + lambda *, environment: observed.append(environment), + ) + monkeypatch.setattr( + smoke_module, + "build_arg_parser", + lambda: SimpleNamespace( + parse_args=lambda: SimpleNamespace(environment="debian:12-build") + ), + ) + smoke_module.main() + assert observed == ["debian:12-build"] diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 9629771..3c3196e 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -15,13 +15,18 @@ def test_doctor_main_prints_json( ) -> None: class StubParser: def parse_args(self) -> argparse.Namespace: - return argparse.Namespace(platform="linux-x86_64") + return argparse.Namespace(platform="linux-x86_64", environment="debian:12") monkeypatch.setattr(doctor_module, "_build_parser", lambda: StubParser()) monkeypatch.setattr( doctor_module, "doctor_report", - lambda platform: {"platform": platform, "runtime_ok": True, "issues": []}, + lambda *, platform, environment: { + "platform": platform, + "environment": environment, + "runtime_ok": True, + "issues": [], + }, ) doctor_module.main() output = json.loads(capsys.readouterr().out) @@ -32,3 +37,4 @@ def test_doctor_build_parser_defaults_platform() -> None: parser = doctor_module._build_parser() args = parser.parse_args([]) assert args.platform == DEFAULT_PLATFORM + assert args.environment == "debian:12" diff --git a/tests/test_host_helpers.py b/tests/test_host_helpers.py new file mode 100644 index 0000000..2255208 --- /dev/null +++ b/tests/test_host_helpers.py @@ -0,0 +1,501 @@ +from __future__ import annotations + +import json +import shutil +import sys +from pathlib import Path +from subprocess import CompletedProcess + +import pytest + +import pyro_mcp.host_helpers as host_helpers +from pyro_mcp.host_helpers import ( + DEFAULT_OPENCODE_CONFIG_PATH, + HostServerConfig, + _canonical_server_command, + _command_matches, + _repair_command, + connect_cli_host, + doctor_hosts, + print_or_write_opencode_config, + repair_host, +) + + +def _install_fake_mcp_cli(tmp_path: Path, name: str) -> tuple[Path, Path]: + bin_dir = tmp_path / "bin" + bin_dir.mkdir(parents=True) + state_path = tmp_path / f"{name}-state.json" + script_path = bin_dir / name + script_path.write_text( + "\n".join( + [ + f"#!{sys.executable}", + "import json", + "import shlex", + "import sys", + f"STATE_PATH = {str(state_path)!r}", + "try:", + " with open(STATE_PATH, 'r', encoding='utf-8') as handle:", + " state = json.load(handle)", + "except FileNotFoundError:", + " state = {}", + "args = sys.argv[1:]", + "if args[:2] == ['mcp', 'add']:", + " name = args[2]", + " marker = args.index('--')", + " state[name] = args[marker + 1:]", + " with open(STATE_PATH, 'w', encoding='utf-8') as handle:", + " json.dump(state, handle)", + " print(f'added {name}')", + " raise SystemExit(0)", + "if args[:2] == ['mcp', 'remove']:", + " name = args[2]", + " if name in state:", + " del state[name]", + " with open(STATE_PATH, 'w', encoding='utf-8') as handle:", + " json.dump(state, handle)", + " print(f'removed {name}')", + " raise SystemExit(0)", + " print('not found', file=sys.stderr)", + " raise SystemExit(1)", + "if args[:2] == ['mcp', 'get']:", + " name = args[2]", + " if name not in state:", + " print('not found', file=sys.stderr)", + " raise SystemExit(1)", + " print(f'{name}: {shlex.join(state[name])}')", + " raise SystemExit(0)", + "if args[:2] == ['mcp', 'list']:", + " for item in sorted(state):", + " print(item)", + " raise SystemExit(0)", + "print('unsupported', file=sys.stderr)", + "raise SystemExit(2)", + ] + ), + encoding="utf-8", + ) + script_path.chmod(0o755) + return bin_dir, state_path + + +def test_connect_cli_host_replaces_existing_entry( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + bin_dir, state_path = _install_fake_mcp_cli(tmp_path, "codex") + state_path.write_text(json.dumps({"pyro": ["old", "command"]}), encoding="utf-8") + monkeypatch.setenv("PATH", str(bin_dir)) + + payload = connect_cli_host("codex", config=HostServerConfig()) + + assert payload["host"] == "codex" + assert payload["server_command"] == ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"] + assert json.loads(state_path.read_text(encoding="utf-8")) == { + "pyro": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"] + } + + +def test_canonical_server_command_validates_and_renders_variants() -> None: + assert _canonical_server_command(HostServerConfig(installed_package=True)) == [ + "pyro", + "mcp", + "serve", + ] + assert _canonical_server_command( + HostServerConfig(profile="workspace-full", project_path="/repo") + ) == [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + "--profile", + "workspace-full", + "--project-path", + "/repo", + ] + assert _canonical_server_command( + HostServerConfig(repo_url="https://example.com/repo.git", repo_ref="main") + ) == [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + "--repo-url", + "https://example.com/repo.git", + "--repo-ref", + "main", + ] + assert _canonical_server_command(HostServerConfig(mode="repro-fix")) == [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + "--mode", + "repro-fix", + ] + assert _canonical_server_command(HostServerConfig(no_project_source=True)) == [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + "--no-project-source", + ] + + with pytest.raises(ValueError, match="mutually exclusive"): + _canonical_server_command( + HostServerConfig(project_path="/repo", repo_url="https://example.com/repo.git") + ) + with pytest.raises(ValueError, match="cannot be combined"): + _canonical_server_command(HostServerConfig(project_path="/repo", no_project_source=True)) + with pytest.raises(ValueError, match="requires --repo-url"): + _canonical_server_command(HostServerConfig(repo_ref="main")) + with pytest.raises(ValueError, match="mutually exclusive"): + _canonical_server_command( + HostServerConfig(profile="workspace-full", mode="repro-fix") + ) + + +def test_repair_command_and_command_matches_cover_edge_cases() -> None: + assert _repair_command("codex", HostServerConfig()) == "pyro host repair codex" + assert _repair_command("codex", HostServerConfig(project_path="/repo")) == ( + "pyro host repair codex --project-path /repo" + ) + assert _repair_command( + "opencode", + HostServerConfig(installed_package=True, profile="workspace-full", repo_url="file:///repo"), + config_path=Path("/tmp/opencode.json"), + ) == ( + "pyro host repair opencode --installed-package --profile workspace-full " + "--repo-url file:///repo --config-path /tmp/opencode.json" + ) + assert _repair_command("codex", HostServerConfig(no_project_source=True)) == ( + "pyro host repair codex --no-project-source" + ) + assert _repair_command("codex", HostServerConfig(mode="inspect")) == ( + "pyro host repair codex --mode inspect" + ) + assert _command_matches( + "pyro: uvx --from pyro-mcp pyro mcp serve", + ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"], + ) + assert _command_matches( + '"uvx --from pyro-mcp pyro mcp serve', + ['"uvx', "--from", "pyro-mcp", "pyro", "mcp", "serve"], + ) + assert not _command_matches("pyro: uvx --from pyro-mcp pyro mcp serve --profile vm-run", [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + ]) + + +def test_connect_cli_host_reports_missing_cli_and_add_failure( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + with pytest.raises(ValueError, match="unsupported CLI host"): + connect_cli_host("unsupported", config=HostServerConfig()) + + monkeypatch.setenv("PATH", "") + with pytest.raises(RuntimeError, match="codex CLI is not installed"): + connect_cli_host("codex", config=HostServerConfig()) + + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + script_path = bin_dir / "codex" + script_path.write_text( + "\n".join( + [ + f"#!{sys.executable}", + "import sys", + "raise SystemExit(1 if sys.argv[1:3] == ['mcp', 'add'] else 0)", + ] + ), + encoding="utf-8", + ) + script_path.chmod(0o755) + monkeypatch.setenv("PATH", str(bin_dir)) + + with pytest.raises(RuntimeError, match="codex mcp add failed"): + connect_cli_host("codex", config=HostServerConfig()) + + +def test_doctor_hosts_reports_ok_and_drifted( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + codex_bin, codex_state = _install_fake_mcp_cli(tmp_path / "codex", "codex") + claude_bin, claude_state = _install_fake_mcp_cli(tmp_path / "claude", "claude") + combined_path = str(codex_bin) + ":" + str(claude_bin) + monkeypatch.setenv("PATH", combined_path) + + codex_state.write_text( + json.dumps({"pyro": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]}), + encoding="utf-8", + ) + claude_state.write_text( + json.dumps( + { + "pyro": [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + "--profile", + "workspace-full", + ] + } + ), + encoding="utf-8", + ) + opencode_config = tmp_path / "opencode.json" + opencode_config.write_text( + json.dumps( + { + "mcp": { + "pyro": { + "type": "local", + "enabled": True, + "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"], + } + } + } + ), + encoding="utf-8", + ) + + entries = doctor_hosts(config=HostServerConfig(), config_path=opencode_config) + by_host = {entry.host: entry for entry in entries} + + assert by_host["codex"].status == "ok" + assert by_host["codex"].configured is True + assert by_host["claude-code"].status == "drifted" + assert by_host["claude-code"].configured is True + assert by_host["opencode"].status == "ok" + assert by_host["opencode"].configured is True + + +def test_doctor_hosts_reports_missing_and_drifted_opencode_shapes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("PATH", "") + config_path = tmp_path / "opencode.json" + + config_path.write_text("[]", encoding="utf-8") + entries = doctor_hosts(config=HostServerConfig(), config_path=config_path) + by_host = {entry.host: entry for entry in entries} + assert by_host["opencode"].status == "unavailable" + assert "JSON object" in by_host["opencode"].details + + config_path.write_text(json.dumps({"mcp": {}}), encoding="utf-8") + entries = doctor_hosts(config=HostServerConfig(), config_path=config_path) + by_host = {entry.host: entry for entry in entries} + assert by_host["opencode"].status == "unavailable" + assert "missing mcp.pyro" in by_host["opencode"].details + + config_path.write_text( + json.dumps({"mcp": {"pyro": {"type": "local", "enabled": False, "command": ["wrong"]}}}), + encoding="utf-8", + ) + entries = doctor_hosts(config=HostServerConfig(), config_path=config_path) + by_host = {entry.host: entry for entry in entries} + assert by_host["opencode"].status == "drifted" + assert by_host["opencode"].configured is True + + +def test_doctor_hosts_reports_invalid_json_for_installed_opencode( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + config_path = tmp_path / "opencode.json" + config_path.write_text("{invalid", encoding="utf-8") + monkeypatch.setattr( + shutil, + "which", + lambda name: "/usr/bin/opencode" if name == "opencode" else None, + ) + + entries = doctor_hosts(config=HostServerConfig(), config_path=config_path) + by_host = {entry.host: entry for entry in entries} + + assert by_host["opencode"].status == "drifted" + assert "invalid JSON" in by_host["opencode"].details + + +def test_repair_opencode_preserves_unrelated_keys( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("PATH", "") + config_path = tmp_path / "opencode.json" + config_path.write_text( + json.dumps({"theme": "light", "mcp": {"other": {"type": "local"}}}), + encoding="utf-8", + ) + + payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path) + + assert payload["config_path"] == str(config_path.resolve()) + repaired = json.loads(config_path.read_text(encoding="utf-8")) + assert repaired["theme"] == "light" + assert repaired["mcp"]["other"] == {"type": "local"} + assert repaired["mcp"]["pyro"] == { + "type": "local", + "enabled": True, + "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"], + } + + +def test_repair_opencode_backs_up_non_object_json( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("PATH", "") + config_path = tmp_path / "opencode.json" + config_path.write_text("[]", encoding="utf-8") + + payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path) + + backup_path = Path(str(payload["backup_path"])) + assert backup_path.exists() + assert backup_path.read_text(encoding="utf-8") == "[]" + + +def test_repair_opencode_backs_up_invalid_json( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("PATH", "") + config_path = tmp_path / "opencode.json" + config_path.write_text("{invalid", encoding="utf-8") + + payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path) + + backup_path = Path(str(payload["backup_path"])) + assert backup_path.exists() + assert backup_path.read_text(encoding="utf-8") == "{invalid" + repaired = json.loads(config_path.read_text(encoding="utf-8")) + assert repaired["mcp"]["pyro"]["command"] == [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + ] + + +def test_print_or_write_opencode_config_writes_json(tmp_path: Path) -> None: + output_path = tmp_path / "opencode.json" + payload = print_or_write_opencode_config( + config=HostServerConfig(project_path="/repo"), + output_path=output_path, + ) + + assert payload["output_path"] == str(output_path) + rendered = json.loads(output_path.read_text(encoding="utf-8")) + assert rendered == { + "mcp": { + "pyro": { + "type": "local", + "enabled": True, + "command": [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + "--project-path", + "/repo", + ], + } + } + } + + +def test_print_or_write_opencode_config_returns_rendered_text() -> None: + payload = print_or_write_opencode_config(config=HostServerConfig(profile="vm-run")) + + assert payload["host"] == "opencode" + assert payload["server_command"] == [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + "--profile", + "vm-run", + ] + rendered = str(payload["rendered_config"]) + assert '"type": "local"' in rendered + assert '"command": [' in rendered + + +def test_doctor_reports_opencode_missing_when_config_absent( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("PATH", "") + + entries = doctor_hosts( + config=HostServerConfig(), + config_path=tmp_path / "missing-opencode.json", + ) + by_host = {entry.host: entry for entry in entries} + + assert by_host["opencode"].status == "unavailable" + assert str(DEFAULT_OPENCODE_CONFIG_PATH) not in by_host["opencode"].details + + +def test_repair_host_delegates_non_opencode_and_doctor_handles_list_only_configured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + host_helpers, + "connect_cli_host", + lambda host, *, config: {"host": host, "profile": config.profile}, + ) + assert repair_host("codex", config=HostServerConfig(profile="vm-run")) == { + "host": "codex", + "profile": "vm-run", + } + + commands: list[list[str]] = [] + + def _fake_run_command(command: list[str]) -> CompletedProcess[str]: + commands.append(command) + if command[:3] == ["codex", "mcp", "get"]: + return CompletedProcess(command, 1, "", "not found") + if command[:3] == ["codex", "mcp", "list"]: + return CompletedProcess(command, 0, "pyro\n", "") + raise AssertionError(command) + + monkeypatch.setattr( + shutil, + "which", + lambda name: "/usr/bin/codex" if name == "codex" else None, + ) + monkeypatch.setattr(host_helpers, "_run_command", _fake_run_command) + + entry = host_helpers._doctor_cli_host("codex", config=HostServerConfig()) + assert entry.status == "drifted" + assert entry.configured is True + assert commands == [["codex", "mcp", "get", "pyro"], ["codex", "mcp", "list"]] diff --git a/tests/test_project_startup.py b/tests/test_project_startup.py new file mode 100644 index 0000000..fd8a6b1 --- /dev/null +++ b/tests/test_project_startup.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +import pyro_mcp.project_startup as project_startup +from pyro_mcp.project_startup import ( + ProjectStartupSource, + describe_project_startup_source, + materialize_project_startup_source, + resolve_project_startup_source, +) + + +def _git(repo: Path, *args: str) -> str: + result = subprocess.run( # noqa: S603 + ["git", "-c", "commit.gpgsign=false", *args], + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def _make_repo(root: Path, *, filename: str = "note.txt", content: str = "hello\n") -> Path: + root.mkdir() + _git(root, "init") + _git(root, "config", "user.name", "Pyro Tests") + _git(root, "config", "user.email", "pyro-tests@example.com") + (root / filename).write_text(content, encoding="utf-8") + _git(root, "add", filename) + _git(root, "commit", "-m", "init") + return root + + +def test_resolve_project_startup_source_detects_nearest_git_root(tmp_path: Path) -> None: + repo = _make_repo(tmp_path / "repo") + nested = repo / "src" / "pkg" + nested.mkdir(parents=True) + + resolved = resolve_project_startup_source(cwd=nested) + + assert resolved == ProjectStartupSource( + kind="project_path", + origin_ref=str(repo.resolve()), + resolved_path=repo.resolve(), + ) + + +def test_resolve_project_startup_source_project_path_prefers_git_root(tmp_path: Path) -> None: + repo = _make_repo(tmp_path / "repo") + nested = repo / "nested" + nested.mkdir() + + resolved = resolve_project_startup_source(project_path=nested, cwd=tmp_path) + + assert resolved == ProjectStartupSource( + kind="project_path", + origin_ref=str(repo.resolve()), + resolved_path=repo.resolve(), + ) + + +def test_resolve_project_startup_source_validates_flag_combinations(tmp_path: Path) -> None: + repo = _make_repo(tmp_path / "repo") + + with pytest.raises(ValueError, match="mutually exclusive"): + resolve_project_startup_source(project_path=repo, repo_url="https://example.com/repo.git") + + with pytest.raises(ValueError, match="requires --repo-url"): + resolve_project_startup_source(repo_ref="main") + + with pytest.raises(ValueError, match="cannot be combined"): + resolve_project_startup_source(project_path=repo, no_project_source=True) + + +def test_resolve_project_startup_source_handles_explicit_none_and_empty_values( + tmp_path: Path, +) -> None: + repo = _make_repo(tmp_path / "repo") + outside = tmp_path / "outside" + outside.mkdir() + + assert resolve_project_startup_source(no_project_source=True, cwd=tmp_path) is None + assert resolve_project_startup_source(cwd=outside) is None + + with pytest.raises(ValueError, match="must not be empty"): + resolve_project_startup_source(repo_url=" ", cwd=repo) + + with pytest.raises(ValueError, match="must not be empty"): + resolve_project_startup_source(repo_url="https://example.com/repo.git", repo_ref=" ") + + +def test_resolve_project_startup_source_rejects_missing_or_non_directory_project_path( + tmp_path: Path, +) -> None: + missing = tmp_path / "missing" + file_path = tmp_path / "note.txt" + file_path.write_text("hello\n", encoding="utf-8") + + with pytest.raises(ValueError, match="does not exist"): + resolve_project_startup_source(project_path=missing, cwd=tmp_path) + + with pytest.raises(ValueError, match="must be a directory"): + resolve_project_startup_source(project_path=file_path, cwd=tmp_path) + + +def test_resolve_project_startup_source_keeps_plain_relative_directory_when_not_a_repo( + tmp_path: Path, +) -> None: + plain = tmp_path / "plain" + plain.mkdir() + + resolved = resolve_project_startup_source(project_path="plain", cwd=tmp_path) + + assert resolved == ProjectStartupSource( + kind="project_path", + origin_ref=str(plain.resolve()), + resolved_path=plain.resolve(), + ) + + +def test_materialize_project_startup_source_clones_local_repo_url_at_ref(tmp_path: Path) -> None: + repo = _make_repo(tmp_path / "repo", content="one\n") + first_commit = _git(repo, "rev-parse", "HEAD") + (repo / "note.txt").write_text("two\n", encoding="utf-8") + _git(repo, "add", "note.txt") + _git(repo, "commit", "-m", "update") + + source = ProjectStartupSource( + kind="repo_url", + origin_ref=str(repo.resolve()), + repo_ref=first_commit, + ) + + with materialize_project_startup_source(source) as clone_dir: + assert (clone_dir / "note.txt").read_text(encoding="utf-8") == "one\n" + + +def test_materialize_project_startup_source_validates_project_source_and_clone_failures( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + with pytest.raises(RuntimeError, match="missing a resolved path"): + with materialize_project_startup_source( + ProjectStartupSource(kind="project_path", origin_ref="/repo", resolved_path=None) + ): + pass + + source = ProjectStartupSource(kind="repo_url", origin_ref="https://example.com/repo.git") + + def _clone_failure( + command: list[str], + *, + cwd: Path | None = None, + ) -> subprocess.CompletedProcess[str]: + del cwd + return subprocess.CompletedProcess(command, 1, "", "clone failed") + + monkeypatch.setattr("pyro_mcp.project_startup._run_git", _clone_failure) + with pytest.raises(RuntimeError, match="failed to clone repo_url"): + with materialize_project_startup_source(source): + pass + + +def test_materialize_project_startup_source_reports_checkout_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo = _make_repo(tmp_path / "repo", content="one\n") + source = ProjectStartupSource( + kind="repo_url", + origin_ref=str(repo.resolve()), + repo_ref="missing-ref", + ) + + original_run_git = project_startup._run_git + + def _checkout_failure( + command: list[str], + *, + cwd: Path | None = None, + ) -> subprocess.CompletedProcess[str]: + if command[:2] == ["git", "checkout"]: + return subprocess.CompletedProcess(command, 1, "", "checkout failed") + return original_run_git(command, cwd=cwd) + + monkeypatch.setattr("pyro_mcp.project_startup._run_git", _checkout_failure) + with pytest.raises(RuntimeError, match="failed to checkout repo_ref"): + with materialize_project_startup_source(source): + pass + + +def test_describe_project_startup_source_formats_project_and_repo_sources(tmp_path: Path) -> None: + repo = _make_repo(tmp_path / "repo") + + project_description = describe_project_startup_source( + ProjectStartupSource( + kind="project_path", + origin_ref=str(repo.resolve()), + resolved_path=repo.resolve(), + ) + ) + repo_description = describe_project_startup_source( + ProjectStartupSource( + kind="repo_url", + origin_ref="https://example.com/repo.git", + repo_ref="main", + ) + ) + + assert project_description == f"the current project at {repo.resolve()}" + assert repo_description == "the clean clone source https://example.com/repo.git at ref main" + + +def test_describe_project_startup_source_handles_none_and_repo_without_ref() -> None: + assert describe_project_startup_source(None) is None + assert ( + describe_project_startup_source( + ProjectStartupSource(kind="repo_url", origin_ref="https://example.com/repo.git") + ) + == "the clean clone source https://example.com/repo.git" + ) + + +def test_detect_git_root_returns_none_for_empty_stdout(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + project_startup, + "_run_git", + lambda command, *, cwd=None: subprocess.CompletedProcess(command, 0, "\n", ""), + ) + + assert project_startup._detect_git_root(Path.cwd()) is None diff --git a/tests/test_public_contract.py b/tests/test_public_contract.py index 5033d4a..2f5385a 100644 --- a/tests/test_public_contract.py +++ b/tests/test_public_contract.py @@ -15,9 +15,16 @@ from pyro_mcp.cli import _build_parser from pyro_mcp.contract import ( PUBLIC_CLI_COMMANDS, PUBLIC_CLI_DEMO_SUBCOMMANDS, + PUBLIC_CLI_DOCTOR_FLAGS, PUBLIC_CLI_ENV_SUBCOMMANDS, + PUBLIC_CLI_HOST_CONNECT_FLAGS, + PUBLIC_CLI_HOST_DOCTOR_FLAGS, + PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS, + PUBLIC_CLI_HOST_REPAIR_FLAGS, + PUBLIC_CLI_HOST_SUBCOMMANDS, PUBLIC_CLI_MCP_SERVE_FLAGS, PUBLIC_CLI_MCP_SUBCOMMANDS, + PUBLIC_CLI_PREPARE_FLAGS, PUBLIC_CLI_RUN_FLAGS, PUBLIC_CLI_WORKSPACE_CREATE_FLAGS, PUBLIC_CLI_WORKSPACE_DIFF_FLAGS, @@ -53,10 +60,16 @@ from pyro_mcp.contract import ( PUBLIC_CLI_WORKSPACE_START_FLAGS, PUBLIC_CLI_WORKSPACE_STOP_FLAGS, PUBLIC_CLI_WORKSPACE_SUBCOMMANDS, + PUBLIC_CLI_WORKSPACE_SUMMARY_FLAGS, PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS, PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS, PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS, + PUBLIC_MCP_COLD_START_MODE_TOOLS, + PUBLIC_MCP_INSPECT_MODE_TOOLS, + PUBLIC_MCP_MODES, PUBLIC_MCP_PROFILES, + PUBLIC_MCP_REPRO_FIX_MODE_TOOLS, + PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS, PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS, PUBLIC_SDK_METHODS, ) @@ -102,6 +115,35 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None: env_help_text = _subparser_choice(parser, "env").format_help() for subcommand_name in PUBLIC_CLI_ENV_SUBCOMMANDS: assert subcommand_name in env_help_text + prepare_help_text = _subparser_choice(parser, "prepare").format_help() + for flag in PUBLIC_CLI_PREPARE_FLAGS: + assert flag in prepare_help_text + host_help_text = _subparser_choice(parser, "host").format_help() + for subcommand_name in PUBLIC_CLI_HOST_SUBCOMMANDS: + assert subcommand_name in host_help_text + host_connect_help_text = _subparser_choice( + _subparser_choice(parser, "host"), "connect" + ).format_help() + for flag in PUBLIC_CLI_HOST_CONNECT_FLAGS: + assert flag in host_connect_help_text + host_doctor_help_text = _subparser_choice( + _subparser_choice(parser, "host"), "doctor" + ).format_help() + for flag in PUBLIC_CLI_HOST_DOCTOR_FLAGS: + assert flag in host_doctor_help_text + host_print_config_help_text = _subparser_choice( + _subparser_choice(parser, "host"), "print-config" + ).format_help() + for flag in PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS: + assert flag in host_print_config_help_text + host_repair_help_text = _subparser_choice( + _subparser_choice(parser, "host"), "repair" + ).format_help() + for flag in PUBLIC_CLI_HOST_REPAIR_FLAGS: + assert flag in host_repair_help_text + doctor_help_text = _subparser_choice(parser, "doctor").format_help() + for flag in PUBLIC_CLI_DOCTOR_FLAGS: + assert flag in doctor_help_text mcp_help_text = _subparser_choice(parser, "mcp").format_help() for subcommand_name in PUBLIC_CLI_MCP_SUBCOMMANDS: assert subcommand_name in mcp_help_text @@ -110,6 +152,8 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None: assert flag in mcp_serve_help_text for profile_name in PUBLIC_MCP_PROFILES: assert profile_name in mcp_serve_help_text + for mode_name in PUBLIC_MCP_MODES: + assert mode_name in mcp_serve_help_text workspace_help_text = _subparser_choice(parser, "workspace").format_help() for subcommand_name in PUBLIC_CLI_WORKSPACE_SUBCOMMANDS: @@ -244,6 +288,11 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None: ).format_help() for flag in PUBLIC_CLI_WORKSPACE_STOP_FLAGS: assert flag in workspace_stop_help_text + workspace_summary_help_text = _subparser_choice( + _subparser_choice(parser, "workspace"), "summary" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_SUMMARY_FLAGS: + assert flag in workspace_summary_help_text workspace_shell_help_text = _subparser_choice( _subparser_choice(parser, "workspace"), "shell", @@ -338,6 +387,14 @@ def test_public_mcp_tools_match_contract(tmp_path: Path) -> None: assert tool_names == tuple(sorted(PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS)) +def test_public_mcp_modes_are_declared_and_non_empty() -> None: + assert PUBLIC_MCP_MODES == ("repro-fix", "inspect", "cold-start", "review-eval") + assert PUBLIC_MCP_REPRO_FIX_MODE_TOOLS + assert PUBLIC_MCP_INSPECT_MODE_TOOLS + assert PUBLIC_MCP_COLD_START_MODE_TOOLS + assert PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS + + def test_pyproject_exposes_single_public_cli_script() -> None: pyproject = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) scripts = pyproject["project"]["scripts"] diff --git a/tests/test_runtime.py b/tests/test_runtime.py index a2b9004..60ebc4b 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -6,7 +6,32 @@ from pathlib import Path import pytest +from pyro_mcp.daily_loop import DailyLoopManifest, prepare_manifest_path, write_prepare_manifest from pyro_mcp.runtime import doctor_report, resolve_runtime_paths, runtime_capabilities +from pyro_mcp.vm_environments import EnvironmentStore, get_environment + + +def _materialize_installed_environment( + environment_store: EnvironmentStore, + *, + name: str, +) -> None: + spec = get_environment(name, runtime_paths=environment_store._runtime_paths) + install_dir = environment_store._install_dir(spec) + install_dir.mkdir(parents=True, exist_ok=True) + (install_dir / "vmlinux").write_text("kernel\n", encoding="utf-8") + (install_dir / "rootfs.ext4").write_text("rootfs\n", encoding="utf-8") + (install_dir / "environment.json").write_text( + json.dumps( + { + "name": spec.name, + "version": spec.version, + "source": "test-cache", + "source_digest": spec.source_digest, + } + ), + encoding="utf-8", + ) def test_resolve_runtime_paths_default_bundle() -> None: @@ -109,6 +134,7 @@ def test_doctor_report_has_runtime_fields() -> None: assert "runtime_ok" in report assert "kvm" in report assert "networking" in report + assert "daily_loop" in report if report["runtime_ok"]: runtime = report.get("runtime") assert isinstance(runtime, dict) @@ -122,6 +148,61 @@ def test_doctor_report_has_runtime_fields() -> None: assert "tun_available" in networking +def test_doctor_report_daily_loop_statuses( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("PYRO_ENVIRONMENT_CACHE_DIR", str(tmp_path)) + cold_report = doctor_report(environment="debian:12") + cold_daily_loop = cold_report["daily_loop"] + assert cold_daily_loop["status"] == "cold" + assert cold_daily_loop["installed"] is False + + paths = resolve_runtime_paths() + environment_store = EnvironmentStore(runtime_paths=paths, cache_dir=tmp_path) + _materialize_installed_environment(environment_store, name="debian:12") + + installed_report = doctor_report(environment="debian:12") + installed_daily_loop = installed_report["daily_loop"] + assert installed_daily_loop["status"] == "cold" + assert installed_daily_loop["installed"] is True + + manifest_path = prepare_manifest_path( + tmp_path, + platform="linux-x86_64", + environment="debian:12", + ) + write_prepare_manifest( + manifest_path, + DailyLoopManifest( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version=environment_store.catalog_version, + bundle_version=( + None + if paths.manifest.get("bundle_version") is None + else str(paths.manifest.get("bundle_version")) + ), + prepared_at=123.0, + network_prepared=True, + last_prepare_duration_ms=456, + ), + ) + warm_report = doctor_report(environment="debian:12") + warm_daily_loop = warm_report["daily_loop"] + assert warm_daily_loop["status"] == "warm" + assert warm_daily_loop["network_prepared"] is True + + stale_manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + stale_manifest["catalog_version"] = "0.0.0" + manifest_path.write_text(json.dumps(stale_manifest), encoding="utf-8") + stale_report = doctor_report(environment="debian:12") + stale_daily_loop = stale_report["daily_loop"] + assert stale_daily_loop["status"] == "stale" + assert "catalog version changed" in str(stale_daily_loop["reason"]) + + def test_runtime_capabilities_reports_real_bundle_flags() -> None: paths = resolve_runtime_paths() capabilities = runtime_capabilities(paths) diff --git a/tests/test_server.py b/tests/test_server.py index 1481149..1ad39b9 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import subprocess from pathlib import Path from typing import Any, cast @@ -8,6 +9,8 @@ import pytest import pyro_mcp.server as server_module from pyro_mcp.contract import ( + PUBLIC_MCP_COLD_START_MODE_TOOLS, + PUBLIC_MCP_REPRO_FIX_MODE_TOOLS, PUBLIC_MCP_VM_RUN_PROFILE_TOOLS, PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS, ) @@ -16,6 +19,28 @@ from pyro_mcp.vm_manager import VmManager from pyro_mcp.vm_network import TapNetworkManager +def _git(repo: Path, *args: str) -> str: + result = subprocess.run( # noqa: S603 + ["git", "-c", "commit.gpgsign=false", *args], + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def _make_repo(root: Path, *, content: str = "hello\n") -> Path: + root.mkdir() + _git(root, "init") + _git(root, "config", "user.name", "Pyro Tests") + _git(root, "config", "user.email", "pyro-tests@example.com") + (root / "note.txt").write_text(content, encoding="utf-8") + _git(root, "add", "note.txt") + _git(root, "commit", "-m", "init") + return root + + def test_create_server_registers_vm_tools(tmp_path: Path) -> None: manager = VmManager( backend_name="mock", @@ -62,6 +87,151 @@ def test_create_server_workspace_core_profile_registers_expected_tools(tmp_path: assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS)) +def test_create_server_repro_fix_mode_registers_expected_tools(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + async def _run() -> list[str]: + server = create_server(manager=manager, mode="repro-fix") + tools = await server.list_tools() + return sorted(tool.name for tool in tools) + + assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_REPRO_FIX_MODE_TOOLS)) + + +def test_create_server_cold_start_mode_registers_expected_tools(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + async def _run() -> list[str]: + server = create_server(manager=manager, mode="cold-start") + tools = await server.list_tools() + return sorted(tool.name for tool in tools) + + assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_COLD_START_MODE_TOOLS)) + + +def test_create_server_workspace_create_description_mentions_project_source(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + repo = _make_repo(tmp_path / "repo") + + async def _run() -> dict[str, Any]: + server = create_server(manager=manager, project_path=repo) + tools = await server.list_tools() + tool_map = {tool.name: tool.model_dump() for tool in tools} + return tool_map["workspace_create"] + + workspace_create = asyncio.run(_run()) + description = cast(str, workspace_create["description"]) + assert "If `seed_path` is omitted" in description + assert str(repo.resolve()) in description + + +def test_create_server_project_path_seeds_workspace_when_seed_path_is_omitted( + tmp_path: Path, +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + repo = _make_repo(tmp_path / "repo", content="project-aware\n") + + def _extract_structured(raw_result: object) -> dict[str, Any]: + if not isinstance(raw_result, tuple) or len(raw_result) != 2: + raise TypeError("unexpected call_tool result shape") + _, structured = raw_result + if not isinstance(structured, dict): + raise TypeError("expected structured dictionary result") + return cast(dict[str, Any], structured) + + async def _run() -> tuple[dict[str, Any], dict[str, Any]]: + server = create_server(manager=manager, project_path=repo) + created = _extract_structured( + await server.call_tool( + "workspace_create", + { + "environment": "debian:12-base", + "allow_host_compat": True, + }, + ) + ) + executed = _extract_structured( + await server.call_tool( + "workspace_exec", + { + "workspace_id": created["workspace_id"], + "command": "cat note.txt", + }, + ) + ) + return created, executed + + created, executed = asyncio.run(_run()) + assert created["workspace_seed"]["mode"] == "directory" + assert created["workspace_seed"]["seed_path"] == str(repo.resolve()) + assert created["workspace_seed"]["origin_kind"] == "project_path" + assert created["workspace_seed"]["origin_ref"] == str(repo.resolve()) + assert executed["stdout"] == "project-aware\n" + + +def test_create_server_repo_url_seeds_workspace_when_seed_path_is_omitted(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + repo = _make_repo(tmp_path / "repo", content="committed\n") + (repo / "note.txt").write_text("dirty\n", encoding="utf-8") + + def _extract_structured(raw_result: object) -> dict[str, Any]: + if not isinstance(raw_result, tuple) or len(raw_result) != 2: + raise TypeError("unexpected call_tool result shape") + _, structured = raw_result + if not isinstance(structured, dict): + raise TypeError("expected structured dictionary result") + return cast(dict[str, Any], structured) + + async def _run() -> tuple[dict[str, Any], dict[str, Any]]: + server = create_server(manager=manager, repo_url=str(repo.resolve())) + created = _extract_structured( + await server.call_tool( + "workspace_create", + { + "environment": "debian:12-base", + "allow_host_compat": True, + }, + ) + ) + executed = _extract_structured( + await server.call_tool( + "workspace_exec", + { + "workspace_id": created["workspace_id"], + "command": "cat note.txt", + }, + ) + ) + return created, executed + + created, executed = asyncio.run(_run()) + assert created["workspace_seed"]["mode"] == "directory" + assert created["workspace_seed"]["seed_path"] is None + assert created["workspace_seed"]["origin_kind"] == "repo_url" + assert created["workspace_seed"]["origin_ref"] == str(repo.resolve()) + assert executed["stdout"] == "committed\n" + + def test_vm_run_round_trip(tmp_path: Path) -> None: manager = VmManager( backend_name="mock", @@ -464,6 +634,9 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None: }, ) ) + summary = _extract_structured( + await server.call_tool("workspace_summary", {"workspace_id": workspace_id}) + ) reset = _extract_structured( await server.call_tool( "workspace_reset", @@ -501,6 +674,7 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None: service_status, service_logs, service_stopped, + summary, reset, deleted_snapshot, logs, @@ -526,6 +700,7 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None: service_status, service_logs, service_stopped, + summary, reset, deleted_snapshot, logs, @@ -562,6 +737,10 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None: assert service_logs["stderr"].count("[REDACTED]") >= 1 assert service_logs["tail_lines"] is None assert service_stopped["state"] == "stopped" + assert summary["workspace_id"] == created["workspace_id"] + assert summary["commands"]["total"] >= 1 + assert summary["changes"]["available"] is True + assert summary["artifacts"]["exports"][0]["workspace_path"] == "/workspace/subdir/more.txt" assert reset["workspace_reset"]["snapshot_name"] == "checkpoint" assert reset["secrets"] == created["secrets"] assert reset["command_count"] == 0 diff --git a/tests/test_vm_manager.py b/tests/test_vm_manager.py index 22e953c..73fc74f 100644 --- a/tests/test_vm_manager.py +++ b/tests/test_vm_manager.py @@ -699,6 +699,124 @@ def test_workspace_diff_and_export_round_trip(tmp_path: Path) -> None: assert logs["count"] == 0 +def test_workspace_summary_synthesizes_current_session(tmp_path: Path) -> None: + seed_dir = tmp_path / "seed" + seed_dir.mkdir() + (seed_dir / "note.txt").write_text("hello\n", encoding="utf-8") + update_dir = tmp_path / "update" + update_dir.mkdir() + (update_dir / "more.txt").write_text("more\n", encoding="utf-8") + + 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, + seed_path=seed_dir, + name="review-eval", + labels={"suite": "smoke"}, + )["workspace_id"] + ) + manager.push_workspace_sync(workspace_id, source_path=update_dir) + manager.write_workspace_file(workspace_id, "src/app.py", text="print('hello')\n") + manager.apply_workspace_patch( + workspace_id, + patch=( + "--- a/note.txt\n" + "+++ b/note.txt\n" + "@@ -1 +1 @@\n" + "-hello\n" + "+patched\n" + ), + ) + manager.exec_workspace(workspace_id, command="cat note.txt", timeout_seconds=30) + manager.create_snapshot(workspace_id, "checkpoint") + export_path = tmp_path / "exported-note.txt" + manager.export_workspace(workspace_id, "note.txt", output_path=export_path) + manager.start_service( + workspace_id, + "app", + command='sh -lc \'trap "exit 0" TERM; touch .ready; while true; do sleep 60; done\'', + readiness={"type": "file", "path": ".ready"}, + ) + manager.stop_service(workspace_id, "app") + + summary = manager.summarize_workspace(workspace_id) + + assert summary["workspace_id"] == workspace_id + assert summary["name"] == "review-eval" + assert summary["labels"] == {"suite": "smoke"} + assert summary["outcome"]["command_count"] == 1 + assert summary["outcome"]["export_count"] == 1 + assert summary["outcome"]["snapshot_count"] == 1 + assert summary["commands"]["total"] == 1 + assert summary["commands"]["recent"][0]["command"] == "cat note.txt" + assert [event["event_kind"] for event in summary["edits"]["recent"]] == [ + "patch_apply", + "file_write", + "sync_push", + ] + assert summary["changes"]["available"] is True + assert summary["changes"]["changed"] is True + assert summary["changes"]["summary"]["total"] == 4 + assert summary["services"]["current"][0]["service_name"] == "app" + assert [event["event_kind"] for event in summary["services"]["recent"]] == [ + "service_stop", + "service_start", + ] + assert summary["artifacts"]["exports"][0]["workspace_path"] == "/workspace/note.txt" + assert summary["snapshots"]["named_count"] == 1 + assert summary["snapshots"]["recent"][0]["snapshot_name"] == "checkpoint" + + +def test_workspace_summary_degrades_gracefully_for_stopped_and_legacy_workspaces( + tmp_path: Path, +) -> None: + seed_dir = tmp_path / "seed" + seed_dir.mkdir() + (seed_dir / "note.txt").write_text("hello\n", encoding="utf-8") + + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + stopped_workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + seed_path=seed_dir, + )["workspace_id"] + ) + manager.exec_workspace(stopped_workspace_id, command="cat note.txt", timeout_seconds=30) + manager.stop_workspace(stopped_workspace_id) + stopped_summary = manager.summarize_workspace(stopped_workspace_id) + assert stopped_summary["commands"]["total"] == 1 + assert stopped_summary["changes"]["available"] is False + assert "must be in 'started' state" in str(stopped_summary["changes"]["reason"]) + + legacy_workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + seed_path=seed_dir, + )["workspace_id"] + ) + baseline_path = ( + tmp_path / "vms" / "workspaces" / legacy_workspace_id / "baseline" / "workspace.tar" + ) + baseline_path.unlink() + legacy_summary = manager.summarize_workspace(legacy_workspace_id) + assert legacy_summary["changes"]["available"] is False + assert "baseline snapshot" in str(legacy_summary["changes"]["reason"]) + + def test_workspace_file_ops_and_patch_round_trip(tmp_path: Path) -> None: seed_dir = tmp_path / "seed" seed_dir.mkdir() diff --git a/tests/test_workspace_ports.py b/tests/test_workspace_ports.py index d63cfae..54a3fbe 100644 --- a/tests/test_workspace_ports.py +++ b/tests/test_workspace_ports.py @@ -15,6 +15,13 @@ import pytest from pyro_mcp import workspace_ports +def _socketpair_or_skip() -> tuple[socket.socket, socket.socket]: + try: + return socket.socketpair() + except PermissionError as exc: + pytest.skip(f"socketpair unavailable in this environment: {exc}") + + class _EchoHandler(socketserver.BaseRequestHandler): def handle(self) -> None: data = self.request.recv(65536) @@ -50,18 +57,26 @@ def test_workspace_port_proxy_handler_ignores_upstream_connect_failure( def test_workspace_port_proxy_forwards_tcp_traffic() -> None: - upstream = socketserver.ThreadingTCPServer( - (workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0), - _EchoHandler, - ) + try: + upstream = socketserver.ThreadingTCPServer( + (workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0), + _EchoHandler, + ) + except PermissionError as exc: + pytest.skip(f"TCP bind unavailable in this environment: {exc}") upstream_thread = threading.Thread(target=upstream.serve_forever, daemon=True) upstream_thread.start() upstream_host = str(upstream.server_address[0]) upstream_port = int(upstream.server_address[1]) - proxy = workspace_ports._ProxyServer( # noqa: SLF001 - (workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0), - (upstream_host, upstream_port), - ) + try: + proxy = workspace_ports._ProxyServer( # noqa: SLF001 + (workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0), + (upstream_host, upstream_port), + ) + except PermissionError as exc: + upstream.shutdown() + upstream.server_close() + pytest.skip(f"proxy TCP bind unavailable in this environment: {exc}") proxy_thread = threading.Thread(target=proxy.serve_forever, daemon=True) proxy_thread.start() try: @@ -202,8 +217,8 @@ def test_workspace_ports_main_shutdown_handler_stops_server( def test_workspace_port_proxy_handler_handles_empty_and_invalid_selector_events( monkeypatch: Any, ) -> None: - source, source_peer = socket.socketpair() - upstream, upstream_peer = socket.socketpair() + source, source_peer = _socketpair_or_skip() + upstream, upstream_peer = _socketpair_or_skip() source_peer.close() class FakeSelector: @@ -246,10 +261,17 @@ def test_workspace_port_proxy_handler_handles_recv_and_send_errors( monkeypatch: Any, ) -> None: def _run_once(*, close_source: bool) -> None: - source, source_peer = socket.socketpair() - upstream, upstream_peer = socket.socketpair() + source, source_peer = _socketpair_or_skip() + upstream, upstream_peer = _socketpair_or_skip() if not close_source: - source_peer.sendall(b"hello") + try: + source_peer.sendall(b"hello") + except PermissionError as exc: + source.close() + source_peer.close() + upstream.close() + upstream_peer.close() + pytest.skip(f"socket send unavailable in this environment: {exc}") class FakeSelector: def register(self, *_args: Any, **_kwargs: Any) -> None: diff --git a/tests/test_workspace_use_case_smokes.py b/tests/test_workspace_use_case_smokes.py index 1a5a836..f369587 100644 --- a/tests/test_workspace_use_case_smokes.py +++ b/tests/test_workspace_use_case_smokes.py @@ -391,6 +391,69 @@ class _FakePyro: "workspace_reset": {"snapshot_name": snapshot}, } + def summarize_workspace(self, workspace_id: str) -> dict[str, Any]: + workspace = self._resolve_workspace(workspace_id) + changed = self._diff_changed(workspace) + return { + "workspace_id": workspace_id, + "name": workspace.name, + "labels": dict(workspace.labels), + "environment": workspace.environment, + "state": "started", + "last_activity_at": workspace.last_activity_at, + "session_started_at": workspace.created_at, + "outcome": { + "command_count": 0, + "last_command": None, + "service_count": len(workspace.services), + "running_service_count": sum( + 1 + for service in workspace.services.values() + if service["state"] == "running" + ), + "export_count": 1, + "snapshot_count": max(len(workspace.snapshots) - 1, 0), + "reset_count": workspace.reset_count, + }, + "commands": {"total": 0, "recent": []}, + "edits": {"recent": []}, + "changes": { + "available": True, + "reason": None, + "changed": changed, + "summary": {"total": 1 if changed else 0}, + "entries": ( + [ + { + "path": "/workspace/artifact.txt", + "status": "modified", + "artifact_type": "file", + } + ] + if changed + else [] + ), + }, + "services": { + "current": [ + {"service_name": name, "state": service["state"]} + for name, service in sorted(workspace.services.items()) + ], + "recent": [], + }, + "artifacts": { + "exports": [ + { + "workspace_path": "review-report.txt", + "output_path": str( + self._workspace_dir(workspace_id) / "exported-review.txt" + ), + } + ] + }, + "snapshots": {"named_count": max(len(workspace.snapshots) - 1, 0), "recent": []}, + } + def open_shell(self, workspace_id: str, **_: Any) -> dict[str, Any]: workspace = self._resolve_workspace(workspace_id) self._shell_counter += 1 @@ -436,6 +499,43 @@ class _FakePyro: workspace.shells.pop(shell_id, None) return {"workspace_id": workspace_id, "shell_id": shell_id, "closed": True} + def create_server( + self, + *, + profile: str = "workspace-core", + mode: str | None = None, + project_path: Path, + ) -> Any: + assert profile == "workspace-core" + assert mode in {"repro-fix", "cold-start"} + seed_path = Path(project_path) + outer = self + + class _FakeServer: + async def call_tool( + self, + tool_name: str, + arguments: dict[str, Any], + ) -> tuple[None, dict[str, Any]]: + if tool_name != "workspace_create": + raise AssertionError(f"unexpected tool call: {tool_name}") + result = outer.create_workspace( + environment=cast(str, arguments["environment"]), + seed_path=seed_path, + name=cast(str | None, arguments.get("name")), + labels=cast(dict[str, str] | None, arguments.get("labels")), + ) + created = outer.status_workspace(cast(str, result["workspace_id"])) + created["workspace_seed"] = { + "mode": "directory", + "seed_path": str(seed_path.resolve()), + "origin_kind": "project_path", + "origin_ref": str(seed_path.resolve()), + } + return None, created + + return _FakeServer() + def test_use_case_registry_has_expected_scenarios() -> None: expected = ( @@ -461,7 +561,7 @@ def test_use_case_docs_and_targets_stay_aligned() -> None: recipe_text = (repo_root / recipe.doc_path).read_text(encoding="utf-8") assert recipe.smoke_target in index_text assert recipe.doc_path.rsplit("/", 1)[-1] in index_text - assert recipe.profile in recipe_text + assert recipe.mode in recipe_text assert recipe.smoke_target in recipe_text assert f"{recipe.smoke_target}:" in makefile_text diff --git a/uv.lock b/uv.lock index 95bd68b..14f3147 100644 --- a/uv.lock +++ b/uv.lock @@ -715,7 +715,7 @@ crypto = [ [[package]] name = "pyro-mcp" -version = "4.0.0" +version = "4.5.0" source = { editable = "." } dependencies = [ { name = "mcp" },