Compare commits
No commits in common. "aeed5e19431c7b523ae0f99bc4eae5d9b00eda42" and "643384718567780f70abb1963597fab6599c9886" have entirely different histories.
aeed5e1943
...
6433847185
58 changed files with 1940 additions and 7150 deletions
57
CHANGELOG.md
57
CHANGELOG.md
|
|
@ -2,63 +2,6 @@
|
||||||
|
|
||||||
All notable user-visible changes to `pyro-mcp` are documented here.
|
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
|
## 4.0.0
|
||||||
|
|
||||||
- Flipped the default MCP/server profile from `workspace-full` to
|
- Flipped the default MCP/server profile from `workspace-full` to
|
||||||
|
|
|
||||||
27
Makefile
27
Makefile
|
|
@ -1,7 +1,6 @@
|
||||||
PYTHON ?= uv run python
|
PYTHON ?= uv run python
|
||||||
UV_CACHE_DIR ?= .uv-cache
|
UV_CACHE_DIR ?= .uv-cache
|
||||||
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 auto
|
||||||
PYTEST_FLAGS ?= -n $(PYTEST_WORKERS)
|
|
||||||
OLLAMA_BASE_URL ?= http://localhost:11434/v1
|
OLLAMA_BASE_URL ?= http://localhost:11434/v1
|
||||||
OLLAMA_MODEL ?= llama3.2:3b
|
OLLAMA_MODEL ?= llama3.2:3b
|
||||||
OLLAMA_DEMO_FLAGS ?=
|
OLLAMA_DEMO_FLAGS ?=
|
||||||
|
|
@ -18,9 +17,8 @@ TWINE_USERNAME ?= __token__
|
||||||
PYPI_REPOSITORY_URL ?=
|
PYPI_REPOSITORY_URL ?=
|
||||||
USE_CASE_ENVIRONMENT ?= debian:12
|
USE_CASE_ENVIRONMENT ?= debian:12
|
||||||
USE_CASE_SMOKE_FLAGS ?=
|
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-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
|
.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
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@printf '%s\n' \
|
@printf '%s\n' \
|
||||||
|
|
@ -37,7 +35,6 @@ help:
|
||||||
' demo Run the deterministic VM demo' \
|
' demo Run the deterministic VM demo' \
|
||||||
' network-demo Run the deterministic VM demo with guest networking enabled' \
|
' network-demo Run the deterministic VM demo with guest networking enabled' \
|
||||||
' doctor Show runtime and host diagnostics' \
|
' 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-use-cases Run all real guest-backed workspace use-case smokes' \
|
||||||
' smoke-cold-start-validation Run the cold-start repo validation smoke' \
|
' smoke-cold-start-validation Run the cold-start repo validation smoke' \
|
||||||
' smoke-repro-fix-loop Run the repro-plus-fix loop smoke' \
|
' smoke-repro-fix-loop Run the repro-plus-fix loop smoke' \
|
||||||
|
|
@ -85,16 +82,13 @@ test:
|
||||||
check: lint typecheck test
|
check: lint typecheck test
|
||||||
|
|
||||||
dist-check:
|
dist-check:
|
||||||
uv run python -m pyro_mcp.cli --version
|
.venv/bin/pyro --version
|
||||||
uv run python -m pyro_mcp.cli --help >/dev/null
|
.venv/bin/pyro --help >/dev/null
|
||||||
uv run python -m pyro_mcp.cli prepare --help >/dev/null
|
.venv/bin/pyro mcp --help >/dev/null
|
||||||
uv run python -m pyro_mcp.cli host --help >/dev/null
|
.venv/bin/pyro run --help >/dev/null
|
||||||
uv run python -m pyro_mcp.cli host doctor >/dev/null
|
.venv/bin/pyro env list >/dev/null
|
||||||
uv run python -m pyro_mcp.cli mcp --help >/dev/null
|
.venv/bin/pyro env inspect debian:12 >/dev/null
|
||||||
uv run python -m pyro_mcp.cli run --help >/dev/null
|
.venv/bin/pyro doctor >/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:
|
pypi-publish:
|
||||||
@if [ -z "$$TWINE_PASSWORD" ]; then \
|
@if [ -z "$$TWINE_PASSWORD" ]; then \
|
||||||
|
|
@ -119,9 +113,6 @@ network-demo:
|
||||||
doctor:
|
doctor:
|
||||||
uv run pyro doctor
|
uv run pyro doctor
|
||||||
|
|
||||||
smoke-daily-loop:
|
|
||||||
uv run python scripts/daily_loop_smoke.py --environment "$(DAILY_LOOP_ENVIRONMENT)"
|
|
||||||
|
|
||||||
smoke-use-cases:
|
smoke-use-cases:
|
||||||
uv run python scripts/workspace_use_case_smoke.py --scenario all --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS)
|
uv run python scripts/workspace_use_case_smoke.py --scenario all --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS)
|
||||||
|
|
||||||
|
|
|
||||||
698
README.md
698
README.md
|
|
@ -1,50 +1,34 @@
|
||||||
# pyro-mcp
|
# pyro-mcp
|
||||||
|
|
||||||
`pyro-mcp` is a disposable MCP workspace for chat-based coding agents such as
|
`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`.
|
||||||
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.
|
|
||||||
|
|
||||||
[](https://pypi.org/project/pyro-mcp/)
|
[](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
|
## Start Here
|
||||||
|
|
||||||
- Install and zero-to-hero path: [docs/install.md](docs/install.md)
|
- Install: [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)
|
- Vision: [docs/vision.md](docs/vision.md)
|
||||||
- Public contract: [docs/public-contract.md](docs/public-contract.md)
|
- Workspace GA roadmap: [docs/roadmap/task-workspace-ga.md](docs/roadmap/task-workspace-ga.md)
|
||||||
- Host requirements: [docs/host-requirements.md](docs/host-requirements.md)
|
- LLM chat roadmap: [docs/roadmap/llm-chat-ergonomics.md](docs/roadmap/llm-chat-ergonomics.md)
|
||||||
- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md)
|
- Use-case recipes: [docs/use-cases/README.md](docs/use-cases/README.md)
|
||||||
|
- First run transcript: [docs/first-run.md](docs/first-run.md)
|
||||||
- Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif)
|
- 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)
|
- 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/)
|
- 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)
|
||||||
## Who It's For
|
- Host requirements: [docs/host-requirements.md](docs/host-requirements.md)
|
||||||
|
- Integration targets: [docs/integrations.md](docs/integrations.md)
|
||||||
- Claude Code users who want disposable workspaces instead of running directly
|
- Public contract: [docs/public-contract.md](docs/public-contract.md)
|
||||||
on the host
|
- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md)
|
||||||
- Codex users who want an MCP-backed sandbox for repo setup, bug fixing, and
|
- Changelog: [CHANGELOG.md](CHANGELOG.md)
|
||||||
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
|
## Quickstart
|
||||||
|
|
||||||
|
|
@ -54,7 +38,8 @@ Use either of these equivalent quickstart paths:
|
||||||
# Package without install
|
# Package without install
|
||||||
python -m pip install uv
|
python -m pip install uv
|
||||||
uvx --from pyro-mcp pyro doctor
|
uvx --from pyro-mcp pyro doctor
|
||||||
uvx --from pyro-mcp pyro prepare debian:12
|
uvx --from pyro-mcp pyro env list
|
||||||
|
uvx --from pyro-mcp pyro env pull debian:12
|
||||||
uvx --from pyro-mcp pyro run debian:12 -- git --version
|
uvx --from pyro-mcp pyro run debian:12 -- git --version
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -63,7 +48,8 @@ uvx --from pyro-mcp pyro run debian:12 -- git --version
|
||||||
```bash
|
```bash
|
||||||
# Already installed
|
# Already installed
|
||||||
pyro doctor
|
pyro doctor
|
||||||
pyro prepare debian:12
|
pyro env list
|
||||||
|
pyro env pull debian:12
|
||||||
pyro run debian:12 -- git --version
|
pyro run debian:12 -- git --version
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -74,7 +60,7 @@ What success looks like:
|
||||||
```bash
|
```bash
|
||||||
Platform: linux-x86_64
|
Platform: linux-x86_64
|
||||||
Runtime: PASS
|
Runtime: PASS
|
||||||
Catalog version: 4.4.0
|
Catalog version: 4.0.0
|
||||||
...
|
...
|
||||||
[pull] phase=install environment=debian:12
|
[pull] phase=install environment=debian:12
|
||||||
[pull] phase=ready environment=debian:12
|
[pull] phase=ready environment=debian:12
|
||||||
|
|
@ -87,76 +73,89 @@ Pulled: debian:12
|
||||||
git version ...
|
git version ...
|
||||||
```
|
```
|
||||||
|
|
||||||
The first pull downloads an OCI environment from public Docker Hub, requires
|
The first pull downloads an OCI environment from public Docker Hub, requires outbound HTTPS
|
||||||
outbound HTTPS access to `registry-1.docker.io`, and needs local cache space
|
access to `registry-1.docker.io`, and needs local cache space for the guest image.
|
||||||
for the guest image. `pyro prepare debian:12` performs that install step
|
|
||||||
automatically, then proves create, exec, reset, and delete on one throwaway
|
## Stable Workspace Path
|
||||||
workspace so the daily loop is warm before the chat host connects.
|
|
||||||
|
`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"
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
## Chat Host Quickstart
|
## Chat Host Quickstart
|
||||||
|
|
||||||
After the quickstart works, make the daily loop explicit before you connect the
|
For most MCP chat hosts, bare `pyro mcp serve` now starts `workspace-core`. It exposes the practical
|
||||||
chat host:
|
persistent editing loop without shells, services, snapshots, secrets, network
|
||||||
|
policy, or disk tools.
|
||||||
```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
|
```bash
|
||||||
uvx --from pyro-mcp pyro mcp serve
|
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:
|
Copy-paste host-specific starts:
|
||||||
|
|
||||||
- Claude Code: [examples/claude_code_mcp.md](examples/claude_code_mcp.md)
|
- Claude Code: [examples/claude_code_mcp.md](examples/claude_code_mcp.md)
|
||||||
|
|
@ -164,16 +163,16 @@ Copy-paste host-specific starts:
|
||||||
- OpenCode: [examples/opencode_mcp_config.json](examples/opencode_mcp_config.json)
|
- OpenCode: [examples/opencode_mcp_config.json](examples/opencode_mcp_config.json)
|
||||||
- Generic MCP config: [examples/mcp_client_config.md](examples/mcp_client_config.md)
|
- Generic MCP config: [examples/mcp_client_config.md](examples/mcp_client_config.md)
|
||||||
|
|
||||||
Claude Code cold-start or review-eval:
|
Claude Code:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||||
```
|
```
|
||||||
|
|
||||||
Codex repro-fix or inspect:
|
Codex:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||||
```
|
```
|
||||||
|
|
||||||
OpenCode `opencode.json` snippet:
|
OpenCode `opencode.json` snippet:
|
||||||
|
|
@ -184,72 +183,229 @@ OpenCode `opencode.json` snippet:
|
||||||
"pyro": {
|
"pyro": {
|
||||||
"type": "local",
|
"type": "local",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
|
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
If OpenCode launches the server from an unexpected cwd, use
|
If `pyro-mcp` is already installed, replace the `uvx --from pyro-mcp pyro`
|
||||||
`pyro host print-config opencode --project-path /abs/path/to/repo` or add
|
command with `pyro` in the same host-specific command or config shape. Use
|
||||||
`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same command
|
`--profile workspace-full` only when the host truly needs the full advanced
|
||||||
array.
|
workspace surface.
|
||||||
|
|
||||||
If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with
|
Profile progression:
|
||||||
`pyro` in the same command or config shape.
|
|
||||||
|
|
||||||
Use the generic no-mode path when the named mode feels too narrow. Move to
|
- `workspace-core`: default and recommended first profile for normal persistent chat editing
|
||||||
`--profile workspace-full` only when the chat truly needs shells, services,
|
- `vm-run`: smallest one-shot-only surface
|
||||||
snapshots, secrets, network policy, or disk tools.
|
- `workspace-full`: explicit advanced opt-in when the chat truly needs shells, services, snapshots, secrets, network policy, or disk tools
|
||||||
|
|
||||||
## Zero To Hero
|
## Supported Hosts
|
||||||
|
|
||||||
1. Validate the host with `pyro doctor`.
|
Supported today:
|
||||||
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.
|
|
||||||
|
|
||||||
That is the intended user journey. The terminal commands exist to validate and
|
- Linux x86_64
|
||||||
debug that chat-host path, not to replace it as the main product story.
|
- Python 3.12+
|
||||||
|
- `uv`
|
||||||
|
- `/dev/kvm`
|
||||||
|
|
||||||
## Manual Terminal Workspace Flow
|
Optional for outbound guest networking:
|
||||||
|
|
||||||
If you want to understand what the agent gets inside the sandbox, or debug a
|
- `ip`
|
||||||
recipe outside the chat host, use the terminal companion flow below:
|
- `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
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv tool install pyro-mcp
|
uvx --from pyro-mcp pyro doctor
|
||||||
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"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Add `workspace-full` only when the chat or your manual debugging loop really
|
Expected success signals:
|
||||||
needs:
|
|
||||||
|
|
||||||
- persistent PTY shells
|
```bash
|
||||||
- long-running services and readiness probes
|
Platform: linux-x86_64
|
||||||
- guest networking and published ports
|
Runtime: PASS
|
||||||
- secrets
|
KVM: exists=yes readable=yes writable=yes
|
||||||
- stopped-workspace disk inspection
|
Environment cache: /home/you/.cache/pyro-mcp/environments
|
||||||
|
Capabilities: vm_boot=yes guest_exec=yes guest_network=yes
|
||||||
|
Networking: tun=yes ip_forward=yes
|
||||||
|
```
|
||||||
|
|
||||||
The five recipe docs show when those capabilities are justified:
|
### 2. Inspect the catalog
|
||||||
[docs/use-cases/README.md](docs/use-cases/README.md)
|
|
||||||
|
```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/<name>`, 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.
|
||||||
|
|
||||||
## Official Environments
|
## Official Environments
|
||||||
|
|
||||||
|
|
@ -259,10 +415,216 @@ Current official environments in the shipped catalog:
|
||||||
- `debian:12-base`
|
- `debian:12-base`
|
||||||
- `debian:12-build`
|
- `debian:12-build`
|
||||||
|
|
||||||
The embedded Firecracker runtime ships with the package. Official environments
|
The package ships the embedded Firecracker runtime and a package-controlled environment catalog.
|
||||||
are pulled as OCI artifacts from public Docker Hub into a local cache on first
|
Official environments are pulled as OCI artifacts from public Docker Hub repositories into a local
|
||||||
use or through `pyro env pull`. End users do not need registry credentials to
|
cache on first use or through `pyro env pull`.
|
||||||
pull or run the official environments.
|
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.
|
||||||
|
|
||||||
## Contributor Workflow
|
## Contributor Workflow
|
||||||
|
|
||||||
|
|
@ -275,12 +637,11 @@ make check
|
||||||
make dist-check
|
make dist-check
|
||||||
```
|
```
|
||||||
|
|
||||||
Contributor runtime sources live under `runtime_sources/`. The packaged runtime
|
Contributor runtime sources live under `runtime_sources/`. The packaged runtime bundle under
|
||||||
bundle under `src/pyro_mcp/runtime_bundle/` contains the embedded boot/runtime
|
`src/pyro_mcp/runtime_bundle/` contains the embedded boot/runtime assets plus manifest metadata;
|
||||||
assets plus manifest metadata. End-user environment installs pull
|
end-user environment installs pull OCI-published environments by default. Use
|
||||||
OCI-published environments by default. Use
|
`PYRO_RUNTIME_BUNDLE_DIR=build/runtime_bundle` only when you are explicitly validating a locally
|
||||||
`PYRO_RUNTIME_BUNDLE_DIR=build/runtime_bundle` only when you are explicitly
|
built contributor runtime bundle.
|
||||||
validating a locally built contributor runtime bundle.
|
|
||||||
|
|
||||||
Official environment publication is performed locally against Docker Hub:
|
Official environment publication is performed locally against Docker Hub:
|
||||||
|
|
||||||
|
|
@ -291,9 +652,20 @@ make runtime-materialize
|
||||||
make runtime-publish-official-environments-oci
|
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:
|
For a local PyPI publish:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export TWINE_PASSWORD='pypi-...'
|
export TWINE_PASSWORD='pypi-...'
|
||||||
make pypi-publish
|
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.
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,28 @@
|
||||||
# First Run Transcript
|
# First Run Transcript
|
||||||
|
|
||||||
This is the intended evaluator-to-chat-host path for a first successful run on
|
This is the intended evaluator path for a first successful run on a supported host.
|
||||||
a supported host.
|
|
||||||
|
|
||||||
Copy the commands as-is. Paths and timing values will differ on your machine.
|
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
|
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
|
`uvx --from pyro-mcp` prefix. If you are running from a source checkout instead
|
||||||
instead of the published package, replace `pyro` with `uv run pyro`.
|
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
|
## 1. Verify the host
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ uvx --from pyro-mcp pyro doctor --environment debian:12
|
$ uvx --from pyro-mcp pyro doctor
|
||||||
Platform: linux-x86_64
|
Platform: linux-x86_64
|
||||||
Runtime: PASS
|
Runtime: PASS
|
||||||
KVM: exists=yes readable=yes writable=yes
|
KVM: exists=yes readable=yes writable=yes
|
||||||
Environment cache: /home/you/.cache/pyro-mcp/environments
|
Environment cache: /home/you/.cache/pyro-mcp/environments
|
||||||
Catalog version: 4.5.0
|
|
||||||
Capabilities: vm_boot=yes guest_exec=yes guest_network=yes
|
Capabilities: vm_boot=yes guest_exec=yes guest_network=yes
|
||||||
Networking: tun=yes ip_forward=yes
|
Networking: tun=yes ip_forward=yes
|
||||||
Daily loop: COLD (debian:12)
|
|
||||||
Run: pyro prepare debian:12
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2. Inspect the catalog
|
## 2. Inspect the catalog
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ uvx --from pyro-mcp pyro env list
|
$ uvx --from pyro-mcp pyro env list
|
||||||
Catalog version: 4.4.0
|
Catalog version: 4.0.0
|
||||||
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
|
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-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.
|
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
|
||||||
|
|
@ -38,10 +30,9 @@ debian:12-build [installed|not installed] Debian 12 environment with Git and com
|
||||||
|
|
||||||
## 3. Pull the default environment
|
## 3. Pull the default environment
|
||||||
|
|
||||||
The first pull downloads an OCI environment from public Docker Hub, requires
|
The first pull downloads an OCI environment from public Docker Hub, requires outbound HTTPS
|
||||||
outbound HTTPS access to `registry-1.docker.io`, and needs local cache space
|
access to `registry-1.docker.io`, and needs local cache space for the guest image. See
|
||||||
for the guest image. See [host-requirements.md](host-requirements.md) for the
|
[host-requirements.md](host-requirements.md) for the full host requirements.
|
||||||
full host requirements.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ uvx --from pyro-mcp pyro env pull debian:12
|
$ uvx --from pyro-mcp pyro env pull debian:12
|
||||||
|
|
@ -54,6 +45,9 @@ Installed: yes
|
||||||
Cache dir: /home/you/.cache/pyro-mcp/environments
|
Cache dir: /home/you/.cache/pyro-mcp/environments
|
||||||
Default packages: bash, coreutils, git
|
Default packages: bash, coreutils, git
|
||||||
Install dir: /home/you/.cache/pyro-mcp/environments/linux-x86_64/debian_12-1.0.0
|
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
|
OCI source: registry-1.docker.io/thalesmaciel/pyro-environment-debian-12:1.0.0
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -68,152 +62,239 @@ $ uvx --from pyro-mcp pyro run debian:12 -- git --version
|
||||||
git version ...
|
git version ...
|
||||||
```
|
```
|
||||||
|
|
||||||
The guest command output and the `[run] ...` summary are written to different
|
The guest command output and the `[run] ...` summary are written to different streams, so they
|
||||||
streams, so they may appear in either order in terminals or capture tools. Use
|
may appear in either order in terminals or capture tools. Use `--json` if you need a
|
||||||
`--json` if you need a deterministic structured result.
|
deterministic structured result.
|
||||||
|
|
||||||
## 5. Start the MCP server
|
## 5. Continue into the stable workspace path
|
||||||
|
|
||||||
Warm the daily loop first so the host is already ready for repeated create and
|
The commands below use the published-package form. The same stable workspace path works with an
|
||||||
reset cycles:
|
installed `pyro` binary by dropping the `uvx --from pyro-mcp` prefix, or with `uv run pyro` from
|
||||||
|
a source checkout.
|
||||||
```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
|
```bash
|
||||||
$ export WORKSPACE_ID="$(uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --id-only)"
|
$ 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 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 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 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 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 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 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 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 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"
|
$ uvx --from pyro-mcp pyro workspace delete "$WORKSPACE_ID"
|
||||||
```
|
```
|
||||||
|
|
||||||
Move to the generic no-mode path when the named mode is too narrow. Move to
|
## 6. Optional one-shot demo and expanded workspace flow
|
||||||
`--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
|
```bash
|
||||||
$ uvx --from pyro-mcp pyro demo
|
$ 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/<name>`, 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": {
|
"cleanup": {
|
||||||
"deleted": true,
|
"deleted": true,
|
||||||
|
|
@ -228,5 +309,7 @@ $ uvx --from pyro-mcp pyro demo
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`pyro demo` proves the one-shot create/start/exec/delete VM lifecycle works end
|
When you are done evaluating and want to remove stale cached environments, run `pyro env prune`.
|
||||||
to end.
|
|
||||||
|
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).
|
||||||
|
|
|
||||||
358
docs/install.md
358
docs/install.md
|
|
@ -1,17 +1,11 @@
|
||||||
# Install
|
# 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
|
## Support Matrix
|
||||||
|
|
||||||
Supported today:
|
Supported today:
|
||||||
|
|
||||||
- Linux `x86_64`
|
- Linux x86_64
|
||||||
- Python `3.12+`
|
- Python 3.12+
|
||||||
- `uv`
|
- `uv`
|
||||||
- `/dev/kvm`
|
- `/dev/kvm`
|
||||||
|
|
||||||
|
|
@ -46,27 +40,27 @@ Use either of these equivalent evaluator paths:
|
||||||
```bash
|
```bash
|
||||||
# Package without install
|
# Package without install
|
||||||
uvx --from pyro-mcp pyro doctor
|
uvx --from pyro-mcp pyro doctor
|
||||||
uvx --from pyro-mcp pyro prepare debian:12
|
uvx --from pyro-mcp pyro env list
|
||||||
|
uvx --from pyro-mcp pyro env pull debian:12
|
||||||
uvx --from pyro-mcp pyro run debian:12 -- git --version
|
uvx --from pyro-mcp pyro run debian:12 -- git --version
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Already installed
|
# Already installed
|
||||||
pyro doctor
|
pyro doctor
|
||||||
pyro prepare debian:12
|
pyro env list
|
||||||
|
pyro env pull debian:12
|
||||||
pyro run debian:12 -- git --version
|
pyro run debian:12 -- git --version
|
||||||
```
|
```
|
||||||
|
|
||||||
If you are running from a repo checkout instead, replace `pyro` with
|
If you are running from a repo checkout instead, replace `pyro` with `uv run pyro`.
|
||||||
`uv run pyro`.
|
|
||||||
|
|
||||||
After that one-shot proof works, the intended next step is a warmed daily loop
|
After that one-shot proof works, continue into the stable workspace path with `pyro workspace ...`.
|
||||||
plus a named chat mode through `pyro host connect` or `pyro host print-config`.
|
|
||||||
|
|
||||||
## 1. Check the host
|
### 1. Check the host first
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uvx --from pyro-mcp pyro doctor --environment debian:12
|
uvx --from pyro-mcp pyro doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected success signals:
|
Expected success signals:
|
||||||
|
|
@ -76,16 +70,13 @@ Platform: linux-x86_64
|
||||||
Runtime: PASS
|
Runtime: PASS
|
||||||
KVM: exists=yes readable=yes writable=yes
|
KVM: exists=yes readable=yes writable=yes
|
||||||
Environment cache: /home/you/.cache/pyro-mcp/environments
|
Environment cache: /home/you/.cache/pyro-mcp/environments
|
||||||
Catalog version: 4.5.0
|
|
||||||
Capabilities: vm_boot=yes guest_exec=yes guest_network=yes
|
Capabilities: vm_boot=yes guest_exec=yes guest_network=yes
|
||||||
Networking: tun=yes ip_forward=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).
|
If `Runtime: FAIL`, stop here and use [troubleshooting.md](troubleshooting.md).
|
||||||
|
|
||||||
## 2. Inspect the catalog
|
### 2. Inspect the catalog
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uvx --from pyro-mcp pyro env list
|
uvx --from pyro-mcp pyro env list
|
||||||
|
|
@ -94,22 +85,21 @@ uvx --from pyro-mcp pyro env list
|
||||||
Expected output:
|
Expected output:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
Catalog version: 4.4.0
|
Catalog version: 4.0.0
|
||||||
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
|
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-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.
|
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
|
```bash
|
||||||
uvx --from pyro-mcp pyro env pull debian:12
|
uvx --from pyro-mcp pyro env pull debian:12
|
||||||
```
|
```
|
||||||
|
|
||||||
The first pull downloads an OCI environment from public Docker Hub, requires
|
The first pull downloads an OCI environment from public Docker Hub, requires outbound HTTPS
|
||||||
outbound HTTPS access to `registry-1.docker.io`, and needs local cache space
|
access to `registry-1.docker.io`, and needs local cache space for the guest image. See
|
||||||
for the guest image. See [host-requirements.md](host-requirements.md) for the
|
[host-requirements.md](host-requirements.md) for the full host requirements.
|
||||||
full host requirements.
|
|
||||||
|
|
||||||
Expected success signals:
|
Expected success signals:
|
||||||
|
|
||||||
|
|
@ -120,7 +110,7 @@ Pulled: debian:12
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
## 4. Run one command in a guest
|
### 4. Run one command in a guest
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uvx --from pyro-mcp pyro run debian:12 -- git --version
|
uvx --from pyro-mcp pyro run debian:12 -- git --version
|
||||||
|
|
@ -136,124 +126,17 @@ Expected success signals:
|
||||||
git version ...
|
git version ...
|
||||||
```
|
```
|
||||||
|
|
||||||
The guest command output and the `[run] ...` summary are written to different
|
The guest command output and the `[run] ...` summary are written to different streams, so they
|
||||||
streams, so they may appear in either order. Use `--json` if you need a
|
may appear in either order in terminals or capture tools. Use `--json` if you need a
|
||||||
deterministic structured result.
|
deterministic structured result.
|
||||||
|
|
||||||
## 5. Warm the daily loop
|
If guest execution is unavailable, the command fails unless you explicitly pass
|
||||||
|
`--allow-host-compat`.
|
||||||
|
|
||||||
```bash
|
## 5. Continue into the stable workspace path
|
||||||
uvx --from pyro-mcp pyro prepare debian:12
|
|
||||||
```
|
|
||||||
|
|
||||||
That one command ensures the environment is installed, proves one guest-backed
|
The commands below use plain `pyro ...`. Run the same flow with `uvx --from pyro-mcp pyro ...`
|
||||||
create/exec/reset/delete loop, and records a warm manifest so the next
|
for the published package, or `uv run pyro ...` from a source checkout.
|
||||||
`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
|
```bash
|
||||||
uv tool install pyro-mcp
|
uv tool install pyro-mcp
|
||||||
|
|
@ -264,49 +147,202 @@ pyro workspace sync push "$WORKSPACE_ID" ./changes
|
||||||
pyro workspace file read "$WORKSPACE_ID" note.txt --content-only
|
pyro workspace file read "$WORKSPACE_ID" note.txt --content-only
|
||||||
pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch
|
pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch
|
||||||
pyro workspace exec "$WORKSPACE_ID" -- cat note.txt
|
pyro workspace exec "$WORKSPACE_ID" -- cat note.txt
|
||||||
pyro workspace summary "$WORKSPACE_ID"
|
|
||||||
pyro workspace snapshot create "$WORKSPACE_ID" checkpoint
|
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 reset "$WORKSPACE_ID" --snapshot checkpoint
|
||||||
pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt
|
pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt
|
||||||
pyro workspace delete "$WORKSPACE_ID"
|
pyro workspace delete "$WORKSPACE_ID"
|
||||||
```
|
```
|
||||||
|
|
||||||
When you need deeper debugging or richer recipes, add:
|
This is the stable persistent-workspace contract:
|
||||||
|
|
||||||
- `pyro workspace shell *` for interactive PTY state
|
- `workspace create` seeds `/workspace`
|
||||||
- `pyro workspace service *` for long-running processes and readiness probes
|
- `workspace create --name/--label`, `workspace list`, and `workspace update` make workspaces discoverable
|
||||||
- `pyro workspace create --network-policy egress+published-ports` plus
|
- `workspace sync push` imports later host-side changes
|
||||||
`workspace service start --publish` for host-probed services
|
- `workspace file *` and `workspace patch apply` cover model-native text inspection and edits
|
||||||
- `pyro workspace create --secret` and `--secret-file` when the sandbox needs
|
- `workspace exec` and `workspace shell *` keep work inside one sandbox
|
||||||
private tokens
|
- `workspace service *` manages long-running processes with typed readiness
|
||||||
- `pyro workspace stop` plus `workspace disk *` for offline inspection
|
- `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
|
||||||
|
|
||||||
## 9. Trustworthy verification path
|
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.
|
||||||
|
|
||||||
The five recipe docs in [use-cases/README.md](use-cases/README.md) are backed
|
## 6. Optional demo proof point
|
||||||
by a real Firecracker smoke pack:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make smoke-use-cases
|
uvx --from pyro-mcp pyro demo
|
||||||
```
|
```
|
||||||
|
|
||||||
Treat that smoke pack as the trustworthy guest-backed verification path for the
|
`pyro demo` proves the one-shot create/start/exec/delete VM lifecycle works end to end.
|
||||||
advertised chat-host workflows.
|
|
||||||
|
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`.
|
||||||
|
|
||||||
## Installed CLI
|
## Installed CLI
|
||||||
|
|
||||||
If you already installed the package, the same path works with plain `pyro ...`:
|
If you already installed the package, the same evaluator path works with plain `pyro ...`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv tool install pyro-mcp
|
uv tool install pyro-mcp
|
||||||
pyro --version
|
pyro --version
|
||||||
pyro doctor --environment debian:12
|
pyro doctor
|
||||||
pyro prepare debian:12
|
pyro env list
|
||||||
|
pyro env pull debian:12
|
||||||
pyro run debian:12 -- git --version
|
pyro run debian:12 -- git --version
|
||||||
pyro mcp serve
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Contributor clone
|
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/<name>`. 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
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git lfs install
|
git lfs install
|
||||||
|
|
|
||||||
|
|
@ -1,257 +1,164 @@
|
||||||
# Chat Host Integrations
|
# Integration Targets
|
||||||
|
|
||||||
This page documents the intended product path for `pyro-mcp`:
|
These are the main ways to integrate `pyro-mcp` into an LLM application.
|
||||||
|
|
||||||
- validate the host with the CLI
|
Use this page after you have already validated the host and guest execution through the
|
||||||
- warm the daily loop with `pyro prepare debian:12`
|
CLI path in [install.md](install.md) or [first-run.md](first-run.md).
|
||||||
- run `pyro mcp serve`
|
|
||||||
- connect a chat host
|
|
||||||
- let the agent work inside disposable workspaces
|
|
||||||
|
|
||||||
`pyro-mcp` currently has no users. Expect breaking changes while this chat-host
|
## Recommended Default
|
||||||
path is still being shaped.
|
|
||||||
|
|
||||||
Use this page after you have already validated the host and guest execution
|
Bare `pyro mcp serve` now starts `workspace-core`. Use `vm_run` only for one-shot
|
||||||
through [install.md](install.md) or [first-run.md](first-run.md).
|
integrations, and promote the chat surface to `workspace-full` only when it
|
||||||
|
truly needs shells, services, snapshots, secrets, network policy, or disk
|
||||||
|
tools.
|
||||||
|
|
||||||
Recommended first commands before connecting a host:
|
That keeps the model-facing contract small:
|
||||||
|
|
||||||
```bash
|
- one tool
|
||||||
pyro doctor --environment debian:12
|
- one command
|
||||||
pyro prepare debian:12
|
- one ephemeral VM
|
||||||
```
|
- automatic cleanup
|
||||||
|
|
||||||
## Recommended Modes
|
Profile progression:
|
||||||
|
|
||||||
Use a named mode when one workflow already matches the job:
|
- `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
|
||||||
|
|
||||||
```bash
|
## OpenAI Responses API
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
The mode-backed raw server forms are:
|
Best when:
|
||||||
|
|
||||||
```bash
|
- your agent already uses OpenAI models directly
|
||||||
pyro mcp serve --mode repro-fix
|
- you want a normal tool-calling loop instead of MCP transport
|
||||||
pyro mcp serve --mode inspect
|
- you want the smallest amount of integration code
|
||||||
pyro mcp serve --mode cold-start
|
|
||||||
pyro mcp serve --mode review-eval
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the generic no-mode path only when the named mode feels too narrow.
|
Recommended surface:
|
||||||
|
|
||||||
## Generic Default
|
- `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
|
||||||
|
|
||||||
Bare `pyro mcp serve` starts `workspace-core`. From a repo root, it also
|
Canonical example:
|
||||||
auto-detects the current Git checkout so the first `workspace_create` can omit
|
|
||||||
`seed_path`. That is the product path.
|
|
||||||
|
|
||||||
```bash
|
- [examples/openai_responses_vm_run.py](../examples/openai_responses_vm_run.py)
|
||||||
pyro mcp serve
|
- [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)
|
||||||
|
|
||||||
If the host does not preserve cwd, fall back to:
|
## MCP Clients
|
||||||
|
|
||||||
```bash
|
Best when:
|
||||||
pyro mcp serve --project-path /abs/path/to/repo
|
|
||||||
```
|
|
||||||
|
|
||||||
If you are outside a repo checkout entirely, start from a clean clone source:
|
- 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
|
||||||
|
|
||||||
```bash
|
Recommended entrypoint:
|
||||||
pyro mcp serve --repo-url https://github.com/example/project.git
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `--profile workspace-full` only when the chat truly needs shells, services,
|
- `pyro mcp serve`
|
||||||
snapshots, secrets, network policy, or disk tools.
|
|
||||||
|
|
||||||
## Helper First
|
Profile progression:
|
||||||
|
|
||||||
Use the helper flow before the raw host CLI commands:
|
- `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
|
||||||
|
|
||||||
```bash
|
Host-specific onramps:
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
These helpers wrap the same `pyro mcp serve` entrypoint, make named modes the
|
- Claude Code: [examples/claude_code_mcp.md](../examples/claude_code_mcp.md)
|
||||||
first user-facing story, and still leave the generic no-mode path available
|
- Codex: [examples/codex_mcp.md](../examples/codex_mcp.md)
|
||||||
when a mode is too narrow.
|
- 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)
|
||||||
|
|
||||||
## Claude Code
|
## Direct Python SDK
|
||||||
|
|
||||||
Preferred:
|
Best when:
|
||||||
|
|
||||||
```bash
|
- your application owns orchestration itself
|
||||||
pyro host connect claude-code --mode cold-start
|
- you do not need MCP transport
|
||||||
```
|
- you want direct access to `Pyro`
|
||||||
|
|
||||||
Repair:
|
Recommended default:
|
||||||
|
|
||||||
```bash
|
- `Pyro.run_in_vm(...)`
|
||||||
pyro host repair claude-code
|
- `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
|
||||||
|
|
||||||
Package without install:
|
Lifecycle note:
|
||||||
|
|
||||||
```bash
|
- `Pyro.exec_vm(...)` runs one command and auto-cleans the VM afterward
|
||||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
- use `create_vm(...)` + `start_vm(...)` only when you need pre-exec inspection or status before
|
||||||
claude mcp list
|
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
|
||||||
|
|
||||||
If Claude Code launches the server from an unexpected cwd, use:
|
Examples:
|
||||||
|
|
||||||
```bash
|
- [examples/python_run.py](../examples/python_run.py)
|
||||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start --project-path /abs/path/to/repo
|
- [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)
|
||||||
|
|
||||||
Already installed:
|
## Agent Framework Wrappers
|
||||||
|
|
||||||
```bash
|
Examples:
|
||||||
claude mcp add pyro -- pyro mcp serve
|
|
||||||
claude mcp list
|
|
||||||
```
|
|
||||||
|
|
||||||
Reference:
|
- LangChain tools
|
||||||
|
- PydanticAI tools
|
||||||
|
- custom in-house orchestration layers
|
||||||
|
|
||||||
- [claude_code_mcp.md](../examples/claude_code_mcp.md)
|
Best when:
|
||||||
|
|
||||||
## Codex
|
- you already have an application framework that expects a Python callable tool
|
||||||
|
- you want to wrap `vm_run` behind framework-specific abstractions
|
||||||
|
|
||||||
Preferred:
|
Recommended pattern:
|
||||||
|
|
||||||
```bash
|
- keep the framework wrapper thin
|
||||||
pyro host connect codex --mode repro-fix
|
- map one-shot framework tool input directly onto `vm_run`
|
||||||
```
|
- expose `workspace_*` only when the framework truly needs repeated commands in one workspace
|
||||||
|
|
||||||
|
Concrete example:
|
||||||
|
|
||||||
Repair:
|
- [examples/langchain_vm_run.py](../examples/langchain_vm_run.py)
|
||||||
|
|
||||||
```bash
|
## Selection Rule
|
||||||
pyro host repair codex
|
|
||||||
```
|
Choose the narrowest integration that matches the host environment:
|
||||||
|
|
||||||
Package without install:
|
1. OpenAI Responses API if you want a direct provider tool loop.
|
||||||
|
2. MCP if your host already speaks MCP.
|
||||||
```bash
|
3. Python SDK if you own orchestration and do not need transport.
|
||||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
4. Framework wrappers only as thin adapters over the same `vm_run` contract.
|
||||||
codex mcp list
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -1,192 +1,375 @@
|
||||||
# Public Contract
|
# Public Contract
|
||||||
|
|
||||||
This document describes the chat way to use `pyro-mcp` in `4.x`.
|
This document defines the stable public interface for `pyro-mcp` `3.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
|
## Package Identity
|
||||||
|
|
||||||
- distribution name: `pyro-mcp`
|
- Distribution name: `pyro-mcp`
|
||||||
- public executable: `pyro`
|
- Public executable: `pyro`
|
||||||
- primary product entrypoint: `pyro mcp serve`
|
- Public Python import: `from pyro_mcp import Pyro`
|
||||||
|
- Public package-level factory: `from pyro_mcp import create_server`
|
||||||
|
|
||||||
`pyro-mcp` is a disposable MCP workspace for chat-based coding agents on Linux
|
Stable product framing:
|
||||||
`x86_64` KVM hosts.
|
|
||||||
|
|
||||||
## Supported Product Path
|
- `pyro run` is the stable one-shot entrypoint.
|
||||||
|
- `pyro workspace ...` is the stable persistent workspace contract.
|
||||||
|
|
||||||
The intended user journey is:
|
## CLI Contract
|
||||||
|
|
||||||
1. `pyro doctor`
|
Top-level commands:
|
||||||
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 list`
|
||||||
- `pyro env pull`
|
- `pyro env pull`
|
||||||
|
- `pyro env inspect`
|
||||||
|
- `pyro env prune`
|
||||||
|
- `pyro mcp serve`
|
||||||
- `pyro run`
|
- `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`
|
||||||
|
- `pyro demo ollama`
|
||||||
|
|
||||||
What to expect from that path:
|
Stable `pyro run` interface:
|
||||||
|
|
||||||
- `pyro run <environment> -- <command>` defaults to `1 vCPU / 1024 MiB`
|
- positional environment name
|
||||||
- `pyro run` fails if guest boot or guest exec is unavailable unless
|
- `--vcpu-count`
|
||||||
`--allow-host-compat` is set
|
- `--mem-mib`
|
||||||
- `pyro run`, `pyro env list`, `pyro env pull`, `pyro env inspect`,
|
- `--timeout-seconds`
|
||||||
`pyro env prune`, `pyro doctor`, and `pyro prepare` are human-readable by
|
- `--ttl-seconds`
|
||||||
default and return structured JSON with `--json`
|
- `--network`
|
||||||
- the first official environment pull downloads from public Docker Hub into the
|
- `--allow-host-compat`
|
||||||
local environment cache
|
- `--json`
|
||||||
- `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
|
|
||||||
|
|
||||||
These commands exist to validate and debug the chat-host path. They are not the
|
Behavioral guarantees:
|
||||||
main product destination.
|
|
||||||
|
|
||||||
## MCP Entry Point
|
- `pyro run <environment> -- <command>` 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.
|
||||||
|
|
||||||
The product entrypoint is:
|
## Python SDK Contract
|
||||||
|
|
||||||
```bash
|
Primary facade:
|
||||||
pyro mcp serve
|
|
||||||
```
|
|
||||||
|
|
||||||
What to expect:
|
- `Pyro`
|
||||||
|
|
||||||
- named modes are now the first chat-host story:
|
Supported public entrypoints:
|
||||||
- `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
|
|
||||||
|
|
||||||
Host-specific setup docs:
|
- `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(...)`
|
||||||
|
|
||||||
- [claude_code_mcp.md](../examples/claude_code_mcp.md)
|
Stable public method names:
|
||||||
- [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)
|
|
||||||
|
|
||||||
The chat-host bootstrap helper surface is:
|
- `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(...)`
|
||||||
|
|
||||||
- `pyro host connect claude-code`
|
Behavioral defaults:
|
||||||
- `pyro host connect codex`
|
|
||||||
- `pyro host print-config opencode`
|
|
||||||
- `pyro host doctor`
|
|
||||||
- `pyro host repair HOST`
|
|
||||||
|
|
||||||
These helpers wrap the same `pyro mcp serve` entrypoint and are the preferred
|
- `Pyro.create_vm(...)` and `Pyro.run_in_vm(...)` default to `vcpu_count=1` and `mem_mib=1024`.
|
||||||
setup and repair path for supported hosts.
|
- `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`.
|
||||||
|
|
||||||
## Named Modes
|
## MCP Contract
|
||||||
|
|
||||||
The supported named modes are:
|
Stable MCP profiles:
|
||||||
|
|
||||||
| Mode | Intended workflow | Key tools |
|
- `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`
|
||||||
| `repro-fix` | reproduce, patch, rerun, diff, export, reset | file ops, patch, diff, export, reset, summary |
|
- `workspace-full`: exposes the complete stable MCP surface below
|
||||||
| `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 |
|
|
||||||
|
|
||||||
Use the generic no-mode path when one of those named modes feels too narrow for
|
Behavioral defaults:
|
||||||
the job.
|
|
||||||
|
|
||||||
## Generic Workspace Contract
|
- `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`.
|
||||||
|
|
||||||
`workspace-core` is the normal chat path. It exposes:
|
Primary tool:
|
||||||
|
|
||||||
- `vm_run`
|
- `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_create`
|
||||||
- `workspace_list`
|
- `workspace_list`
|
||||||
- `workspace_update`
|
|
||||||
- `workspace_status`
|
|
||||||
- `workspace_sync_push`
|
- `workspace_sync_push`
|
||||||
|
- `workspace_stop`
|
||||||
|
- `workspace_start`
|
||||||
- `workspace_exec`
|
- `workspace_exec`
|
||||||
- `workspace_logs`
|
|
||||||
- `workspace_summary`
|
|
||||||
- `workspace_file_list`
|
- `workspace_file_list`
|
||||||
- `workspace_file_read`
|
- `workspace_file_read`
|
||||||
- `workspace_file_write`
|
- `workspace_file_write`
|
||||||
- `workspace_patch_apply`
|
|
||||||
- `workspace_diff`
|
|
||||||
- `workspace_export`
|
- `workspace_export`
|
||||||
|
- `workspace_patch_apply`
|
||||||
|
- `workspace_disk_export`
|
||||||
|
- `workspace_disk_list`
|
||||||
|
- `workspace_disk_read`
|
||||||
|
- `workspace_diff`
|
||||||
|
- `snapshot_create`
|
||||||
|
- `snapshot_list`
|
||||||
|
- `snapshot_delete`
|
||||||
- `workspace_reset`
|
- `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`
|
- `workspace_delete`
|
||||||
|
|
||||||
That is enough for the normal persistent editing loop:
|
Behavioral defaults:
|
||||||
|
|
||||||
- create one workspace, often without `seed_path` when the server already has a
|
- `vm_run` and `vm_create` default to `vcpu_count=1` and `mem_mib=1024`.
|
||||||
project source
|
- `workspace_create` defaults to `vcpu_count=1` and `mem_mib=1024`.
|
||||||
- sync or seed repo content
|
- `vm_run` and `vm_create` expose `allow_host_compat`, which defaults to `false`.
|
||||||
- inspect and edit files without shell quoting
|
- `workspace_create` exposes `allow_host_compat`, which defaults to `false`.
|
||||||
- run commands repeatedly in one sandbox
|
- `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.
|
||||||
- review the current session in one concise summary
|
- `workspace_create` accepts optional `name` and `labels` metadata for human discovery without changing the stable `workspace_id`.
|
||||||
- diff and export results
|
- `workspace_create` accepts `network_policy` with `off`, `egress`, or `egress+published-ports` to control workspace guest networking and service port publication.
|
||||||
- reset and retry
|
- `workspace_create` accepts optional `secrets` and persists guest-only UTF-8 secret material outside `/workspace`.
|
||||||
- delete the workspace when the task is done
|
- `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.
|
||||||
|
|
||||||
Move to `workspace-full` only when the chat truly needs:
|
## Versioning Rule
|
||||||
|
|
||||||
- persistent PTY shell sessions
|
- `pyro-mcp` uses SemVer.
|
||||||
- long-running services and readiness probes
|
- Environment names are stable identifiers in the shipped catalog.
|
||||||
- secrets
|
- Changing a public command name, public flag, public method name, public MCP tool name, or required request field is a breaking change.
|
||||||
- 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.
|
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,12 @@ goal:
|
||||||
make the core agent-workspace use cases feel trivial from a chat-driven LLM
|
make the core agent-workspace use cases feel trivial from a chat-driven LLM
|
||||||
interface.
|
interface.
|
||||||
|
|
||||||
Current baseline is `4.5.0`:
|
Current baseline is `4.0.0`:
|
||||||
|
|
||||||
- `pyro mcp serve` is now the default product entrypoint
|
- the stable workspace contract exists across CLI, SDK, and MCP
|
||||||
- `workspace-core` is now the default MCP profile
|
- one-shot `pyro run` still exists as the narrow entrypoint
|
||||||
- one-shot `pyro run` still exists as the terminal companion path
|
|
||||||
- workspaces already support seeding, sync push, exec, export, diff, snapshots,
|
- workspaces already support seeding, sync push, exec, export, diff, snapshots,
|
||||||
reset, services, PTY shells, secrets, network policy, and published ports
|
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
|
- stopped-workspace disk tools now exist, but remain explicitly secondary
|
||||||
|
|
||||||
## What "Trivial In Chat" Means
|
## What "Trivial In Chat" Means
|
||||||
|
|
@ -36,16 +33,9 @@ More concretely, the model should not need to:
|
||||||
- choose from an unnecessarily large tool surface when a smaller profile would
|
- choose from an unnecessarily large tool surface when a smaller profile would
|
||||||
work
|
work
|
||||||
|
|
||||||
The next gaps for the narrowed persona are now about real-project credibility:
|
The remaining UX friction for a technically strong new user is now narrower:
|
||||||
|
|
||||||
- current-checkout startup is still brittle for messy local repos with unreadable,
|
- no major chat-host ergonomics gaps remain in the current roadmap
|
||||||
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
|
## Locked Decisions
|
||||||
|
|
||||||
|
|
@ -53,24 +43,9 @@ The next gaps for the narrowed persona are now about real-project credibility:
|
||||||
or runner abstractions
|
or runner abstractions
|
||||||
- keep disk tools secondary and do not make them the main chat-facing surface
|
- 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
|
- prefer narrow tool profiles and structured outputs over more raw shell calls
|
||||||
- optimize the MCP/chat-host path first and keep the CLI companion path good
|
- capability milestones should update CLI, SDK, and MCP together
|
||||||
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
|
- CLI-only ergonomics are allowed when the SDK and MCP surfaces already have the
|
||||||
structured behavior natively
|
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,
|
- every milestone below must also update docs, help text, runnable examples,
|
||||||
and at least one real smoke scenario
|
and at least one real smoke scenario
|
||||||
|
|
||||||
|
|
@ -87,16 +62,6 @@ The next gaps for the narrowed persona are now about real-project credibility:
|
||||||
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
|
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
|
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
|
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:
|
Completed so far:
|
||||||
|
|
||||||
|
|
@ -127,29 +92,10 @@ Completed so far:
|
||||||
config manually.
|
config manually.
|
||||||
- `4.0.0` flipped the default MCP/server profile to `workspace-core`, so the bare entrypoint now
|
- `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.
|
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:
|
Planned next:
|
||||||
|
|
||||||
- [`4.6.0` Git-Tracked Project Sources](llm-chat-ergonomics/4.6.0-git-tracked-project-sources.md)
|
- no further chat-ergonomics milestones are currently planned in this roadmap.
|
||||||
- [`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
|
## Expected Outcome
|
||||||
|
|
||||||
|
|
@ -171,16 +117,3 @@ The intended model-facing shape is:
|
||||||
- human-mode content reads are copy-paste safe
|
- human-mode content reads are copy-paste safe
|
||||||
- the default bare MCP server entrypoint matches the recommended narrow profile
|
- the default bare MCP server entrypoint matches the recommended narrow profile
|
||||||
- the five core use cases are documented and smoke-tested end to end
|
- 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
|
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
# `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
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
# `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
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
# `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
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
# `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
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
# `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
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
# `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
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
# `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`
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
# `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
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
# `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
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
# `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
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Workspace Use-Case Recipes
|
# Workspace Use-Case Recipes
|
||||||
|
|
||||||
These recipes turn the chat-host workspace path into five concrete agent flows.
|
These recipes turn the stable workspace surface into five concrete agent flows.
|
||||||
They are the canonical next step after the quickstart in [install.md](../install.md)
|
They are the canonical next step after the quickstart in [install.md](../install.md)
|
||||||
or [first-run.md](../first-run.md).
|
or [first-run.md](../first-run.md).
|
||||||
|
|
||||||
|
|
@ -12,13 +12,13 @@ make smoke-use-cases
|
||||||
|
|
||||||
Recipe matrix:
|
Recipe matrix:
|
||||||
|
|
||||||
| Use case | Recommended mode | Smoke target | Recipe |
|
| Use case | Recommended profile | Smoke target | Recipe |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Cold-start repo validation | `cold-start` | `make smoke-cold-start-validation` | [cold-start-repo-validation.md](cold-start-repo-validation.md) |
|
| 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 | `repro-fix` | `make smoke-repro-fix-loop` | [repro-fix-loop.md](repro-fix-loop.md) |
|
| Repro plus fix loop | `workspace-core` | `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) |
|
| Parallel isolated workspaces | `workspace-core` | `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) |
|
| Unsafe or untrusted code inspection | `workspace-core` | `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) |
|
| Review and evaluation workflows | `workspace-full` | `make smoke-review-eval` | [review-eval-workflows.md](review-eval-workflows.md) |
|
||||||
|
|
||||||
All five recipes use the same real Firecracker-backed smoke runner:
|
All five recipes use the same real Firecracker-backed smoke runner:
|
||||||
|
|
||||||
|
|
@ -30,9 +30,3 @@ That runner generates its own host fixtures, creates real guest-backed workspace
|
||||||
verifies the intended flow, exports one concrete result when relevant, and cleans
|
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
|
up on both success and failure. Treat `make smoke-use-cases` as the trustworthy
|
||||||
guest-backed verification path for the advertised workspace workflows.
|
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
|
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,6 @@
|
||||||
# Cold-Start Repo Validation
|
# Cold-Start Repo Validation
|
||||||
|
|
||||||
Recommended mode: `cold-start`
|
Recommended profile: `workspace-full`
|
||||||
|
|
||||||
Recommended startup:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pyro host connect claude-code --mode cold-start
|
|
||||||
```
|
|
||||||
|
|
||||||
Smoke target:
|
Smoke target:
|
||||||
|
|
||||||
|
|
@ -18,18 +12,26 @@ 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
|
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.
|
process alive, probe it from another command, and export a validation report.
|
||||||
|
|
||||||
Chat-host recipe:
|
Canonical SDK flow:
|
||||||
|
|
||||||
1. Create one workspace from the repo seed.
|
```python
|
||||||
2. Run the validation command inside that workspace.
|
from pyro_mcp import Pyro
|
||||||
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.
|
|
||||||
|
|
||||||
If the named mode feels too narrow, fall back to the generic no-mode path and
|
pyro = Pyro()
|
||||||
then opt into `--profile workspace-full` only when you truly need the larger
|
created = pyro.create_workspace(environment="debian:12", seed_path="./repo")
|
||||||
advanced surface.
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
This recipe is intentionally guest-local and deterministic. It proves startup,
|
This recipe is intentionally guest-local and deterministic. It proves startup,
|
||||||
service readiness, validation, and host-out report capture without depending on
|
service readiness, validation, and host-out report capture without depending on
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,6 @@
|
||||||
# Parallel Isolated Workspaces
|
# Parallel Isolated Workspaces
|
||||||
|
|
||||||
Recommended mode: `repro-fix`
|
Recommended profile: `workspace-core`
|
||||||
|
|
||||||
Recommended startup:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pyro host connect codex --mode repro-fix
|
|
||||||
```
|
|
||||||
|
|
||||||
Smoke target:
|
Smoke target:
|
||||||
|
|
||||||
|
|
@ -17,16 +11,33 @@ make smoke-parallel-workspaces
|
||||||
Use this flow when the agent needs one isolated workspace per issue, branch, or
|
Use this flow when the agent needs one isolated workspace per issue, branch, or
|
||||||
review thread and must rediscover the right one later.
|
review thread and must rediscover the right one later.
|
||||||
|
|
||||||
Chat-host recipe:
|
Canonical SDK flow:
|
||||||
|
|
||||||
1. Create one workspace per issue or branch with a human-friendly name and
|
```python
|
||||||
labels.
|
from pyro_mcp import Pyro
|
||||||
2. Mutate each workspace independently.
|
|
||||||
3. Rediscover the right workspace later with `workspace_list`.
|
pyro = Pyro()
|
||||||
4. Update metadata when ownership or issue mapping changes.
|
alpha = pyro.create_workspace(
|
||||||
5. Delete each workspace independently when its task is done.
|
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"])
|
||||||
|
```
|
||||||
|
|
||||||
The important proof here is operational, not syntactic: names, labels, list
|
The important proof here is operational, not syntactic: names, labels, list
|
||||||
ordering, and file contents stay isolated even when multiple workspaces are
|
ordering, and file contents stay isolated even when multiple workspaces are
|
||||||
active at the same time. Parallel work still means “open another workspace in
|
active at the same time.
|
||||||
the same mode,” not “pick a special parallel-work mode.”
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,6 @@
|
||||||
# Repro Plus Fix Loop
|
# Repro Plus Fix Loop
|
||||||
|
|
||||||
Recommended mode: `repro-fix`
|
Recommended profile: `workspace-core`
|
||||||
|
|
||||||
Recommended startup:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pyro host connect codex --mode repro-fix
|
|
||||||
```
|
|
||||||
|
|
||||||
Smoke target:
|
Smoke target:
|
||||||
|
|
||||||
|
|
@ -18,21 +12,31 @@ 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
|
quoting tricks, rerun the failing command, diff the result, export the fix, and
|
||||||
reset back to baseline.
|
reset back to baseline.
|
||||||
|
|
||||||
Chat-host recipe:
|
Canonical SDK flow:
|
||||||
|
|
||||||
1. Start the server from the repo root with bare `pyro mcp serve`, or use
|
```python
|
||||||
`--project-path` if the host does not preserve cwd.
|
from pyro_mcp import Pyro
|
||||||
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.
|
|
||||||
|
|
||||||
If the mode feels too narrow for the job, fall back to the generic bare
|
pyro = Pyro()
|
||||||
`pyro mcp serve` path.
|
created = pyro.create_workspace(environment="debian:12", seed_path="./broken-repro")
|
||||||
|
workspace_id = str(created["workspace_id"])
|
||||||
|
|
||||||
This is the main `repro-fix` story: model-native file ops, repeatable exec,
|
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,
|
||||||
structured diff, explicit export, and reset-over-repair.
|
structured diff, explicit export, and reset-over-repair.
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,6 @@
|
||||||
# Review And Evaluation Workflows
|
# Review And Evaluation Workflows
|
||||||
|
|
||||||
Recommended mode: `review-eval`
|
Recommended profile: `workspace-full`
|
||||||
|
|
||||||
Recommended startup:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pyro host connect claude-code --mode review-eval
|
|
||||||
```
|
|
||||||
|
|
||||||
Smoke target:
|
Smoke target:
|
||||||
|
|
||||||
|
|
@ -17,15 +11,30 @@ make smoke-review-eval
|
||||||
Use this flow when an agent needs to read a checklist interactively, run an
|
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.
|
evaluation script, checkpoint or reset its changes, and export the final report.
|
||||||
|
|
||||||
Chat-host recipe:
|
Canonical SDK flow:
|
||||||
|
|
||||||
1. Create a named snapshot before the review starts.
|
```python
|
||||||
2. Open a readable PTY shell and inspect the checklist interactively.
|
from pyro_mcp import Pyro
|
||||||
3. Run the review or evaluation script in the same workspace.
|
|
||||||
4. Capture `workspace summary` to review what changed and what to export.
|
pyro = Pyro()
|
||||||
5. Export the final report.
|
created = pyro.create_workspace(environment="debian:12", seed_path="./review-fixture")
|
||||||
6. Reset back to the snapshot if the review branch goes sideways.
|
workspace_id = str(created["workspace_id"])
|
||||||
7. Delete the workspace when the evaluation is done.
|
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
This is the stable shell-facing story: readable PTY output for chat loops,
|
This is the stable shell-facing story: readable PTY output for chat loops,
|
||||||
checkpointed evaluation, explicit export, and reset when a review branch goes
|
checkpointed evaluation, explicit export, and reset when a review branch goes
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,6 @@
|
||||||
# Unsafe Or Untrusted Code Inspection
|
# Unsafe Or Untrusted Code Inspection
|
||||||
|
|
||||||
Recommended mode: `inspect`
|
Recommended profile: `workspace-core`
|
||||||
|
|
||||||
Recommended startup:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pyro host connect codex --mode inspect
|
|
||||||
```
|
|
||||||
|
|
||||||
Smoke target:
|
Smoke target:
|
||||||
|
|
||||||
|
|
@ -17,13 +11,24 @@ make smoke-untrusted-inspection
|
||||||
Use this flow when the agent needs to inspect suspicious code or an unfamiliar
|
Use this flow when the agent needs to inspect suspicious code or an unfamiliar
|
||||||
repo without granting more capabilities than necessary.
|
repo without granting more capabilities than necessary.
|
||||||
|
|
||||||
Chat-host recipe:
|
Canonical SDK flow:
|
||||||
|
|
||||||
1. Create one workspace from the suspicious repo seed.
|
```python
|
||||||
2. Inspect the tree with structured file listing and file reads.
|
from pyro_mcp import Pyro
|
||||||
3. Run the smallest possible command that produces the inspection report.
|
|
||||||
4. Export only the report the agent chose to materialize.
|
pyro = Pyro()
|
||||||
5. Delete the workspace when inspection is complete.
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
This recipe stays offline-by-default, uses only explicit file reads and execs,
|
This recipe stays offline-by-default, uses only explicit file reads and execs,
|
||||||
and exports only the inspection report the agent chose to materialize.
|
and exports only the inspection report the agent chose to materialize.
|
||||||
|
|
|
||||||
135
docs/vision.md
135
docs/vision.md
|
|
@ -1,19 +1,16 @@
|
||||||
# Vision
|
# Vision
|
||||||
|
|
||||||
`pyro-mcp` should become the disposable MCP workspace for chat-based coding
|
`pyro-mcp` should become the disposable sandbox where an agent can do real
|
||||||
agents.
|
development work safely, repeatedly, and reproducibly.
|
||||||
|
|
||||||
That is a different product from a generic VM wrapper, a secure CI runner, or
|
That is a different product from a generic VM wrapper, a secure CI runner, or a
|
||||||
an SDK-first platform.
|
task queue with better isolation.
|
||||||
|
|
||||||
`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
|
## Core Thesis
|
||||||
|
|
||||||
The goal is not just to run one command in a microVM.
|
The goal is not just to run one command in a microVM.
|
||||||
|
|
||||||
The goal is to give a chat-hosted coding agent a bounded workspace where it can:
|
The goal is to give an LLM or coding agent a bounded workspace where it can:
|
||||||
|
|
||||||
- inspect a repo
|
- inspect a repo
|
||||||
- install dependencies
|
- install dependencies
|
||||||
|
|
@ -26,25 +23,6 @@ The goal is to give a chat-hosted coding agent a bounded workspace where it can:
|
||||||
|
|
||||||
The sandbox is the execution boundary for agentic software work.
|
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
|
## What This Is Not
|
||||||
|
|
||||||
`pyro-mcp` should not drift into:
|
`pyro-mcp` should not drift into:
|
||||||
|
|
@ -54,10 +32,9 @@ product story.
|
||||||
- a generic CI job runner
|
- a generic CI job runner
|
||||||
- a scheduler or queueing platform
|
- a scheduler or queueing platform
|
||||||
- a broad VM orchestration product
|
- 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, or
|
Those products optimize for queued work, throughput, retries, matrix builds, and
|
||||||
library ergonomics.
|
shared infrastructure.
|
||||||
|
|
||||||
`pyro-mcp` should optimize for agent loops:
|
`pyro-mcp` should optimize for agent loops:
|
||||||
|
|
||||||
|
|
@ -80,15 +57,10 @@ 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.
|
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
|
To stay aligned, the primary abstraction should be a workspace the agent
|
||||||
inhabits from a chat host, not a job the agent submits to a runner.
|
inhabits, not a job the agent submits.
|
||||||
|
|
||||||
## Product Principles
|
## 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
|
### Workspace-First
|
||||||
|
|
||||||
The default mental model should be "open a disposable workspace" rather than
|
The default mental model should be "open a disposable workspace" rather than
|
||||||
|
|
@ -113,6 +85,11 @@ Anything that crosses the host boundary should be intentional and visible:
|
||||||
Agents should be able to checkpoint, reset, and retry cheaply. Disposable state
|
Agents should be able to checkpoint, reset, and retry cheaply. Disposable state
|
||||||
is a feature, not a limitation.
|
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
|
### Agent-Native Observability
|
||||||
|
|
||||||
The sandbox should expose the things an agent actually needs to reason about:
|
The sandbox should expose the things an agent actually needs to reason about:
|
||||||
|
|
@ -124,16 +101,10 @@ The sandbox should expose the things an agent actually needs to reason about:
|
||||||
- readiness
|
- readiness
|
||||||
- exported results
|
- exported results
|
||||||
|
|
||||||
## The Shape Of The Product
|
## The Shape Of An LLM-First Sandbox
|
||||||
|
|
||||||
The strongest direction is a small chat-facing contract built around:
|
The strongest future direction is a small, agent-native contract built around
|
||||||
|
workspaces, shells, files, services, and reset.
|
||||||
- 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:
|
Representative primitives:
|
||||||
|
|
||||||
|
|
@ -143,57 +114,95 @@ Representative primitives:
|
||||||
- `workspace.sync_push`
|
- `workspace.sync_push`
|
||||||
- `workspace.export`
|
- `workspace.export`
|
||||||
- `workspace.diff`
|
- `workspace.diff`
|
||||||
|
- `workspace.snapshot`
|
||||||
- `workspace.reset`
|
- `workspace.reset`
|
||||||
- `workspace.exec`
|
|
||||||
- `shell.open`
|
- `shell.open`
|
||||||
- `shell.read`
|
- `shell.read`
|
||||||
- `shell.write`
|
- `shell.write`
|
||||||
|
- `shell.signal`
|
||||||
|
- `shell.close`
|
||||||
|
- `workspace.exec`
|
||||||
- `service.start`
|
- `service.start`
|
||||||
- `service.status`
|
- `service.status`
|
||||||
- `service.logs`
|
- `service.logs`
|
||||||
|
- `service.stop`
|
||||||
|
|
||||||
These names are illustrative, not a promise that every lower-level repo surface
|
These names are illustrative, not a committed public API.
|
||||||
should be treated as equally stable or equally important.
|
|
||||||
|
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
|
||||||
|
|
||||||
## Interactive Shells And Disk Operations
|
## Interactive Shells And Disk Operations
|
||||||
|
|
||||||
Interactive shells are aligned with the vision because they make the agent feel
|
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.
|
present inside the sandbox rather than reduced to one-shot job submission.
|
||||||
|
|
||||||
They should remain subordinate to the workspace model, not replace it with a
|
That does not mean `pyro-mcp` should become a raw SSH replacement. The shell
|
||||||
raw SSH story.
|
should sit inside a higher-level workspace model with structured file, service,
|
||||||
|
diff, and reset operations around it.
|
||||||
|
|
||||||
Disk-level operations are useful for:
|
Disk-level operations are also useful, but they should remain supporting tools.
|
||||||
|
They are good for:
|
||||||
|
|
||||||
- fast workspace seeding
|
- fast workspace seeding
|
||||||
- snapshotting
|
- snapshotting
|
||||||
- offline inspection
|
- offline inspection
|
||||||
|
- diffing
|
||||||
- export/import without a full boot
|
- export/import without a full boot
|
||||||
|
|
||||||
They should remain supporting tools rather than the product identity.
|
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.
|
||||||
|
|
||||||
## What To Build Next
|
## What To Build Next
|
||||||
|
|
||||||
Features should keep reinforcing the chat-host path in this order:
|
Features should be prioritized in this order:
|
||||||
|
|
||||||
1. make the first chat-host setup painfully obvious
|
1. Repeated commands in one persistent workspace
|
||||||
2. make the recipe-backed workflows feel trivial from chat
|
2. Interactive shell sessions with PTY semantics
|
||||||
3. keep the smoke pack trustworthy enough to gate the advertised stories
|
3. Structured workspace sync and export
|
||||||
4. keep the terminal companion path good enough to debug what the chat sees
|
4. Service lifecycle and readiness checks
|
||||||
5. let lower-level repo surfaces move freely when the chat-host product needs it
|
5. Snapshot and reset workflows
|
||||||
|
6. Explicit secrets and network policy
|
||||||
|
7. Secondary disk-level import/export and inspection tools
|
||||||
|
|
||||||
The completed workspace GA roadmap lives in
|
The completed workspace GA roadmap lives in
|
||||||
[roadmap/task-workspace-ga.md](roadmap/task-workspace-ga.md).
|
[roadmap/task-workspace-ga.md](roadmap/task-workspace-ga.md).
|
||||||
|
|
||||||
The follow-on milestones that make the chat-host path clearer live in
|
The next implementation milestones that make those workflows feel natural from
|
||||||
|
chat-driven LLM interfaces live in
|
||||||
[roadmap/llm-chat-ergonomics.md](roadmap/llm-chat-ergonomics.md).
|
[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
|
## Litmus Test
|
||||||
|
|
||||||
When evaluating a new feature, ask:
|
When evaluating a new feature, ask:
|
||||||
|
|
||||||
"Does this make Claude Code, Codex, or OpenCode feel more natural and powerful
|
"Does this help an agent inhabit a safe disposable workspace and do real
|
||||||
when they work inside a disposable sandbox?"
|
software work inside it?"
|
||||||
|
|
||||||
If the better description is "it helps build a broader VM toolkit or SDK", it
|
If the better description is "it helps submit, schedule, and report jobs", the
|
||||||
is probably pushing the product in the wrong direction.
|
feature is probably pushing the product in the wrong direction.
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,21 @@
|
||||||
# Claude Code MCP Setup
|
# Claude Code MCP Setup
|
||||||
|
|
||||||
Recommended modes:
|
Recommended profile: `workspace-core`.
|
||||||
|
|
||||||
- `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:
|
Package without install:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||||
claude mcp list
|
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:
|
Already installed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
claude mcp add pyro -- pyro mcp serve --mode cold-start
|
claude mcp add pyro -- pyro mcp serve
|
||||||
claude mcp list
|
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,
|
Move to `workspace-full` only when the chat truly needs shells, services,
|
||||||
snapshots, secrets, network policy, or disk tools:
|
snapshots, secrets, network policy, or disk tools:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,21 @@
|
||||||
# Codex MCP Setup
|
# Codex MCP Setup
|
||||||
|
|
||||||
Recommended modes:
|
Recommended profile: `workspace-core`.
|
||||||
|
|
||||||
- `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:
|
Package without install:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||||
codex mcp list
|
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:
|
Already installed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
codex mcp add pyro -- pyro mcp serve --mode repro-fix
|
codex mcp add pyro -- pyro mcp serve
|
||||||
codex mcp list
|
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,
|
Move to `workspace-full` only when the chat truly needs shells, services,
|
||||||
snapshots, secrets, network policy, or disk tools:
|
snapshots, secrets, network policy, or disk tools:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
# MCP Client Config Example
|
# MCP Client Config Example
|
||||||
|
|
||||||
Recommended named modes for most chat hosts in `4.x`:
|
Default for most chat hosts in `4.x`: `workspace-core`.
|
||||||
|
|
||||||
- `repro-fix`
|
|
||||||
- `inspect`
|
|
||||||
- `cold-start`
|
|
||||||
- `review-eval`
|
|
||||||
|
|
||||||
Use the host-specific examples first when they apply:
|
Use the host-specific examples first when they apply:
|
||||||
|
|
||||||
|
|
@ -13,18 +8,8 @@ Use the host-specific examples first when they apply:
|
||||||
- Codex: [examples/codex_mcp.md](codex_mcp.md)
|
- Codex: [examples/codex_mcp.md](codex_mcp.md)
|
||||||
- OpenCode: [examples/opencode_mcp_config.json](opencode_mcp_config.json)
|
- 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
|
Use this generic config only when the host expects a plain `mcpServers` JSON
|
||||||
shape or when the named modes are too narrow for the workflow.
|
shape.
|
||||||
|
|
||||||
`pyro-mcp` is intended to be exposed to LLM clients through the public `pyro` CLI.
|
`pyro-mcp` is intended to be exposed to LLM clients through the public `pyro` CLI.
|
||||||
|
|
||||||
|
|
@ -35,7 +20,7 @@ Generic stdio MCP configuration using `uvx`:
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"pyro": {
|
"pyro": {
|
||||||
"command": "uvx",
|
"command": "uvx",
|
||||||
"args": ["--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
|
"args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -48,28 +33,21 @@ If `pyro-mcp` is already installed locally, the same server can be configured wi
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"pyro": {
|
"pyro": {
|
||||||
"command": "pyro",
|
"command": "pyro",
|
||||||
"args": ["mcp", "serve", "--mode", "repro-fix"]
|
"args": ["mcp", "serve"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
If the host does not preserve the server working directory and you want the
|
Profile progression:
|
||||||
first `workspace_create` to start from a specific checkout, add
|
|
||||||
`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same args list.
|
|
||||||
|
|
||||||
Mode progression:
|
- `workspace-core`: the default and recommended first persistent chat profile
|
||||||
|
- `vm-run`: expose only `vm_run`
|
||||||
- `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
|
- `workspace-full`: explicit advanced opt-in for shells, services, snapshots, secrets, network policy, and disk tools
|
||||||
|
|
||||||
Primary mode for most agents:
|
Primary profile for most agents:
|
||||||
|
|
||||||
- `repro-fix`
|
- `workspace-core`
|
||||||
|
|
||||||
Use lifecycle tools only when the agent needs persistent VM state across multiple tool calls.
|
Use lifecycle tools only when the agent needs persistent VM state across multiple tool calls.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
"pyro": {
|
"pyro": {
|
||||||
"type": "local",
|
"type": "local",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
|
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
[project]
|
[project]
|
||||||
name = "pyro-mcp"
|
name = "pyro-mcp"
|
||||||
version = "4.5.0"
|
version = "4.0.0"
|
||||||
description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM."
|
description = "Stable Firecracker workspaces, one-shot sandboxes, and MCP tools for coding agents."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [
|
authors = [
|
||||||
|
|
@ -9,7 +9,7 @@ authors = [
|
||||||
]
|
]
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 4 - Beta",
|
"Development Status :: 5 - Production/Stable",
|
||||||
"Environment :: Console",
|
"Environment :: Console",
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: MIT License",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
"""Run the real guest-backed daily-loop smoke."""
|
|
||||||
|
|
||||||
from pyro_mcp.daily_loop_smoke import main
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -8,22 +8,11 @@ from typing import Any, Literal, cast
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
from pyro_mcp.contract import (
|
from pyro_mcp.contract import (
|
||||||
PUBLIC_MCP_COLD_START_MODE_TOOLS,
|
|
||||||
PUBLIC_MCP_INSPECT_MODE_TOOLS,
|
|
||||||
PUBLIC_MCP_MODES,
|
|
||||||
PUBLIC_MCP_PROFILES,
|
PUBLIC_MCP_PROFILES,
|
||||||
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
|
|
||||||
PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS,
|
|
||||||
PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
|
PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
|
||||||
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
|
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
|
||||||
PUBLIC_MCP_WORKSPACE_FULL_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 (
|
from pyro_mcp.vm_manager import (
|
||||||
DEFAULT_ALLOW_HOST_COMPAT,
|
DEFAULT_ALLOW_HOST_COMPAT,
|
||||||
DEFAULT_MEM_MIB,
|
DEFAULT_MEM_MIB,
|
||||||
|
|
@ -35,77 +24,12 @@ from pyro_mcp.vm_manager import (
|
||||||
)
|
)
|
||||||
|
|
||||||
McpToolProfile = Literal["vm-run", "workspace-core", "workspace-full"]
|
McpToolProfile = Literal["vm-run", "workspace-core", "workspace-full"]
|
||||||
WorkspaceUseCaseMode = Literal["repro-fix", "inspect", "cold-start", "review-eval"]
|
|
||||||
|
|
||||||
_PROFILE_TOOLS: dict[str, tuple[str, ...]] = {
|
_PROFILE_TOOLS: dict[str, tuple[str, ...]] = {
|
||||||
"vm-run": PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
|
"vm-run": PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
|
||||||
"workspace-core": PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
|
"workspace-core": PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
|
||||||
"workspace-full": PUBLIC_MCP_WORKSPACE_FULL_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:
|
def _validate_mcp_profile(profile: str) -> McpToolProfile:
|
||||||
|
|
@ -115,44 +39,6 @@ def _validate_mcp_profile(profile: str) -> McpToolProfile:
|
||||||
return cast(McpToolProfile, profile)
|
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:
|
class Pyro:
|
||||||
"""High-level facade over the ephemeral VM runtime."""
|
"""High-level facade over the ephemeral VM runtime."""
|
||||||
|
|
||||||
|
|
@ -300,9 +186,6 @@ class Pyro:
|
||||||
def logs_workspace(self, workspace_id: str) -> dict[str, Any]:
|
def logs_workspace(self, workspace_id: str) -> dict[str, Any]:
|
||||||
return self._manager.logs_workspace(workspace_id)
|
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(
|
def export_workspace(
|
||||||
self,
|
self,
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
|
|
@ -579,40 +462,15 @@ class Pyro:
|
||||||
allow_host_compat=allow_host_compat,
|
allow_host_compat=allow_host_compat,
|
||||||
)
|
)
|
||||||
|
|
||||||
def create_server(
|
def create_server(self, *, profile: McpToolProfile = "workspace-core") -> FastMCP:
|
||||||
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.
|
"""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
|
`workspace-core` is the default stable chat-host profile in 4.x. Use
|
||||||
`profile="workspace-full"` only when the host truly needs the full
|
`profile="workspace-full"` only when the host truly needs the full
|
||||||
advanced workspace surface. By default, the server auto-detects the
|
advanced workspace surface.
|
||||||
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)
|
normalized_profile = _validate_mcp_profile(profile)
|
||||||
normalized_mode = _validate_workspace_mode(mode) if mode is not None else None
|
enabled_tools = set(_PROFILE_TOOLS[normalized_profile])
|
||||||
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")
|
server = FastMCP(name="pyro_mcp")
|
||||||
|
|
||||||
def _enabled(tool_name: str) -> bool:
|
def _enabled(tool_name: str) -> bool:
|
||||||
|
|
@ -725,59 +583,9 @@ class Pyro:
|
||||||
return self.reap_expired()
|
return self.reap_expired()
|
||||||
|
|
||||||
if _enabled("workspace_create"):
|
if _enabled("workspace_create"):
|
||||||
workspace_create_description = _workspace_create_description(
|
if normalized_profile == "workspace-core":
|
||||||
startup_source,
|
|
||||||
mode=normalized_mode,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _create_workspace_from_server_defaults(
|
@server.tool(name="workspace_create")
|
||||||
*,
|
|
||||||
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(
|
async def workspace_create_core(
|
||||||
environment: str,
|
environment: str,
|
||||||
vcpu_count: int = DEFAULT_VCPU_COUNT,
|
vcpu_count: int = DEFAULT_VCPU_COUNT,
|
||||||
|
|
@ -788,7 +596,8 @@ class Pyro:
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
labels: dict[str, str] | None = None,
|
labels: dict[str, str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
return _create_workspace_from_server_defaults(
|
"""Create and start a persistent workspace."""
|
||||||
|
return self.create_workspace(
|
||||||
environment=environment,
|
environment=environment,
|
||||||
vcpu_count=vcpu_count,
|
vcpu_count=vcpu_count,
|
||||||
mem_mib=mem_mib,
|
mem_mib=mem_mib,
|
||||||
|
|
@ -803,7 +612,7 @@ class Pyro:
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
||||||
@server.tool(name="workspace_create", description=workspace_create_description)
|
@server.tool(name="workspace_create")
|
||||||
async def workspace_create_full(
|
async def workspace_create_full(
|
||||||
environment: str,
|
environment: str,
|
||||||
vcpu_count: int = DEFAULT_VCPU_COUNT,
|
vcpu_count: int = DEFAULT_VCPU_COUNT,
|
||||||
|
|
@ -816,7 +625,8 @@ class Pyro:
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
labels: dict[str, str] | None = None,
|
labels: dict[str, str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
return _create_workspace_from_server_defaults(
|
"""Create and start a persistent workspace."""
|
||||||
|
return self.create_workspace(
|
||||||
environment=environment,
|
environment=environment,
|
||||||
vcpu_count=vcpu_count,
|
vcpu_count=vcpu_count,
|
||||||
mem_mib=mem_mib,
|
mem_mib=mem_mib,
|
||||||
|
|
@ -856,21 +666,15 @@ class Pyro:
|
||||||
)
|
)
|
||||||
|
|
||||||
if _enabled("workspace_exec"):
|
if _enabled("workspace_exec"):
|
||||||
if normalized_mode is not None or normalized_profile == "workspace-core":
|
if normalized_profile == "workspace-core":
|
||||||
|
|
||||||
@server.tool(
|
@server.tool(name="workspace_exec")
|
||||||
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(
|
async def workspace_exec_core(
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
command: str,
|
command: str,
|
||||||
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""Run one command inside an existing persistent workspace."""
|
||||||
return self.exec_workspace(
|
return self.exec_workspace(
|
||||||
workspace_id,
|
workspace_id,
|
||||||
command=command,
|
command=command,
|
||||||
|
|
@ -880,20 +684,14 @@ class Pyro:
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
||||||
@server.tool(
|
@server.tool(name="workspace_exec")
|
||||||
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(
|
async def workspace_exec_full(
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
command: str,
|
command: str,
|
||||||
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
||||||
secret_env: dict[str, str] | None = None,
|
secret_env: dict[str, str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""Run one command inside an existing persistent workspace."""
|
||||||
return self.exec_workspace(
|
return self.exec_workspace(
|
||||||
workspace_id,
|
workspace_id,
|
||||||
command=command,
|
command=command,
|
||||||
|
|
@ -940,32 +738,15 @@ class Pyro:
|
||||||
"""Return persisted command history for one workspace."""
|
"""Return persisted command history for one workspace."""
|
||||||
return self.logs_workspace(workspace_id)
|
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"):
|
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(
|
async def workspace_export(
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
path: str,
|
path: str,
|
||||||
output_path: str,
|
output_path: str,
|
||||||
) -> dict[str, Any]:
|
) -> 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)
|
return self.export_workspace(workspace_id, path, output_path=output_path)
|
||||||
|
|
||||||
if _enabled("workspace_diff"):
|
if _enabled("workspace_diff"):
|
||||||
|
|
@ -977,21 +758,13 @@ class Pyro:
|
||||||
|
|
||||||
if _enabled("workspace_file_list"):
|
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(
|
async def workspace_file_list(
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
path: str = "/workspace",
|
path: str = "/workspace",
|
||||||
recursive: bool = False,
|
recursive: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""List metadata for files and directories under one live workspace path."""
|
||||||
return self.list_workspace_files(
|
return self.list_workspace_files(
|
||||||
workspace_id,
|
workspace_id,
|
||||||
path=path,
|
path=path,
|
||||||
|
|
@ -1000,18 +773,13 @@ class Pyro:
|
||||||
|
|
||||||
if _enabled("workspace_file_read"):
|
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(
|
async def workspace_file_read(
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
path: str,
|
path: str,
|
||||||
max_bytes: int = 65536,
|
max_bytes: int = 65536,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""Read one regular text file from a live workspace path."""
|
||||||
return self.read_workspace_file(
|
return self.read_workspace_file(
|
||||||
workspace_id,
|
workspace_id,
|
||||||
path,
|
path,
|
||||||
|
|
@ -1020,18 +788,13 @@ class Pyro:
|
||||||
|
|
||||||
if _enabled("workspace_file_write"):
|
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(
|
async def workspace_file_write(
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
path: str,
|
path: str,
|
||||||
text: str,
|
text: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""Create or replace one regular text file under `/workspace`."""
|
||||||
return self.write_workspace_file(
|
return self.write_workspace_file(
|
||||||
workspace_id,
|
workspace_id,
|
||||||
path,
|
path,
|
||||||
|
|
@ -1040,17 +803,12 @@ class Pyro:
|
||||||
|
|
||||||
if _enabled("workspace_patch_apply"):
|
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(
|
async def workspace_patch_apply(
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
patch: str,
|
patch: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""Apply a unified text patch inside one live workspace."""
|
||||||
return self.apply_workspace_patch(
|
return self.apply_workspace_patch(
|
||||||
workspace_id,
|
workspace_id,
|
||||||
patch=patch,
|
patch=patch,
|
||||||
|
|
@ -1128,38 +886,8 @@ class Pyro:
|
||||||
return self.reset_workspace(workspace_id, snapshot=snapshot)
|
return self.reset_workspace(workspace_id, snapshot=snapshot)
|
||||||
|
|
||||||
if _enabled("shell_open"):
|
if _enabled("shell_open"):
|
||||||
if normalized_mode == "review-eval":
|
|
||||||
|
|
||||||
@server.tool(
|
@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(
|
async def shell_open(
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
cwd: str = "/workspace",
|
cwd: str = "/workspace",
|
||||||
|
|
@ -1167,6 +895,7 @@ class Pyro:
|
||||||
rows: int = 30,
|
rows: int = 30,
|
||||||
secret_env: dict[str, str] | None = None,
|
secret_env: dict[str, str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""Open a persistent interactive shell inside one workspace."""
|
||||||
return self.open_shell(
|
return self.open_shell(
|
||||||
workspace_id,
|
workspace_id,
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
|
|
@ -1177,13 +906,7 @@ class Pyro:
|
||||||
|
|
||||||
if _enabled("shell_read"):
|
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(
|
async def shell_read(
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
shell_id: str,
|
shell_id: str,
|
||||||
|
|
@ -1192,6 +915,7 @@ class Pyro:
|
||||||
plain: bool = False,
|
plain: bool = False,
|
||||||
wait_for_idle_ms: int | None = None,
|
wait_for_idle_ms: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""Read merged PTY output from a workspace shell."""
|
||||||
return self.read_shell(
|
return self.read_shell(
|
||||||
workspace_id,
|
workspace_id,
|
||||||
shell_id,
|
shell_id,
|
||||||
|
|
@ -1203,19 +927,14 @@ class Pyro:
|
||||||
|
|
||||||
if _enabled("shell_write"):
|
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(
|
async def shell_write(
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
shell_id: str,
|
shell_id: str,
|
||||||
input: str,
|
input: str,
|
||||||
append_newline: bool = True,
|
append_newline: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""Write text input to a persistent workspace shell."""
|
||||||
return self.write_shell(
|
return self.write_shell(
|
||||||
workspace_id,
|
workspace_id,
|
||||||
shell_id,
|
shell_id,
|
||||||
|
|
@ -1225,18 +944,13 @@ class Pyro:
|
||||||
|
|
||||||
if _enabled("shell_signal"):
|
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(
|
async def shell_signal(
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
shell_id: str,
|
shell_id: str,
|
||||||
signal_name: str = "INT",
|
signal_name: str = "INT",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""Send a signal to the shell process group."""
|
||||||
return self.signal_shell(
|
return self.signal_shell(
|
||||||
workspace_id,
|
workspace_id,
|
||||||
shell_id,
|
shell_id,
|
||||||
|
|
@ -1245,68 +959,14 @@ class Pyro:
|
||||||
|
|
||||||
if _enabled("shell_close"):
|
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]:
|
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)
|
return self.close_shell(workspace_id, shell_id)
|
||||||
|
|
||||||
if _enabled("service_start"):
|
if _enabled("service_start"):
|
||||||
if normalized_mode == "cold-start":
|
|
||||||
|
|
||||||
@server.tool(
|
@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(
|
async def service_start(
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
service_name: str,
|
service_name: str,
|
||||||
|
|
@ -1321,6 +981,7 @@ class Pyro:
|
||||||
secret_env: dict[str, str] | None = None,
|
secret_env: dict[str, str] | None = None,
|
||||||
published_ports: list[dict[str, int | None]] | None = None,
|
published_ports: list[dict[str, int | None]] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""Start a named long-running service inside a workspace."""
|
||||||
readiness: dict[str, Any] | None = None
|
readiness: dict[str, Any] | None = None
|
||||||
if ready_file is not None:
|
if ready_file is not None:
|
||||||
readiness = {"type": "file", "path": ready_file}
|
readiness = {"type": "file", "path": ready_file}
|
||||||
|
|
@ -1344,43 +1005,28 @@ class Pyro:
|
||||||
|
|
||||||
if _enabled("service_list"):
|
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]:
|
async def service_list(workspace_id: str) -> dict[str, Any]:
|
||||||
|
"""List named services in one workspace."""
|
||||||
return self.list_services(workspace_id)
|
return self.list_services(workspace_id)
|
||||||
|
|
||||||
if _enabled("service_status"):
|
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]:
|
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)
|
return self.status_service(workspace_id, service_name)
|
||||||
|
|
||||||
if _enabled("service_logs"):
|
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(
|
async def service_logs(
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
service_name: str,
|
service_name: str,
|
||||||
tail_lines: int = 200,
|
tail_lines: int = 200,
|
||||||
all: bool = False,
|
all: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""Read persisted stdout/stderr for one workspace service."""
|
||||||
return self.logs_service(
|
return self.logs_service(
|
||||||
workspace_id,
|
workspace_id,
|
||||||
service_name,
|
service_name,
|
||||||
|
|
@ -1390,14 +1036,9 @@ class Pyro:
|
||||||
|
|
||||||
if _enabled("service_stop"):
|
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]:
|
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)
|
return self.stop_service(workspace_id, service_name)
|
||||||
|
|
||||||
if _enabled("workspace_delete"):
|
if _enabled("workspace_delete"):
|
||||||
|
|
|
||||||
|
|
@ -8,21 +8,12 @@ import shlex
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from textwrap import dedent
|
from textwrap import dedent
|
||||||
from typing import Any, cast
|
from typing import Any
|
||||||
|
|
||||||
from pyro_mcp import __version__
|
from pyro_mcp import __version__
|
||||||
from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode
|
from pyro_mcp.api import Pyro
|
||||||
from pyro_mcp.contract import PUBLIC_MCP_MODES, PUBLIC_MCP_PROFILES
|
from pyro_mcp.contract import PUBLIC_MCP_PROFILES
|
||||||
from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT
|
|
||||||
from pyro_mcp.demo import run_demo
|
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.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.runtime import DEFAULT_PLATFORM, doctor_report
|
||||||
from pyro_mcp.vm_environments import DEFAULT_CATALOG_VERSION
|
from pyro_mcp.vm_environments import DEFAULT_CATALOG_VERSION
|
||||||
|
|
@ -155,7 +146,6 @@ def _print_doctor_human(payload: dict[str, Any]) -> None:
|
||||||
)
|
)
|
||||||
runtime = payload.get("runtime")
|
runtime = payload.get("runtime")
|
||||||
if isinstance(runtime, dict):
|
if isinstance(runtime, dict):
|
||||||
print(f"Catalog version: {str(runtime.get('catalog_version', 'unknown'))}")
|
|
||||||
print(f"Environment cache: {str(runtime.get('cache_dir', 'unknown'))}")
|
print(f"Environment cache: {str(runtime.get('cache_dir', 'unknown'))}")
|
||||||
capabilities = runtime.get("capabilities")
|
capabilities = runtime.get("capabilities")
|
||||||
if isinstance(capabilities, dict):
|
if isinstance(capabilities, dict):
|
||||||
|
|
@ -173,108 +163,12 @@ def _print_doctor_human(payload: dict[str, Any]) -> None:
|
||||||
f"tun={'yes' if bool(networking.get('tun_available')) else 'no'} "
|
f"tun={'yes' if bool(networking.get('tun_available')) else 'no'} "
|
||||||
f"ip_forward={'yes' if bool(networking.get('ip_forward_enabled')) 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:
|
if isinstance(issues, list) and issues:
|
||||||
print("Issues:")
|
print("Issues:")
|
||||||
for issue in issues:
|
for issue in issues:
|
||||||
print(f"- {issue}")
|
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:
|
def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> None:
|
||||||
print(f"{action} ID: {str(payload.get('workspace_id', 'unknown'))}")
|
print(f"{action} ID: {str(payload.get('workspace_id', 'unknown'))}")
|
||||||
name = payload.get("name")
|
name = payload.get("name")
|
||||||
|
|
@ -297,16 +191,7 @@ def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> N
|
||||||
if isinstance(workspace_seed, dict):
|
if isinstance(workspace_seed, dict):
|
||||||
mode = str(workspace_seed.get("mode", "empty"))
|
mode = str(workspace_seed.get("mode", "empty"))
|
||||||
seed_path = workspace_seed.get("seed_path")
|
seed_path = workspace_seed.get("seed_path")
|
||||||
origin_kind = workspace_seed.get("origin_kind")
|
if isinstance(seed_path, str) and seed_path != "":
|
||||||
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}")
|
print(f"Workspace seed: {mode} from {seed_path}")
|
||||||
else:
|
else:
|
||||||
print(f"Workspace seed: {mode}")
|
print(f"Workspace seed: {mode}")
|
||||||
|
|
@ -592,147 +477,6 @@ def _print_workspace_logs_human(payload: dict[str, Any]) -> None:
|
||||||
print(stderr, end="" if stderr.endswith("\n") else "\n", file=sys.stderr)
|
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:
|
def _print_workspace_snapshot_human(payload: dict[str, Any], *, prefix: str) -> None:
|
||||||
snapshot = payload.get("snapshot")
|
snapshot = payload.get("snapshot")
|
||||||
if not isinstance(snapshot, dict):
|
if not isinstance(snapshot, dict):
|
||||||
|
|
@ -892,73 +636,24 @@ class _HelpFormatter(
|
||||||
return help_string
|
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:
|
def _build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description=(
|
description=(
|
||||||
"Validate the host and serve disposable MCP workspaces for chat-based "
|
"Run stable one-shot and persistent workspace workflows on supported "
|
||||||
"coding agents on supported Linux x86_64 KVM hosts."
|
"Linux x86_64 KVM hosts."
|
||||||
),
|
),
|
||||||
epilog=dedent(
|
epilog=dedent(
|
||||||
"""
|
"""
|
||||||
Suggested zero-to-hero path:
|
Suggested first run:
|
||||||
pyro doctor
|
pyro doctor
|
||||||
pyro prepare debian:12
|
pyro env list
|
||||||
|
pyro env pull debian:12
|
||||||
pyro run debian:12 -- git --version
|
pyro run debian:12 -- git --version
|
||||||
pyro host connect claude-code
|
|
||||||
|
|
||||||
Connect a chat host after that:
|
Continue into the stable workspace path 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 create debian:12 --seed-path ./repo --id-only
|
||||||
pyro workspace sync push WORKSPACE_ID ./changes
|
pyro workspace sync push WORKSPACE_ID ./changes
|
||||||
pyro workspace exec WORKSPACE_ID -- cat note.txt
|
pyro workspace exec WORKSPACE_ID -- cat note.txt
|
||||||
pyro workspace summary WORKSPACE_ID
|
|
||||||
pyro workspace diff WORKSPACE_ID
|
pyro workspace diff WORKSPACE_ID
|
||||||
pyro workspace snapshot create WORKSPACE_ID checkpoint
|
pyro workspace snapshot create WORKSPACE_ID checkpoint
|
||||||
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
|
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
|
||||||
|
|
@ -966,6 +661,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
|
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
|
||||||
sh -lc 'touch .ready && while true; do sleep 60; done'
|
sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||||
pyro workspace export WORKSPACE_ID note.txt --output ./note.txt
|
pyro workspace export WORKSPACE_ID note.txt --output ./note.txt
|
||||||
|
|
||||||
|
Use `pyro mcp serve` only after the CLI validation path works.
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
formatter_class=_HelpFormatter,
|
formatter_class=_HelpFormatter,
|
||||||
|
|
@ -973,51 +670,6 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
||||||
subparsers = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
|
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_parser = subparsers.add_parser(
|
||||||
"env",
|
"env",
|
||||||
help="Inspect and manage curated environments.",
|
help="Inspect and manage curated environments.",
|
||||||
|
|
@ -1103,151 +755,18 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
help="Print structured JSON instead of human-readable output.",
|
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_parser = subparsers.add_parser(
|
||||||
"mcp",
|
"mcp",
|
||||||
help="Run the MCP server.",
|
help="Run the MCP server.",
|
||||||
description=(
|
description=(
|
||||||
"Run the MCP server after you have already validated the host and "
|
"Run the MCP server after you have already validated the host and "
|
||||||
"guest execution with `pyro doctor` and `pyro run`. This is the "
|
"guest execution with `pyro doctor` and `pyro run`. Bare `pyro "
|
||||||
"main product path for Claude Code, Codex, and OpenCode."
|
"mcp serve` now starts the recommended `workspace-core` profile."
|
||||||
),
|
),
|
||||||
epilog=dedent(
|
epilog=dedent(
|
||||||
"""
|
"""
|
||||||
Examples:
|
Examples:
|
||||||
pyro mcp serve
|
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 vm-run
|
||||||
pyro mcp serve --profile workspace-full
|
pyro mcp serve --profile workspace-full
|
||||||
"""
|
"""
|
||||||
|
|
@ -1260,83 +779,36 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
help="Run the MCP server over stdio.",
|
help="Run the MCP server over stdio.",
|
||||||
description=(
|
description=(
|
||||||
"Expose pyro tools over stdio for an MCP client. Bare `pyro mcp "
|
"Expose pyro tools over stdio for an MCP client. Bare `pyro mcp "
|
||||||
"serve` starts the generic `workspace-core` path. Use `--mode` to "
|
"serve` now starts `workspace-core`, the recommended first profile "
|
||||||
"start from an opinionated use-case flow, or `--profile` to choose "
|
"for most chat hosts."
|
||||||
"a generic profile directly. When launched from inside a Git "
|
|
||||||
"checkout, it also seeds the first workspace from that repo by default."
|
|
||||||
),
|
),
|
||||||
epilog=dedent(
|
epilog=dedent(
|
||||||
"""
|
"""
|
||||||
Generic default path:
|
Default and recommended first start:
|
||||||
pyro mcp serve
|
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:
|
Profiles:
|
||||||
workspace-core: default for normal persistent chat editing and the
|
workspace-core: default for normal persistent chat editing
|
||||||
recommended first profile for most chat hosts
|
|
||||||
vm-run: smallest one-shot-only surface
|
vm-run: smallest one-shot-only surface
|
||||||
workspace-full: larger opt-in surface for shells, services,
|
workspace-full: advanced 4.x opt-in surface for shells, services,
|
||||||
snapshots, secrets, network policy, and disk tools
|
snapshots, secrets, network policy, and disk tools
|
||||||
|
|
||||||
Project-aware startup:
|
Use --profile workspace-full only when the host truly needs the full
|
||||||
- bare `pyro mcp serve` auto-detects the nearest Git checkout
|
advanced workspace surface.
|
||||||
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,
|
formatter_class=_HelpFormatter,
|
||||||
)
|
)
|
||||||
mcp_profile_group = mcp_serve_parser.add_mutually_exclusive_group()
|
mcp_serve_parser.add_argument(
|
||||||
mcp_profile_group.add_argument(
|
|
||||||
"--profile",
|
"--profile",
|
||||||
choices=PUBLIC_MCP_PROFILES,
|
choices=PUBLIC_MCP_PROFILES,
|
||||||
default="workspace-core",
|
default="workspace-core",
|
||||||
help=(
|
help=(
|
||||||
"Expose one generic model-facing tool profile instead of a named mode. "
|
"Expose only one model-facing tool profile. `workspace-core` is "
|
||||||
"`workspace-core` is the generic default and `workspace-full` is the "
|
"the default and recommended first profile for most chat hosts; "
|
||||||
"larger opt-in profile."
|
"`workspace-full` is the explicit advanced opt-in surface."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
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_parser = subparsers.add_parser(
|
||||||
"run",
|
"run",
|
||||||
|
|
@ -1393,7 +865,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
"--allow-host-compat",
|
"--allow-host-compat",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help=(
|
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(
|
run_parser.add_argument(
|
||||||
|
|
@ -1415,7 +888,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
"workspace",
|
"workspace",
|
||||||
help="Manage persistent workspaces.",
|
help="Manage persistent workspaces.",
|
||||||
description=(
|
description=(
|
||||||
"Use the workspace model when you need one sandbox to stay alive "
|
"Use the stable workspace contract when you need one sandbox to stay alive "
|
||||||
"across repeated exec, shell, service, diff, export, snapshot, and reset calls."
|
"across repeated exec, shell, service, diff, export, snapshot, and reset calls."
|
||||||
),
|
),
|
||||||
epilog=dedent(
|
epilog=dedent(
|
||||||
|
|
@ -1435,7 +908,6 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
pyro workspace start WORKSPACE_ID
|
pyro workspace start WORKSPACE_ID
|
||||||
pyro workspace snapshot create WORKSPACE_ID checkpoint
|
pyro workspace snapshot create WORKSPACE_ID checkpoint
|
||||||
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
|
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
|
||||||
pyro workspace summary WORKSPACE_ID
|
|
||||||
pyro workspace diff WORKSPACE_ID
|
pyro workspace diff WORKSPACE_ID
|
||||||
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
|
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
|
||||||
pyro workspace shell open WORKSPACE_ID --id-only
|
pyro workspace shell open WORKSPACE_ID --id-only
|
||||||
|
|
@ -1514,7 +986,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
"--allow-host-compat",
|
"--allow-host-compat",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help=(
|
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(
|
workspace_create_parser.add_argument(
|
||||||
|
|
@ -1564,7 +1037,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
"exec",
|
"exec",
|
||||||
help="Run one command inside an existing workspace.",
|
help="Run one command inside an existing workspace.",
|
||||||
description=(
|
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(
|
epilog=dedent(
|
||||||
"""
|
"""
|
||||||
|
|
@ -1800,7 +1274,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
"created automatically."
|
"created automatically."
|
||||||
),
|
),
|
||||||
epilog=(
|
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,
|
formatter_class=_HelpFormatter,
|
||||||
)
|
)
|
||||||
|
|
@ -1992,7 +1467,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
"start",
|
"start",
|
||||||
help="Start one stopped workspace without resetting it.",
|
help="Start one stopped workspace without resetting it.",
|
||||||
description=(
|
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",
|
epilog="Example:\n pyro workspace start WORKSPACE_ID",
|
||||||
formatter_class=_HelpFormatter,
|
formatter_class=_HelpFormatter,
|
||||||
|
|
@ -2118,7 +1594,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
"shell",
|
"shell",
|
||||||
help="Open and manage persistent interactive shells.",
|
help="Open and manage persistent interactive shells.",
|
||||||
description=(
|
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(
|
epilog=dedent(
|
||||||
"""
|
"""
|
||||||
|
|
@ -2341,7 +1818,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
pyro workspace service stop WORKSPACE_ID app
|
pyro workspace service stop WORKSPACE_ID app
|
||||||
|
|
||||||
Use `--ready-file` by default in the curated Debian environments. `--ready-command`
|
Use `--ready-file` by default in the curated Debian environments. `--ready-command`
|
||||||
remains available when the workflow needs a custom readiness check.
|
remains available as an escape hatch.
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
formatter_class=_HelpFormatter,
|
formatter_class=_HelpFormatter,
|
||||||
|
|
@ -2570,38 +2047,12 @@ while true; do sleep 60; done'
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Print structured JSON instead of human-readable output.",
|
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(
|
workspace_logs_parser = workspace_subparsers.add_parser(
|
||||||
"logs",
|
"logs",
|
||||||
help="Show command history for one workspace.",
|
help="Show command history for one workspace.",
|
||||||
description=(
|
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",
|
epilog="Example:\n pyro workspace logs WORKSPACE_ID",
|
||||||
formatter_class=_HelpFormatter,
|
formatter_class=_HelpFormatter,
|
||||||
|
|
@ -2637,16 +2088,11 @@ while true; do sleep 60; done'
|
||||||
doctor_parser = subparsers.add_parser(
|
doctor_parser = subparsers.add_parser(
|
||||||
"doctor",
|
"doctor",
|
||||||
help="Inspect runtime and host diagnostics.",
|
help="Inspect runtime and host diagnostics.",
|
||||||
description=(
|
description="Check host prerequisites and embedded runtime health before your first run.",
|
||||||
"Check host prerequisites and embedded runtime health, plus "
|
|
||||||
"daily-loop warmth before your first run or before reconnecting a "
|
|
||||||
"chat host."
|
|
||||||
),
|
|
||||||
epilog=dedent(
|
epilog=dedent(
|
||||||
"""
|
"""
|
||||||
Examples:
|
Examples:
|
||||||
pyro doctor
|
pyro doctor
|
||||||
pyro doctor --environment debian:12
|
|
||||||
pyro doctor --json
|
pyro doctor --json
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
|
@ -2657,14 +2103,6 @@ while true; do sleep 60; done'
|
||||||
default=DEFAULT_PLATFORM,
|
default=DEFAULT_PLATFORM,
|
||||||
help="Runtime platform to inspect.",
|
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(
|
doctor_parser.add_argument(
|
||||||
"--json",
|
"--json",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
|
@ -2827,24 +2265,6 @@ def _parse_workspace_publish_options(values: list[str]) -> list[dict[str, int |
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
args = _build_parser().parse_args()
|
args = _build_parser().parse_args()
|
||||||
pyro = Pyro()
|
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.command == "env":
|
||||||
if args.env_command == "list":
|
if args.env_command == "list":
|
||||||
list_payload: dict[str, Any] = {
|
list_payload: dict[str, Any] = {
|
||||||
|
|
@ -2880,66 +2300,8 @@ def main() -> None:
|
||||||
else:
|
else:
|
||||||
_print_prune_human(prune_payload)
|
_print_prune_human(prune_payload)
|
||||||
return
|
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":
|
if args.command == "mcp":
|
||||||
pyro.create_server(
|
pyro.create_server(profile=args.profile).run(transport="stdio")
|
||||||
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
|
return
|
||||||
if args.command == "run":
|
if args.command == "run":
|
||||||
command = _require_command(args.command_args)
|
command = _require_command(args.command_args)
|
||||||
|
|
@ -2992,7 +2354,10 @@ def main() -> None:
|
||||||
if args.command == "workspace":
|
if args.command == "workspace":
|
||||||
if args.workspace_command == "create":
|
if args.workspace_command == "create":
|
||||||
secrets = [
|
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)
|
_parse_workspace_secret_file_option(value)
|
||||||
for value in getattr(args, "secret_file", [])
|
for value in getattr(args, "secret_file", [])
|
||||||
|
|
@ -3027,7 +2392,9 @@ def main() -> None:
|
||||||
return
|
return
|
||||||
if args.workspace_command == "update":
|
if args.workspace_command == "update":
|
||||||
labels = _parse_workspace_label_options(getattr(args, "label", []))
|
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:
|
try:
|
||||||
payload = pyro.update_workspace(
|
payload = pyro.update_workspace(
|
||||||
args.workspace_id,
|
args.workspace_id,
|
||||||
|
|
@ -3074,8 +2441,7 @@ def main() -> None:
|
||||||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||||
raise SystemExit(1) from exc
|
raise SystemExit(1) from exc
|
||||||
_print_workspace_exec_human(payload)
|
_print_workspace_exec_human(payload)
|
||||||
exit_code_raw = payload.get("exit_code", 1)
|
exit_code = int(payload.get("exit_code", 1))
|
||||||
exit_code = exit_code_raw if isinstance(exit_code_raw, int) else 1
|
|
||||||
if exit_code != 0:
|
if exit_code != 0:
|
||||||
raise SystemExit(exit_code)
|
raise SystemExit(exit_code)
|
||||||
return
|
return
|
||||||
|
|
@ -3611,13 +2977,6 @@ def main() -> None:
|
||||||
else:
|
else:
|
||||||
_print_workspace_summary_human(payload, action="Workspace")
|
_print_workspace_summary_human(payload, action="Workspace")
|
||||||
return
|
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":
|
if args.workspace_command == "logs":
|
||||||
payload = pyro.logs_workspace(args.workspace_id)
|
payload = pyro.logs_workspace(args.workspace_id)
|
||||||
if bool(args.json):
|
if bool(args.json):
|
||||||
|
|
@ -3633,17 +2992,7 @@ def main() -> None:
|
||||||
print(f"Deleted workspace: {str(payload.get('workspace_id', 'unknown'))}")
|
print(f"Deleted workspace: {str(payload.get('workspace_id', 'unknown'))}")
|
||||||
return
|
return
|
||||||
if args.command == "doctor":
|
if args.command == "doctor":
|
||||||
try:
|
payload = doctor_report(platform=args.platform)
|
||||||
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):
|
if bool(args.json):
|
||||||
_print_json(payload)
|
_print_json(payload)
|
||||||
else:
|
else:
|
||||||
|
|
@ -3674,7 +3023,3 @@ def main() -> None:
|
||||||
return
|
return
|
||||||
result = run_demo(network=bool(args.network))
|
result = run_demo(network=bool(args.network))
|
||||||
_print_json(result)
|
_print_json(result)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
|
||||||
|
|
@ -2,34 +2,11 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "host", "mcp", "prepare", "run", "workspace")
|
PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "mcp", "run", "workspace")
|
||||||
PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",)
|
PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",)
|
||||||
PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune")
|
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_SUBCOMMANDS = ("serve",)
|
||||||
PUBLIC_CLI_MCP_SERVE_FLAGS = (
|
PUBLIC_CLI_MCP_SERVE_FLAGS = ("--profile",)
|
||||||
"--mode",
|
|
||||||
"--profile",
|
|
||||||
"--project-path",
|
|
||||||
"--repo-url",
|
|
||||||
"--repo-ref",
|
|
||||||
"--no-project-source",
|
|
||||||
)
|
|
||||||
PUBLIC_CLI_PREPARE_FLAGS = ("--network", "--force", "--json")
|
|
||||||
PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = (
|
PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = (
|
||||||
"create",
|
"create",
|
||||||
"delete",
|
"delete",
|
||||||
|
|
@ -48,7 +25,6 @@ PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = (
|
||||||
"start",
|
"start",
|
||||||
"status",
|
"status",
|
||||||
"stop",
|
"stop",
|
||||||
"summary",
|
|
||||||
"sync",
|
"sync",
|
||||||
"update",
|
"update",
|
||||||
)
|
)
|
||||||
|
|
@ -125,7 +101,6 @@ PUBLIC_CLI_WORKSPACE_SNAPSHOT_LIST_FLAGS = ("--json",)
|
||||||
PUBLIC_CLI_WORKSPACE_START_FLAGS = ("--json",)
|
PUBLIC_CLI_WORKSPACE_START_FLAGS = ("--json",)
|
||||||
PUBLIC_CLI_WORKSPACE_STATUS_FLAGS = ("--json",)
|
PUBLIC_CLI_WORKSPACE_STATUS_FLAGS = ("--json",)
|
||||||
PUBLIC_CLI_WORKSPACE_STOP_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_SYNC_PUSH_FLAGS = ("--dest", "--json")
|
||||||
PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS = (
|
PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS = (
|
||||||
"--name",
|
"--name",
|
||||||
|
|
@ -144,7 +119,6 @@ PUBLIC_CLI_RUN_FLAGS = (
|
||||||
"--json",
|
"--json",
|
||||||
)
|
)
|
||||||
PUBLIC_MCP_PROFILES = ("vm-run", "workspace-core", "workspace-full")
|
PUBLIC_MCP_PROFILES = ("vm-run", "workspace-core", "workspace-full")
|
||||||
PUBLIC_MCP_MODES = ("repro-fix", "inspect", "cold-start", "review-eval")
|
|
||||||
|
|
||||||
PUBLIC_SDK_METHODS = (
|
PUBLIC_SDK_METHODS = (
|
||||||
"apply_workspace_patch",
|
"apply_workspace_patch",
|
||||||
|
|
@ -191,7 +165,6 @@ PUBLIC_SDK_METHODS = (
|
||||||
"stop_service",
|
"stop_service",
|
||||||
"stop_vm",
|
"stop_vm",
|
||||||
"stop_workspace",
|
"stop_workspace",
|
||||||
"summarize_workspace",
|
|
||||||
"update_workspace",
|
"update_workspace",
|
||||||
"write_shell",
|
"write_shell",
|
||||||
"write_workspace_file",
|
"write_workspace_file",
|
||||||
|
|
@ -236,7 +209,6 @@ PUBLIC_MCP_TOOLS = (
|
||||||
"workspace_logs",
|
"workspace_logs",
|
||||||
"workspace_patch_apply",
|
"workspace_patch_apply",
|
||||||
"workspace_reset",
|
"workspace_reset",
|
||||||
"workspace_summary",
|
|
||||||
"workspace_start",
|
"workspace_start",
|
||||||
"workspace_status",
|
"workspace_status",
|
||||||
"workspace_stop",
|
"workspace_stop",
|
||||||
|
|
@ -258,82 +230,8 @@ PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS = (
|
||||||
"workspace_logs",
|
"workspace_logs",
|
||||||
"workspace_patch_apply",
|
"workspace_patch_apply",
|
||||||
"workspace_reset",
|
"workspace_reset",
|
||||||
"workspace_summary",
|
|
||||||
"workspace_status",
|
"workspace_status",
|
||||||
"workspace_sync_push",
|
"workspace_sync_push",
|
||||||
"workspace_update",
|
"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
|
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS = PUBLIC_MCP_TOOLS
|
||||||
|
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
"""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),
|
|
||||||
}
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
"""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()
|
|
||||||
|
|
@ -5,18 +5,16 @@ from __future__ import annotations
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT
|
|
||||||
from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report
|
from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report
|
||||||
|
|
||||||
|
|
||||||
def _build_parser() -> argparse.ArgumentParser:
|
def _build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description="Inspect bundled runtime health for pyro-mcp.")
|
parser = argparse.ArgumentParser(description="Inspect bundled runtime health for pyro-mcp.")
|
||||||
parser.add_argument("--platform", default=DEFAULT_PLATFORM)
|
parser.add_argument("--platform", default=DEFAULT_PLATFORM)
|
||||||
parser.add_argument("--environment", default=DEFAULT_PREPARE_ENVIRONMENT)
|
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
args = _build_parser().parse_args()
|
args = _build_parser().parse_args()
|
||||||
report = doctor_report(platform=args.platform, environment=args.environment)
|
report = doctor_report(platform=args.platform)
|
||||||
print(json.dumps(report, indent=2, sort_keys=True))
|
print(json.dumps(report, indent=2, sort_keys=True))
|
||||||
|
|
|
||||||
|
|
@ -1,370 +0,0 @@
|
||||||
"""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),
|
|
||||||
]
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
"""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}"
|
|
||||||
|
|
@ -11,13 +11,6 @@ from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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
|
from pyro_mcp.vm_network import TapNetworkManager
|
||||||
|
|
||||||
DEFAULT_PLATFORM = "linux-x86_64"
|
DEFAULT_PLATFORM = "linux-x86_64"
|
||||||
|
|
@ -207,11 +200,7 @@ def runtime_capabilities(paths: RuntimePaths) -> RuntimeCapabilities:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def doctor_report(
|
def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]:
|
||||||
*,
|
|
||||||
platform: str = DEFAULT_PLATFORM,
|
|
||||||
environment: str = DEFAULT_PREPARE_ENVIRONMENT,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Build a runtime diagnostics report."""
|
"""Build a runtime diagnostics report."""
|
||||||
report: dict[str, Any] = {
|
report: dict[str, Any] = {
|
||||||
"platform": platform,
|
"platform": platform,
|
||||||
|
|
@ -269,36 +258,6 @@ def doctor_report(
|
||||||
"cache_dir": str(environment_store.cache_dir),
|
"cache_dir": str(environment_store.cache_dir),
|
||||||
"environments": environment_store.list_environments(),
|
"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"]:
|
if not report["kvm"]["exists"]:
|
||||||
report["issues"] = ["/dev/kvm is not available on this host"]
|
report["issues"] = ["/dev/kvm is not available on this host"]
|
||||||
return report
|
return report
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,9 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode
|
from pyro_mcp.api import McpToolProfile, Pyro
|
||||||
from pyro_mcp.vm_manager import VmManager
|
from pyro_mcp.vm_manager import VmManager
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -14,29 +12,14 @@ def create_server(
|
||||||
manager: VmManager | None = None,
|
manager: VmManager | None = None,
|
||||||
*,
|
*,
|
||||||
profile: McpToolProfile = "workspace-core",
|
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:
|
) -> FastMCP:
|
||||||
"""Create and return a configured MCP server instance.
|
"""Create and return a configured MCP server instance.
|
||||||
|
|
||||||
Bare server creation uses the generic `workspace-core` path in 4.x. Use
|
`workspace-core` is the default stable chat-host profile 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
|
`profile="workspace-full"` only when the host truly needs the full
|
||||||
advanced workspace surface. By default, the server auto-detects the
|
advanced workspace surface.
|
||||||
nearest Git worktree root from its current working directory for
|
|
||||||
project-aware `workspace_create` calls.
|
|
||||||
"""
|
"""
|
||||||
return Pyro(manager=manager).create_server(
|
return Pyro(manager=manager).create_server(profile=profile)
|
||||||
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:
|
def main() -> None:
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ from typing import Any
|
||||||
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
|
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
|
||||||
|
|
||||||
DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
|
DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
|
||||||
DEFAULT_CATALOG_VERSION = "4.5.0"
|
DEFAULT_CATALOG_VERSION = "4.0.0"
|
||||||
OCI_MANIFEST_ACCEPT = ", ".join(
|
OCI_MANIFEST_ACCEPT = ", ".join(
|
||||||
(
|
(
|
||||||
"application/vnd.oci.image.index.v1+json",
|
"application/vnd.oci.image.index.v1+json",
|
||||||
|
|
@ -48,7 +48,7 @@ class VmEnvironment:
|
||||||
oci_repository: str | None = None
|
oci_repository: str | None = None
|
||||||
oci_reference: str | None = None
|
oci_reference: str | None = None
|
||||||
source_digest: str | None = None
|
source_digest: str | None = None
|
||||||
compatibility: str = ">=4.5.0,<5.0.0"
|
compatibility: str = ">=4.0.0,<5.0.0"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
|
||||||
|
|
@ -24,15 +24,6 @@ from dataclasses import dataclass, field
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
from typing import Any, Literal, cast
|
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 (
|
from pyro_mcp.runtime import (
|
||||||
RuntimeCapabilities,
|
RuntimeCapabilities,
|
||||||
RuntimePaths,
|
RuntimePaths,
|
||||||
|
|
@ -88,13 +79,12 @@ DEFAULT_TIMEOUT_SECONDS = 30
|
||||||
DEFAULT_TTL_SECONDS = 600
|
DEFAULT_TTL_SECONDS = 600
|
||||||
DEFAULT_ALLOW_HOST_COMPAT = False
|
DEFAULT_ALLOW_HOST_COMPAT = False
|
||||||
|
|
||||||
WORKSPACE_LAYOUT_VERSION = 9
|
WORKSPACE_LAYOUT_VERSION = 8
|
||||||
WORKSPACE_BASELINE_DIRNAME = "baseline"
|
WORKSPACE_BASELINE_DIRNAME = "baseline"
|
||||||
WORKSPACE_BASELINE_ARCHIVE_NAME = "workspace.tar"
|
WORKSPACE_BASELINE_ARCHIVE_NAME = "workspace.tar"
|
||||||
WORKSPACE_SNAPSHOTS_DIRNAME = "snapshots"
|
WORKSPACE_SNAPSHOTS_DIRNAME = "snapshots"
|
||||||
WORKSPACE_DIRNAME = "workspace"
|
WORKSPACE_DIRNAME = "workspace"
|
||||||
WORKSPACE_COMMANDS_DIRNAME = "commands"
|
WORKSPACE_COMMANDS_DIRNAME = "commands"
|
||||||
WORKSPACE_REVIEW_DIRNAME = "review"
|
|
||||||
WORKSPACE_SHELLS_DIRNAME = "shells"
|
WORKSPACE_SHELLS_DIRNAME = "shells"
|
||||||
WORKSPACE_SERVICES_DIRNAME = "services"
|
WORKSPACE_SERVICES_DIRNAME = "services"
|
||||||
WORKSPACE_SECRETS_DIRNAME = "secrets"
|
WORKSPACE_SECRETS_DIRNAME = "secrets"
|
||||||
|
|
@ -126,18 +116,7 @@ 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}$")
|
WORKSPACE_LABEL_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||||
|
|
||||||
WorkspaceSeedMode = Literal["empty", "directory", "tar_archive"]
|
WorkspaceSeedMode = Literal["empty", "directory", "tar_archive"]
|
||||||
WorkspaceSeedOriginKind = Literal["empty", "manual_seed_path", "project_path", "repo_url"]
|
|
||||||
WorkspaceArtifactType = Literal["file", "directory", "symlink"]
|
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"]
|
WorkspaceServiceReadinessType = Literal["file", "tcp", "http", "command"]
|
||||||
WorkspaceSnapshotKind = Literal["baseline", "named"]
|
WorkspaceSnapshotKind = Literal["baseline", "named"]
|
||||||
WorkspaceSecretSourceKind = Literal["literal", "file"]
|
WorkspaceSecretSourceKind = Literal["literal", "file"]
|
||||||
|
|
@ -297,7 +276,9 @@ class WorkspaceRecord:
|
||||||
network=_deserialize_network(payload.get("network")),
|
network=_deserialize_network(payload.get("network")),
|
||||||
name=_normalize_workspace_name(_optional_str(payload.get("name")), allow_none=True),
|
name=_normalize_workspace_name(_optional_str(payload.get("name")), allow_none=True),
|
||||||
labels=_normalize_workspace_labels(payload.get("labels")),
|
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)),
|
command_count=int(payload.get("command_count", 0)),
|
||||||
last_command=_optional_dict(payload.get("last_command")),
|
last_command=_optional_dict(payload.get("last_command")),
|
||||||
workspace_seed=_workspace_seed_dict(payload.get("workspace_seed")),
|
workspace_seed=_workspace_seed_dict(payload.get("workspace_seed")),
|
||||||
|
|
@ -335,35 +316,6 @@ 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
|
@dataclass
|
||||||
class WorkspaceSnapshotRecord:
|
class WorkspaceSnapshotRecord:
|
||||||
"""Persistent snapshot metadata stored on disk per workspace."""
|
"""Persistent snapshot metadata stored on disk per workspace."""
|
||||||
|
|
@ -551,7 +503,9 @@ class WorkspacePublishedPortRecord:
|
||||||
host=str(payload.get("host", DEFAULT_PUBLISHED_PORT_HOST)),
|
host=str(payload.get("host", DEFAULT_PUBLISHED_PORT_HOST)),
|
||||||
protocol=str(payload.get("protocol", "tcp")),
|
protocol=str(payload.get("protocol", "tcp")),
|
||||||
proxy_pid=(
|
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))
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -570,8 +524,6 @@ class PreparedWorkspaceSeed:
|
||||||
|
|
||||||
mode: WorkspaceSeedMode
|
mode: WorkspaceSeedMode
|
||||||
source_path: str | None
|
source_path: str | None
|
||||||
origin_kind: WorkspaceSeedOriginKind = "empty"
|
|
||||||
origin_ref: str | None = None
|
|
||||||
archive_path: Path | None = None
|
archive_path: Path | None = None
|
||||||
entry_count: int = 0
|
entry_count: int = 0
|
||||||
bytes_written: int = 0
|
bytes_written: int = 0
|
||||||
|
|
@ -582,19 +534,14 @@ class PreparedWorkspaceSeed:
|
||||||
*,
|
*,
|
||||||
destination: str = WORKSPACE_GUEST_PATH,
|
destination: str = WORKSPACE_GUEST_PATH,
|
||||||
path_key: str = "seed_path",
|
path_key: str = "seed_path",
|
||||||
include_origin: bool = True,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
payload = {
|
return {
|
||||||
"mode": self.mode,
|
"mode": self.mode,
|
||||||
path_key: self.source_path,
|
path_key: self.source_path,
|
||||||
"destination": destination,
|
"destination": destination,
|
||||||
"entry_count": self.entry_count,
|
"entry_count": self.entry_count,
|
||||||
"bytes_written": self.bytes_written,
|
"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:
|
def cleanup(self) -> None:
|
||||||
if self.cleanup_dir is not None:
|
if self.cleanup_dir is not None:
|
||||||
|
|
@ -667,8 +614,6 @@ def _empty_workspace_seed_payload() -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"mode": "empty",
|
"mode": "empty",
|
||||||
"seed_path": None,
|
"seed_path": None,
|
||||||
"origin_kind": "empty",
|
|
||||||
"origin_ref": None,
|
|
||||||
"destination": WORKSPACE_GUEST_PATH,
|
"destination": WORKSPACE_GUEST_PATH,
|
||||||
"entry_count": 0,
|
"entry_count": 0,
|
||||||
"bytes_written": 0,
|
"bytes_written": 0,
|
||||||
|
|
@ -683,8 +628,6 @@ def _workspace_seed_dict(value: object) -> dict[str, Any]:
|
||||||
{
|
{
|
||||||
"mode": str(value.get("mode", payload["mode"])),
|
"mode": str(value.get("mode", payload["mode"])),
|
||||||
"seed_path": _optional_str(value.get("seed_path")),
|
"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"])),
|
"destination": str(value.get("destination", payload["destination"])),
|
||||||
"entry_count": int(value.get("entry_count", payload["entry_count"])),
|
"entry_count": int(value.get("entry_count", payload["entry_count"])),
|
||||||
"bytes_written": int(value.get("bytes_written", payload["bytes_written"])),
|
"bytes_written": int(value.get("bytes_written", payload["bytes_written"])),
|
||||||
|
|
@ -926,7 +869,9 @@ def _validate_workspace_file_read_max_bytes(max_bytes: int) -> int:
|
||||||
if max_bytes <= 0:
|
if max_bytes <= 0:
|
||||||
raise ValueError("max_bytes must be positive")
|
raise ValueError("max_bytes must be positive")
|
||||||
if max_bytes > WORKSPACE_FILE_MAX_BYTES:
|
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
|
return max_bytes
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -954,7 +899,9 @@ def _decode_workspace_patch_text(path: str, content_bytes: bytes) -> str:
|
||||||
try:
|
try:
|
||||||
return content_bytes.decode("utf-8")
|
return content_bytes.decode("utf-8")
|
||||||
except UnicodeDecodeError as exc:
|
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:
|
def _normalize_archive_member_name(name: str) -> PurePosixPath:
|
||||||
|
|
@ -1044,7 +991,9 @@ def _prepare_workspace_secrets(
|
||||||
has_value = "value" in item
|
has_value = "value" in item
|
||||||
has_file_path = "file_path" in item
|
has_file_path = "file_path" in item
|
||||||
if has_value == has_file_path:
|
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
|
source_kind: WorkspaceSecretSourceKind
|
||||||
if has_value:
|
if has_value:
|
||||||
value = _validate_workspace_secret_value(name, str(item["value"]))
|
value = _validate_workspace_secret_value(name, str(item["value"]))
|
||||||
|
|
@ -1524,7 +1473,9 @@ def _normalize_workspace_published_port_specs(
|
||||||
)
|
)
|
||||||
dedupe_key = (spec.host_port, spec.guest_port)
|
dedupe_key = (spec.host_port, spec.guest_port)
|
||||||
if dedupe_key in seen_guest_ports:
|
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)
|
seen_guest_ports.add(dedupe_key)
|
||||||
normalized.append(spec)
|
normalized.append(spec)
|
||||||
return normalized
|
return normalized
|
||||||
|
|
@ -1787,7 +1738,7 @@ def _start_local_service(
|
||||||
),
|
),
|
||||||
"status=$?",
|
"status=$?",
|
||||||
f"printf '%s' \"$status\" > {shlex.quote(str(status_path))}",
|
f"printf '%s' \"$status\" > {shlex.quote(str(status_path))}",
|
||||||
'exit "$status"',
|
"exit \"$status\"",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
+ "\n",
|
+ "\n",
|
||||||
|
|
@ -1970,7 +1921,9 @@ def _patch_rootfs_runtime_file(
|
||||||
) -> None:
|
) -> None:
|
||||||
debugfs_path = shutil.which("debugfs")
|
debugfs_path = shutil.which("debugfs")
|
||||||
if debugfs_path is None:
|
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:
|
with tempfile.TemporaryDirectory(prefix=f"pyro-{asset_label}-") as temp_dir:
|
||||||
staged_path = Path(temp_dir) / Path(destination_path).name
|
staged_path = Path(temp_dir) / Path(destination_path).name
|
||||||
shutil.copy2(source_path, staged_path)
|
shutil.copy2(source_path, staged_path)
|
||||||
|
|
@ -3629,152 +3582,6 @@ class VmManager:
|
||||||
def prune_environments(self) -> dict[str, object]:
|
def prune_environments(self) -> dict[str, object]:
|
||||||
return self._environment_store.prune_environments()
|
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(
|
def create_vm(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -3940,23 +3747,19 @@ class VmManager:
|
||||||
secrets: list[dict[str, str]] | None = None,
|
secrets: list[dict[str, str]] | None = None,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
labels: dict[str, str] | None = None,
|
labels: dict[str, str] | None = None,
|
||||||
_prepared_seed: PreparedWorkspaceSeed | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
self._validate_limits(vcpu_count=vcpu_count, mem_mib=mem_mib, ttl_seconds=ttl_seconds)
|
self._validate_limits(vcpu_count=vcpu_count, mem_mib=mem_mib, ttl_seconds=ttl_seconds)
|
||||||
get_environment(environment, runtime_paths=self._runtime_paths)
|
get_environment(environment, runtime_paths=self._runtime_paths)
|
||||||
normalized_network_policy = _normalize_workspace_network_policy(str(network_policy))
|
normalized_network_policy = _normalize_workspace_network_policy(str(network_policy))
|
||||||
normalized_name = None if name is None else _normalize_workspace_name(name)
|
normalized_name = None if name is None else _normalize_workspace_name(name)
|
||||||
normalized_labels = _normalize_workspace_labels(labels)
|
normalized_labels = _normalize_workspace_labels(labels)
|
||||||
if _prepared_seed is not None and seed_path is not None:
|
prepared_seed = self._prepare_workspace_seed(seed_path)
|
||||||
raise ValueError("_prepared_seed and seed_path are mutually exclusive")
|
|
||||||
prepared_seed = _prepared_seed or self._prepare_workspace_seed(seed_path)
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
workspace_id = uuid.uuid4().hex[:12]
|
workspace_id = uuid.uuid4().hex[:12]
|
||||||
workspace_dir = self._workspace_dir(workspace_id)
|
workspace_dir = self._workspace_dir(workspace_id)
|
||||||
runtime_dir = self._workspace_runtime_dir(workspace_id)
|
runtime_dir = self._workspace_runtime_dir(workspace_id)
|
||||||
host_workspace_dir = self._workspace_host_dir(workspace_id)
|
host_workspace_dir = self._workspace_host_dir(workspace_id)
|
||||||
commands_dir = self._workspace_commands_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)
|
shells_dir = self._workspace_shells_dir(workspace_id)
|
||||||
services_dir = self._workspace_services_dir(workspace_id)
|
services_dir = self._workspace_services_dir(workspace_id)
|
||||||
secrets_dir = self._workspace_secrets_dir(workspace_id)
|
secrets_dir = self._workspace_secrets_dir(workspace_id)
|
||||||
|
|
@ -3965,7 +3768,6 @@ class VmManager:
|
||||||
workspace_dir.mkdir(parents=True, exist_ok=False)
|
workspace_dir.mkdir(parents=True, exist_ok=False)
|
||||||
host_workspace_dir.mkdir(parents=True, exist_ok=True)
|
host_workspace_dir.mkdir(parents=True, exist_ok=True)
|
||||||
commands_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)
|
shells_dir.mkdir(parents=True, exist_ok=True)
|
||||||
services_dir.mkdir(parents=True, exist_ok=True)
|
services_dir.mkdir(parents=True, exist_ok=True)
|
||||||
secrets_dir.mkdir(parents=True, exist_ok=True)
|
secrets_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -4000,7 +3802,9 @@ class VmManager:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"max active VMs reached ({self._max_active_vms}); delete old VMs first"
|
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)
|
self._backend.create(instance)
|
||||||
if self._runtime_capabilities.supports_guest_exec:
|
if self._runtime_capabilities.supports_guest_exec:
|
||||||
self._ensure_workspace_guest_bootstrap_support(instance)
|
self._ensure_workspace_guest_bootstrap_support(instance)
|
||||||
|
|
@ -4081,7 +3885,6 @@ class VmManager:
|
||||||
workspace_sync = prepared_seed.to_payload(
|
workspace_sync = prepared_seed.to_payload(
|
||||||
destination=normalized_destination,
|
destination=normalized_destination,
|
||||||
path_key="source_path",
|
path_key="source_path",
|
||||||
include_origin=False,
|
|
||||||
)
|
)
|
||||||
workspace_sync["entry_count"] = int(import_summary["entry_count"])
|
workspace_sync["entry_count"] = int(import_summary["entry_count"])
|
||||||
workspace_sync["bytes_written"] = int(import_summary["bytes_written"])
|
workspace_sync["bytes_written"] = int(import_summary["bytes_written"])
|
||||||
|
|
@ -4093,18 +3896,6 @@ class VmManager:
|
||||||
workspace.last_error = instance.last_error
|
workspace.last_error = instance.last_error
|
||||||
workspace.metadata = dict(instance.metadata)
|
workspace.metadata = dict(instance.metadata)
|
||||||
self._touch_workspace_activity_locked(workspace)
|
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)
|
self._save_workspace_locked(workspace)
|
||||||
return {
|
return {
|
||||||
"workspace_id": workspace_id,
|
"workspace_id": workspace_id,
|
||||||
|
|
@ -4186,8 +3977,8 @@ class VmManager:
|
||||||
def export_workspace(
|
def export_workspace(
|
||||||
self,
|
self,
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
path: str,
|
|
||||||
*,
|
*,
|
||||||
|
path: str,
|
||||||
output_path: str | Path,
|
output_path: str | Path,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
normalized_path, _ = _normalize_workspace_destination(path)
|
normalized_path, _ = _normalize_workspace_destination(path)
|
||||||
|
|
@ -4219,23 +4010,6 @@ class VmManager:
|
||||||
workspace.firecracker_pid = instance.firecracker_pid
|
workspace.firecracker_pid = instance.firecracker_pid
|
||||||
workspace.last_error = instance.last_error
|
workspace.last_error = instance.last_error
|
||||||
workspace.metadata = dict(instance.metadata)
|
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)
|
self._save_workspace_locked(workspace)
|
||||||
return {
|
return {
|
||||||
"workspace_id": workspace_id,
|
"workspace_id": workspace_id,
|
||||||
|
|
@ -4395,22 +4169,6 @@ class VmManager:
|
||||||
workspace.firecracker_pid = instance.firecracker_pid
|
workspace.firecracker_pid = instance.firecracker_pid
|
||||||
workspace.last_error = instance.last_error
|
workspace.last_error = instance.last_error
|
||||||
workspace.metadata = dict(instance.metadata)
|
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)
|
self._save_workspace_locked(workspace)
|
||||||
return {
|
return {
|
||||||
"workspace_id": workspace_id,
|
"workspace_id": workspace_id,
|
||||||
|
|
@ -4528,15 +4286,6 @@ class VmManager:
|
||||||
workspace.last_error = instance.last_error
|
workspace.last_error = instance.last_error
|
||||||
workspace.metadata = dict(instance.metadata)
|
workspace.metadata = dict(instance.metadata)
|
||||||
self._touch_workspace_activity_locked(workspace)
|
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)
|
self._save_workspace_locked(workspace)
|
||||||
return {
|
return {
|
||||||
"workspace_id": workspace_id,
|
"workspace_id": workspace_id,
|
||||||
|
|
@ -4614,17 +4363,6 @@ class VmManager:
|
||||||
self._touch_workspace_activity_locked(workspace)
|
self._touch_workspace_activity_locked(workspace)
|
||||||
self._save_workspace_locked(workspace)
|
self._save_workspace_locked(workspace)
|
||||||
self._save_workspace_snapshot_locked(snapshot)
|
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 {
|
return {
|
||||||
"workspace_id": workspace_id,
|
"workspace_id": workspace_id,
|
||||||
"snapshot": self._serialize_workspace_snapshot(snapshot),
|
"snapshot": self._serialize_workspace_snapshot(snapshot),
|
||||||
|
|
@ -4658,11 +4396,6 @@ class VmManager:
|
||||||
self._load_workspace_snapshot_locked(workspace_id, normalized_snapshot_name)
|
self._load_workspace_snapshot_locked(workspace_id, normalized_snapshot_name)
|
||||||
self._delete_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._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)
|
self._save_workspace_locked(workspace)
|
||||||
return {
|
return {
|
||||||
"workspace_id": workspace_id,
|
"workspace_id": workspace_id,
|
||||||
|
|
@ -4703,7 +4436,9 @@ class VmManager:
|
||||||
recreated = workspace.to_instance(
|
recreated = workspace.to_instance(
|
||||||
workdir=self._workspace_runtime_dir(workspace.workspace_id)
|
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)
|
self._backend.create(recreated)
|
||||||
if self._runtime_capabilities.supports_guest_exec:
|
if self._runtime_capabilities.supports_guest_exec:
|
||||||
self._ensure_workspace_guest_bootstrap_support(recreated)
|
self._ensure_workspace_guest_bootstrap_support(recreated)
|
||||||
|
|
@ -4897,7 +4632,9 @@ class VmManager:
|
||||||
if wait_for_idle_ms is not None and (
|
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
|
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:
|
with self._lock:
|
||||||
workspace = self._load_workspace_locked(workspace_id)
|
workspace = self._load_workspace_locked(workspace_id)
|
||||||
instance = self._workspace_instance_for_live_shell_locked(workspace)
|
instance = self._workspace_instance_for_live_shell_locked(workspace)
|
||||||
|
|
@ -5172,7 +4909,8 @@ class VmManager:
|
||||||
if normalized_published_ports:
|
if normalized_published_ports:
|
||||||
if workspace.network_policy != "egress+published-ports":
|
if workspace.network_policy != "egress+published-ports":
|
||||||
raise RuntimeError(
|
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:
|
if instance.network is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
|
|
@ -5259,24 +4997,6 @@ class VmManager:
|
||||||
self._touch_workspace_activity_locked(workspace)
|
self._touch_workspace_activity_locked(workspace)
|
||||||
self._save_workspace_locked(workspace)
|
self._save_workspace_locked(workspace)
|
||||||
self._save_workspace_service_locked(service)
|
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)
|
return self._serialize_workspace_service(service)
|
||||||
|
|
||||||
def list_services(self, workspace_id: str) -> dict[str, Any]:
|
def list_services(self, workspace_id: str) -> dict[str, Any]:
|
||||||
|
|
@ -5396,18 +5116,6 @@ class VmManager:
|
||||||
workspace.firecracker_pid = instance.firecracker_pid
|
workspace.firecracker_pid = instance.firecracker_pid
|
||||||
workspace.last_error = instance.last_error
|
workspace.last_error = instance.last_error
|
||||||
workspace.metadata = dict(instance.metadata)
|
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_locked(workspace)
|
||||||
self._save_workspace_service_locked(service)
|
self._save_workspace_service_locked(service)
|
||||||
return self._serialize_workspace_service(service)
|
return self._serialize_workspace_service(service)
|
||||||
|
|
@ -5441,153 +5149,6 @@ class VmManager:
|
||||||
"entries": redacted_entries,
|
"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]:
|
def stop_workspace(self, workspace_id: str) -> dict[str, Any]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
workspace = self._load_workspace_locked(workspace_id)
|
workspace = self._load_workspace_locked(workspace_id)
|
||||||
|
|
@ -5635,7 +5196,9 @@ class VmManager:
|
||||||
self._stop_workspace_services_locked(workspace, instance)
|
self._stop_workspace_services_locked(workspace, instance)
|
||||||
self._close_workspace_shells_locked(workspace, instance)
|
self._close_workspace_shells_locked(workspace, instance)
|
||||||
try:
|
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:
|
if self._runtime_capabilities.supports_guest_exec:
|
||||||
self._ensure_workspace_guest_bootstrap_support(instance)
|
self._ensure_workspace_guest_bootstrap_support(instance)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|
@ -5818,7 +5381,9 @@ class VmManager:
|
||||||
"execution_mode": workspace.metadata.get("execution_mode", "pending"),
|
"execution_mode": workspace.metadata.get("execution_mode", "pending"),
|
||||||
"workspace_path": WORKSPACE_GUEST_PATH,
|
"workspace_path": WORKSPACE_GUEST_PATH,
|
||||||
"workspace_seed": _workspace_seed_dict(workspace.workspace_seed),
|
"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,
|
"command_count": workspace.command_count,
|
||||||
"last_command": workspace.last_command,
|
"last_command": workspace.last_command,
|
||||||
"reset_count": workspace.reset_count,
|
"reset_count": workspace.reset_count,
|
||||||
|
|
@ -5989,7 +5554,9 @@ class VmManager:
|
||||||
env_values: dict[str, str] = {}
|
env_values: dict[str, str] = {}
|
||||||
for secret_name, env_name in secret_env.items():
|
for secret_name, env_name in secret_env.items():
|
||||||
if secret_name not in secret_values:
|
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]
|
env_values[env_name] = secret_values[secret_name]
|
||||||
return env_values
|
return env_values
|
||||||
|
|
||||||
|
|
@ -6096,30 +5663,12 @@ class VmManager:
|
||||||
execution_mode = instance.metadata.get("execution_mode", "unknown")
|
execution_mode = instance.metadata.get("execution_mode", "unknown")
|
||||||
return exec_result, execution_mode
|
return exec_result, execution_mode
|
||||||
|
|
||||||
def _prepare_workspace_seed(
|
def _prepare_workspace_seed(self, seed_path: str | Path | None) -> PreparedWorkspaceSeed:
|
||||||
self,
|
|
||||||
seed_path: str | Path | None,
|
|
||||||
*,
|
|
||||||
origin_kind: WorkspaceSeedOriginKind | None = None,
|
|
||||||
origin_ref: str | None = None,
|
|
||||||
) -> PreparedWorkspaceSeed:
|
|
||||||
if seed_path is None:
|
if seed_path is None:
|
||||||
return PreparedWorkspaceSeed(
|
return PreparedWorkspaceSeed(mode="empty", source_path=None)
|
||||||
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()
|
resolved_source_path = Path(seed_path).expanduser().resolve()
|
||||||
if not resolved_source_path.exists():
|
if not resolved_source_path.exists():
|
||||||
raise ValueError(f"seed_path {resolved_source_path} does not exist")
|
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():
|
if resolved_source_path.is_dir():
|
||||||
cleanup_dir = Path(tempfile.mkdtemp(prefix="pyro-workspace-seed-"))
|
cleanup_dir = Path(tempfile.mkdtemp(prefix="pyro-workspace-seed-"))
|
||||||
archive_path = cleanup_dir / "workspace-seed.tar"
|
archive_path = cleanup_dir / "workspace-seed.tar"
|
||||||
|
|
@ -6131,24 +5680,23 @@ class VmManager:
|
||||||
raise
|
raise
|
||||||
return PreparedWorkspaceSeed(
|
return PreparedWorkspaceSeed(
|
||||||
mode="directory",
|
mode="directory",
|
||||||
source_path=public_source_path,
|
source_path=str(resolved_source_path),
|
||||||
origin_kind=effective_origin_kind,
|
|
||||||
origin_ref=effective_origin_ref,
|
|
||||||
archive_path=archive_path,
|
archive_path=archive_path,
|
||||||
entry_count=entry_count,
|
entry_count=entry_count,
|
||||||
bytes_written=bytes_written,
|
bytes_written=bytes_written,
|
||||||
cleanup_dir=cleanup_dir,
|
cleanup_dir=cleanup_dir,
|
||||||
)
|
)
|
||||||
if not resolved_source_path.is_file() or not _is_supported_seed_archive(
|
if (
|
||||||
resolved_source_path
|
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)
|
entry_count, bytes_written = _inspect_seed_archive(resolved_source_path)
|
||||||
return PreparedWorkspaceSeed(
|
return PreparedWorkspaceSeed(
|
||||||
mode="tar_archive",
|
mode="tar_archive",
|
||||||
source_path=public_source_path,
|
source_path=str(resolved_source_path),
|
||||||
origin_kind=effective_origin_kind,
|
|
||||||
origin_ref=effective_origin_ref,
|
|
||||||
archive_path=resolved_source_path,
|
archive_path=resolved_source_path,
|
||||||
entry_count=entry_count,
|
entry_count=entry_count,
|
||||||
bytes_written=bytes_written,
|
bytes_written=bytes_written,
|
||||||
|
|
@ -6209,9 +5757,6 @@ class VmManager:
|
||||||
def _workspace_commands_dir(self, workspace_id: str) -> Path:
|
def _workspace_commands_dir(self, workspace_id: str) -> Path:
|
||||||
return self._workspace_dir(workspace_id) / WORKSPACE_COMMANDS_DIRNAME
|
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:
|
def _workspace_shells_dir(self, workspace_id: str) -> Path:
|
||||||
return self._workspace_dir(workspace_id) / WORKSPACE_SHELLS_DIRNAME
|
return self._workspace_dir(workspace_id) / WORKSPACE_SHELLS_DIRNAME
|
||||||
|
|
||||||
|
|
@ -6227,9 +5772,6 @@ class VmManager:
|
||||||
def _workspace_shell_record_path(self, workspace_id: str, shell_id: str) -> Path:
|
def _workspace_shell_record_path(self, workspace_id: str, shell_id: str) -> Path:
|
||||||
return self._workspace_shells_dir(workspace_id) / f"{shell_id}.json"
|
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:
|
def _workspace_service_record_path(self, workspace_id: str, service_name: str) -> Path:
|
||||||
return self._workspace_services_dir(workspace_id) / f"{service_name}.json"
|
return self._workspace_services_dir(workspace_id) / f"{service_name}.json"
|
||||||
|
|
||||||
|
|
@ -6245,7 +5787,8 @@ class VmManager:
|
||||||
rootfs_path = Path(raw_rootfs_image)
|
rootfs_path = Path(raw_rootfs_image)
|
||||||
if not rootfs_path.exists():
|
if not rootfs_path.exists():
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"workspace {workspace.workspace_id!r} rootfs image is unavailable at {rootfs_path}"
|
f"workspace {workspace.workspace_id!r} rootfs image is unavailable at "
|
||||||
|
f"{rootfs_path}"
|
||||||
)
|
)
|
||||||
return rootfs_path
|
return rootfs_path
|
||||||
|
|
||||||
|
|
@ -6262,7 +5805,9 @@ class VmManager:
|
||||||
f"workspace {workspace.workspace_id!r} must be stopped before {operation_name}"
|
f"workspace {workspace.workspace_id!r} must be stopped before {operation_name}"
|
||||||
)
|
)
|
||||||
if workspace.metadata.get("execution_mode") == "host_compat":
|
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)
|
return self._workspace_rootfs_image_path_locked(workspace)
|
||||||
|
|
||||||
def _scrub_workspace_runtime_state_locked(
|
def _scrub_workspace_runtime_state_locked(
|
||||||
|
|
@ -6421,46 +5966,6 @@ class VmManager:
|
||||||
entries.append(entry)
|
entries.append(entry)
|
||||||
return entries
|
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:
|
def _workspace_instance_for_live_shell_locked(self, workspace: WorkspaceRecord) -> VmInstance:
|
||||||
instance = self._workspace_instance_for_live_operation_locked(
|
instance = self._workspace_instance_for_live_operation_locked(
|
||||||
workspace,
|
workspace,
|
||||||
|
|
@ -6817,12 +6322,10 @@ class VmManager:
|
||||||
shutil.rmtree(self._workspace_runtime_dir(workspace_id), ignore_errors=True)
|
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_host_dir(workspace_id), ignore_errors=True)
|
||||||
shutil.rmtree(self._workspace_commands_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_shells_dir(workspace_id), ignore_errors=True)
|
||||||
shutil.rmtree(self._workspace_services_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_host_dir(workspace_id).mkdir(parents=True, exist_ok=True)
|
||||||
self._workspace_commands_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_shells_dir(workspace_id).mkdir(parents=True, exist_ok=True)
|
||||||
self._workspace_services_dir(workspace_id).mkdir(parents=True, exist_ok=True)
|
self._workspace_services_dir(workspace_id).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
@ -29,7 +28,7 @@ USE_CASE_CHOICES: Final[tuple[str, ...]] = USE_CASE_SCENARIOS + (USE_CASE_ALL_SC
|
||||||
class WorkspaceUseCaseRecipe:
|
class WorkspaceUseCaseRecipe:
|
||||||
scenario: str
|
scenario: str
|
||||||
title: str
|
title: str
|
||||||
mode: Literal["repro-fix", "inspect", "cold-start", "review-eval"]
|
profile: Literal["workspace-core", "workspace-full"]
|
||||||
smoke_target: str
|
smoke_target: str
|
||||||
doc_path: str
|
doc_path: str
|
||||||
summary: str
|
summary: str
|
||||||
|
|
@ -39,7 +38,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
|
||||||
WorkspaceUseCaseRecipe(
|
WorkspaceUseCaseRecipe(
|
||||||
scenario="cold-start-validation",
|
scenario="cold-start-validation",
|
||||||
title="Cold-Start Repo Validation",
|
title="Cold-Start Repo Validation",
|
||||||
mode="cold-start",
|
profile="workspace-full",
|
||||||
smoke_target="smoke-cold-start-validation",
|
smoke_target="smoke-cold-start-validation",
|
||||||
doc_path="docs/use-cases/cold-start-repo-validation.md",
|
doc_path="docs/use-cases/cold-start-repo-validation.md",
|
||||||
summary=(
|
summary=(
|
||||||
|
|
@ -50,7 +49,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
|
||||||
WorkspaceUseCaseRecipe(
|
WorkspaceUseCaseRecipe(
|
||||||
scenario="repro-fix-loop",
|
scenario="repro-fix-loop",
|
||||||
title="Repro Plus Fix Loop",
|
title="Repro Plus Fix Loop",
|
||||||
mode="repro-fix",
|
profile="workspace-core",
|
||||||
smoke_target="smoke-repro-fix-loop",
|
smoke_target="smoke-repro-fix-loop",
|
||||||
doc_path="docs/use-cases/repro-fix-loop.md",
|
doc_path="docs/use-cases/repro-fix-loop.md",
|
||||||
summary=(
|
summary=(
|
||||||
|
|
@ -61,7 +60,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
|
||||||
WorkspaceUseCaseRecipe(
|
WorkspaceUseCaseRecipe(
|
||||||
scenario="parallel-workspaces",
|
scenario="parallel-workspaces",
|
||||||
title="Parallel Isolated Workspaces",
|
title="Parallel Isolated Workspaces",
|
||||||
mode="repro-fix",
|
profile="workspace-core",
|
||||||
smoke_target="smoke-parallel-workspaces",
|
smoke_target="smoke-parallel-workspaces",
|
||||||
doc_path="docs/use-cases/parallel-workspaces.md",
|
doc_path="docs/use-cases/parallel-workspaces.md",
|
||||||
summary=(
|
summary=(
|
||||||
|
|
@ -72,7 +71,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
|
||||||
WorkspaceUseCaseRecipe(
|
WorkspaceUseCaseRecipe(
|
||||||
scenario="untrusted-inspection",
|
scenario="untrusted-inspection",
|
||||||
title="Unsafe Or Untrusted Code Inspection",
|
title="Unsafe Or Untrusted Code Inspection",
|
||||||
mode="inspect",
|
profile="workspace-core",
|
||||||
smoke_target="smoke-untrusted-inspection",
|
smoke_target="smoke-untrusted-inspection",
|
||||||
doc_path="docs/use-cases/untrusted-inspection.md",
|
doc_path="docs/use-cases/untrusted-inspection.md",
|
||||||
summary=(
|
summary=(
|
||||||
|
|
@ -83,7 +82,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
|
||||||
WorkspaceUseCaseRecipe(
|
WorkspaceUseCaseRecipe(
|
||||||
scenario="review-eval",
|
scenario="review-eval",
|
||||||
title="Review And Evaluation Workflows",
|
title="Review And Evaluation Workflows",
|
||||||
mode="review-eval",
|
profile="workspace-full",
|
||||||
smoke_target="smoke-review-eval",
|
smoke_target="smoke-review-eval",
|
||||||
doc_path="docs/use-cases/review-eval-workflows.md",
|
doc_path="docs/use-cases/review-eval-workflows.md",
|
||||||
summary=(
|
summary=(
|
||||||
|
|
@ -108,15 +107,6 @@ def _log(message: str) -> None:
|
||||||
print(f"[smoke] {message}", flush=True)
|
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(
|
def _create_workspace(
|
||||||
pyro: Pyro,
|
pyro: Pyro,
|
||||||
*,
|
*,
|
||||||
|
|
@ -136,31 +126,6 @@ def _create_workspace(
|
||||||
return str(created["workspace_id"])
|
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:
|
def _safe_delete_workspace(pyro: Pyro, workspace_id: str | None) -> None:
|
||||||
if workspace_id is None:
|
if workspace_id is None:
|
||||||
return
|
return
|
||||||
|
|
@ -195,19 +160,14 @@ def _scenario_cold_start_validation(pyro: Pyro, *, root: Path, environment: str)
|
||||||
)
|
)
|
||||||
workspace_id: str | None = None
|
workspace_id: str | None = None
|
||||||
try:
|
try:
|
||||||
created = _create_project_aware_workspace(
|
workspace_id = _create_workspace(
|
||||||
pyro,
|
pyro,
|
||||||
environment=environment,
|
environment=environment,
|
||||||
project_path=seed_dir,
|
seed_path=seed_dir,
|
||||||
mode="cold-start",
|
|
||||||
name="cold-start-validation",
|
name="cold-start-validation",
|
||||||
labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "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}")
|
_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")
|
validation = pyro.exec_workspace(workspace_id, command="sh validate.sh")
|
||||||
assert int(validation["exit_code"]) == 0, validation
|
assert int(validation["exit_code"]) == 0, validation
|
||||||
assert str(validation["stdout"]) == "validated\n", validation
|
assert str(validation["stdout"]) == "validated\n", validation
|
||||||
|
|
@ -261,20 +221,14 @@ def _scenario_repro_fix_loop(pyro: Pyro, *, root: Path, environment: str) -> Non
|
||||||
)
|
)
|
||||||
workspace_id: str | None = None
|
workspace_id: str | None = None
|
||||||
try:
|
try:
|
||||||
created = _create_project_aware_workspace(
|
workspace_id = _create_workspace(
|
||||||
pyro,
|
pyro,
|
||||||
environment=environment,
|
environment=environment,
|
||||||
project_path=seed_dir,
|
seed_path=seed_dir,
|
||||||
mode="repro-fix",
|
|
||||||
name="repro-fix-loop",
|
name="repro-fix-loop",
|
||||||
labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "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}")
|
_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")
|
initial_read = pyro.read_workspace_file(workspace_id, "message.txt")
|
||||||
assert str(initial_read["content"]) == "broken\n", initial_read
|
assert str(initial_read["content"]) == "broken\n", initial_read
|
||||||
failing = pyro.exec_workspace(workspace_id, command="sh check.sh")
|
failing = pyro.exec_workspace(workspace_id, command="sh check.sh")
|
||||||
|
|
@ -464,11 +418,6 @@ def _scenario_review_eval(pyro: Pyro, *, root: Path, environment: str) -> None:
|
||||||
assert int(rerun["exit_code"]) == 0, rerun
|
assert int(rerun["exit_code"]) == 0, rerun
|
||||||
pyro.export_workspace(workspace_id, "review-report.txt", output_path=export_path)
|
pyro.export_workspace(workspace_id, "review-report.txt", output_path=export_path)
|
||||||
assert export_path.read_text(encoding="utf-8") == "review=pass\n"
|
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:
|
finally:
|
||||||
if shell_id is not None and workspace_id is not None:
|
if shell_id is not None and workspace_id is not None:
|
||||||
try:
|
try:
|
||||||
|
|
@ -502,7 +451,7 @@ def run_workspace_use_case_scenario(
|
||||||
scenario_names = USE_CASE_SCENARIOS if scenario == USE_CASE_ALL_SCENARIO else (scenario,)
|
scenario_names = USE_CASE_SCENARIOS if scenario == USE_CASE_ALL_SCENARIO else (scenario,)
|
||||||
for scenario_name in scenario_names:
|
for scenario_name in scenario_names:
|
||||||
recipe = _RECIPE_BY_SCENARIO[scenario_name]
|
recipe = _RECIPE_BY_SCENARIO[scenario_name]
|
||||||
_log(f"starting {recipe.scenario} ({recipe.title}) mode={recipe.mode}")
|
_log(f"starting {recipe.scenario} ({recipe.title}) profile={recipe.profile}")
|
||||||
scenario_root = root / scenario_name
|
scenario_root = root / scenario_name
|
||||||
scenario_root.mkdir(parents=True, exist_ok=True)
|
scenario_root.mkdir(parents=True, exist_ok=True)
|
||||||
runner = _SCENARIO_RUNNERS[scenario_name]
|
runner = _SCENARIO_RUNNERS[scenario_name]
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,12 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import subprocess
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from pyro_mcp.api import Pyro
|
from pyro_mcp.api import Pyro
|
||||||
from pyro_mcp.contract import (
|
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_VM_RUN_PROFILE_TOOLS,
|
||||||
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
|
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
|
||||||
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS,
|
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS,
|
||||||
|
|
@ -22,28 +15,6 @@ from pyro_mcp.vm_manager import VmManager
|
||||||
from pyro_mcp.vm_network import TapNetworkManager
|
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:
|
def test_pyro_run_in_vm_delegates_to_manager(tmp_path: Path) -> None:
|
||||||
pyro = Pyro(
|
pyro = Pyro(
|
||||||
manager=VmManager(
|
manager=VmManager(
|
||||||
|
|
@ -163,172 +134,6 @@ def test_pyro_create_server_workspace_core_profile_registers_expected_tools_and_
|
||||||
assert "workspace_disk_export" not in tool_map
|
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:
|
def test_pyro_vm_run_tool_executes(tmp_path: Path) -> None:
|
||||||
pyro = Pyro(
|
pyro = Pyro(
|
||||||
manager=VmManager(
|
manager=VmManager(
|
||||||
|
|
@ -575,7 +380,6 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
|
||||||
services = pyro.list_services(workspace_id)
|
services = pyro.list_services(workspace_id)
|
||||||
service_status = pyro.status_service(workspace_id, "app")
|
service_status = pyro.status_service(workspace_id, "app")
|
||||||
service_logs = pyro.logs_service(workspace_id, "app", all=True)
|
service_logs = pyro.logs_service(workspace_id, "app", all=True)
|
||||||
summary = pyro.summarize_workspace(workspace_id)
|
|
||||||
reset = pyro.reset_workspace(workspace_id, snapshot="checkpoint")
|
reset = pyro.reset_workspace(workspace_id, snapshot="checkpoint")
|
||||||
deleted_snapshot = pyro.delete_snapshot(workspace_id, "checkpoint")
|
deleted_snapshot = pyro.delete_snapshot(workspace_id, "checkpoint")
|
||||||
status = pyro.status_workspace(workspace_id)
|
status = pyro.status_workspace(workspace_id)
|
||||||
|
|
@ -612,9 +416,6 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
|
||||||
assert service_status["state"] == "running"
|
assert service_status["state"] == "running"
|
||||||
assert service_logs["stderr"].count("[REDACTED]") >= 1
|
assert service_logs["stderr"].count("[REDACTED]") >= 1
|
||||||
assert service_logs["tail_lines"] is None
|
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["workspace_reset"]["snapshot_name"] == "checkpoint"
|
||||||
assert reset["secrets"] == created["secrets"]
|
assert reset["secrets"] == created["secrets"]
|
||||||
assert deleted_snapshot["deleted"] is True
|
assert deleted_snapshot["deleted"] is True
|
||||||
|
|
@ -1178,14 +979,6 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
|
||||||
calls.append(("logs_workspace", {"workspace_id": workspace_id}))
|
calls.append(("logs_workspace", {"workspace_id": workspace_id}))
|
||||||
return {"workspace_id": workspace_id, "count": 0, "entries": []}
|
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(
|
def open_shell(
|
||||||
self,
|
self,
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
|
|
@ -1317,9 +1110,6 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
|
||||||
status = _extract_structured(
|
status = _extract_structured(
|
||||||
await server.call_tool("workspace_status", {"workspace_id": "workspace-123"})
|
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(
|
logs = _extract_structured(
|
||||||
await server.call_tool("workspace_logs", {"workspace_id": "workspace-123"})
|
await server.call_tool("workspace_logs", {"workspace_id": "workspace-123"})
|
||||||
)
|
)
|
||||||
|
|
@ -1421,7 +1211,6 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
status,
|
status,
|
||||||
summary,
|
|
||||||
logs,
|
logs,
|
||||||
opened,
|
opened,
|
||||||
read,
|
read,
|
||||||
|
|
@ -1436,15 +1225,13 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
|
||||||
|
|
||||||
results = asyncio.run(_run())
|
results = asyncio.run(_run())
|
||||||
assert results[0]["state"] == "started"
|
assert results[0]["state"] == "started"
|
||||||
assert results[1]["workspace_id"] == "workspace-123"
|
assert results[1]["count"] == 0
|
||||||
assert results[2]["count"] == 0
|
assert results[2]["shell_id"] == "shell-1"
|
||||||
assert results[3]["shell_id"] == "shell-1"
|
assert results[6]["closed"] is True
|
||||||
assert results[7]["closed"] is True
|
assert results[7]["state"] == "running"
|
||||||
assert results[8]["state"] == "running"
|
assert results[10]["state"] == "running"
|
||||||
assert results[11]["state"] == "running"
|
|
||||||
assert calls == [
|
assert calls == [
|
||||||
("status_workspace", {"workspace_id": "workspace-123"}),
|
("status_workspace", {"workspace_id": "workspace-123"}),
|
||||||
("summarize_workspace", {"workspace_id": "workspace-123"}),
|
|
||||||
("logs_workspace", {"workspace_id": "workspace-123"}),
|
("logs_workspace", {"workspace_id": "workspace-123"}),
|
||||||
(
|
(
|
||||||
"open_shell",
|
"open_shell",
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ from typing import Any, cast
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import pyro_mcp.cli as cli
|
import pyro_mcp.cli as cli
|
||||||
from pyro_mcp.host_helpers import HostDoctorEntry
|
|
||||||
|
|
||||||
|
|
||||||
def _subparser_choice(parser: argparse.ArgumentParser, name: str) -> argparse.ArgumentParser:
|
def _subparser_choice(parser: argparse.ArgumentParser, name: str) -> argparse.ArgumentParser:
|
||||||
|
|
@ -27,24 +26,17 @@ def test_cli_help_guides_first_run() -> None:
|
||||||
parser = cli._build_parser()
|
parser = cli._build_parser()
|
||||||
help_text = parser.format_help()
|
help_text = parser.format_help()
|
||||||
|
|
||||||
assert "Suggested zero-to-hero path:" in help_text
|
assert "Suggested first run:" in help_text
|
||||||
assert "pyro doctor" in help_text
|
assert "pyro doctor" in help_text
|
||||||
assert "pyro prepare debian:12" in help_text
|
assert "pyro env list" in help_text
|
||||||
|
assert "pyro env pull debian:12" in help_text
|
||||||
assert "pyro run debian:12 -- git --version" in help_text
|
assert "pyro run debian:12 -- git --version" in help_text
|
||||||
assert "pyro host connect claude-code" in help_text
|
assert "Continue into the stable workspace path after that:" 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 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 snapshot create WORKSPACE_ID checkpoint" in help_text
|
||||||
assert "pyro workspace reset WORKSPACE_ID --snapshot 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 "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:
|
def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
||||||
|
|
@ -62,41 +54,8 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
||||||
assert "pyro env pull debian:12" in env_help
|
assert "pyro env pull debian:12" in env_help
|
||||||
assert "downloads from public Docker Hub" 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()
|
doctor_help = _subparser_choice(parser, "doctor").format_help()
|
||||||
assert "Check host prerequisites and embedded runtime health" in doctor_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
|
assert "pyro doctor --json" in doctor_help
|
||||||
|
|
||||||
demo_help = _subparser_choice(parser, "demo").format_help()
|
demo_help = _subparser_choice(parser, "demo").format_help()
|
||||||
|
|
@ -110,22 +69,17 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
||||||
assert "vm-run" in mcp_help
|
assert "vm-run" in mcp_help
|
||||||
assert "recommended first profile for most chat hosts" 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-core: default for normal persistent chat editing" in mcp_help
|
||||||
assert "workspace-full: larger opt-in surface" in mcp_help
|
assert "workspace-full: advanced 4.x 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()
|
workspace_help = _subparser_choice(parser, "workspace").format_help()
|
||||||
assert "Use the workspace model when you need one sandbox to stay alive" in workspace_help
|
assert "stable workspace contract" in workspace_help
|
||||||
assert "pyro workspace create debian:12 --seed-path ./repo" in workspace_help
|
assert "pyro workspace create debian:12 --seed-path ./repo" in workspace_help
|
||||||
assert "--id-only" 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 create debian:12 --name repro-fix --label issue=123" in workspace_help
|
||||||
assert "pyro workspace list" in workspace_help
|
assert "pyro workspace list" in workspace_help
|
||||||
assert (
|
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 sync push WORKSPACE_ID ./repo --dest src" in workspace_help
|
||||||
assert "pyro workspace exec WORKSPACE_ID" in workspace_help
|
assert "pyro workspace exec WORKSPACE_ID" in workspace_help
|
||||||
|
|
@ -137,7 +91,6 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
||||||
assert "pyro workspace start WORKSPACE_ID" in workspace_help
|
assert "pyro workspace start WORKSPACE_ID" in workspace_help
|
||||||
assert "pyro workspace snapshot create WORKSPACE_ID checkpoint" 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 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
|
assert "pyro workspace shell open WORKSPACE_ID --id-only" in workspace_help
|
||||||
|
|
||||||
workspace_create_help = _subparser_choice(
|
workspace_create_help = _subparser_choice(
|
||||||
|
|
@ -192,12 +145,6 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
||||||
assert "--label" in workspace_update_help
|
assert "--label" in workspace_update_help
|
||||||
assert "--clear-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(
|
workspace_file_help = _subparser_choice(
|
||||||
_subparser_choice(parser, "workspace"), "file"
|
_subparser_choice(parser, "workspace"), "file"
|
||||||
).format_help()
|
).format_help()
|
||||||
|
|
@ -360,94 +307,6 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
||||||
assert "Close a persistent workspace shell" in workspace_shell_close_help
|
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(
|
def test_cli_run_prints_json(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
capsys: pytest.CaptureFixture[str],
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
|
@ -485,22 +344,13 @@ def test_cli_doctor_prints_json(
|
||||||
) -> None:
|
) -> None:
|
||||||
class StubParser:
|
class StubParser:
|
||||||
def parse_args(self) -> argparse.Namespace:
|
def parse_args(self) -> argparse.Namespace:
|
||||||
return argparse.Namespace(
|
return argparse.Namespace(command="doctor", platform="linux-x86_64", json=True)
|
||||||
command="doctor",
|
|
||||||
platform="linux-x86_64",
|
|
||||||
environment="debian:12",
|
|
||||||
json=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
cli,
|
cli,
|
||||||
"doctor_report",
|
"doctor_report",
|
||||||
lambda *, platform, environment: {
|
lambda platform: {"platform": platform, "runtime_ok": True},
|
||||||
"platform": platform,
|
|
||||||
"environment": environment,
|
|
||||||
"runtime_ok": True,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
cli.main()
|
cli.main()
|
||||||
output = json.loads(capsys.readouterr().out)
|
output = json.loads(capsys.readouterr().out)
|
||||||
|
|
@ -719,7 +569,7 @@ def test_cli_requires_command_preserves_shell_argument_boundaries() -> None:
|
||||||
command = cli._require_command(
|
command = cli._require_command(
|
||||||
["--", "sh", "-lc", 'printf "hello from workspace\\n" > note.txt']
|
["--", "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:
|
def test_cli_read_utf8_text_file_rejects_non_utf8(tmp_path: Path) -> None:
|
||||||
|
|
@ -995,7 +845,10 @@ def test_cli_workspace_exec_prints_human_output(
|
||||||
cli.main()
|
cli.main()
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert captured.out == "hello\n"
|
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(
|
def test_print_workspace_summary_human_handles_last_command_and_secret_filtering(
|
||||||
|
|
@ -1466,7 +1319,13 @@ def test_cli_workspace_patch_apply_reads_patch_file(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
patch_path = tmp_path / "fix.patch"
|
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")
|
patch_path.write_text(patch_text, encoding="utf-8")
|
||||||
|
|
||||||
class StubPyro:
|
class StubPyro:
|
||||||
|
|
@ -1914,7 +1773,10 @@ def test_cli_workspace_diff_prints_human_output(
|
||||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||||
cli.main()
|
cli.main()
|
||||||
output = capsys.readouterr().out
|
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
|
assert "--- a/note.txt" in output
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2361,7 +2223,8 @@ def test_cli_workspace_sync_push_prints_human(
|
||||||
output = capsys.readouterr().out
|
output = capsys.readouterr().out
|
||||||
assert "[workspace-sync] workspace_id=workspace-123 mode=directory source=/tmp/repo" in output
|
assert "[workspace-sync] workspace_id=workspace-123 mode=directory source=/tmp/repo" in output
|
||||||
assert (
|
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
|
) in output
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2528,168 +2391,6 @@ def test_cli_workspace_logs_prints_json(
|
||||||
assert payload["count"] == 0
|
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(
|
def test_cli_workspace_delete_prints_human(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
capsys: pytest.CaptureFixture[str],
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
|
@ -3104,7 +2805,7 @@ def test_cli_workspace_shell_open_prints_id_only(
|
||||||
assert captured.err == ""
|
assert captured.err == ""
|
||||||
|
|
||||||
|
|
||||||
def test_chat_host_docs_and_examples_recommend_modes_first() -> None:
|
def test_chat_host_docs_and_examples_recommend_workspace_core() -> None:
|
||||||
readme = Path("README.md").read_text(encoding="utf-8")
|
readme = Path("README.md").read_text(encoding="utf-8")
|
||||||
install = Path("docs/install.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")
|
first_run = Path("docs/first-run.md").read_text(encoding="utf-8")
|
||||||
|
|
@ -3113,81 +2814,47 @@ def test_chat_host_docs_and_examples_recommend_modes_first() -> None:
|
||||||
claude_code = Path("examples/claude_code_mcp.md").read_text(encoding="utf-8")
|
claude_code = Path("examples/claude_code_mcp.md").read_text(encoding="utf-8")
|
||||||
codex = Path("examples/codex_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"))
|
opencode = json.loads(Path("examples/opencode_mcp_config.json").read_text(encoding="utf-8"))
|
||||||
claude_helper = "pyro host connect claude-code --mode cold-start"
|
claude_cmd = "claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve"
|
||||||
codex_helper = "pyro host connect codex --mode repro-fix"
|
codex_cmd = "codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve"
|
||||||
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 "## Chat Host Quickstart" in readme
|
||||||
assert claude_helper in readme
|
assert "uvx --from pyro-mcp pyro mcp serve" in readme
|
||||||
assert codex_helper in readme
|
assert claude_cmd in readme
|
||||||
assert inspect_helper in readme
|
assert codex_cmd in readme
|
||||||
assert review_helper in readme
|
|
||||||
assert opencode_helper in readme
|
|
||||||
assert "examples/opencode_mcp_config.json" in readme
|
assert "examples/opencode_mcp_config.json" in readme
|
||||||
assert "pyro host doctor" in readme
|
assert "recommended first profile for normal persistent chat editing" 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 "## 6. Connect a chat host" in install
|
assert "## Chat Host Quickstart" in install
|
||||||
assert claude_helper in install
|
assert "uvx --from pyro-mcp pyro mcp serve" in install
|
||||||
assert codex_helper in install
|
assert claude_cmd in install
|
||||||
assert inspect_helper in install
|
assert codex_cmd in install
|
||||||
assert review_helper in install
|
|
||||||
assert opencode_helper in install
|
|
||||||
assert "workspace-full" 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_helper in first_run
|
assert claude_cmd in first_run
|
||||||
assert codex_helper in first_run
|
assert codex_cmd 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 claude_helper in integrations
|
assert "Bare `pyro mcp serve` now starts `workspace-core`." 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/claude_code_mcp.md" in integrations
|
||||||
assert "examples/codex_mcp.md" in integrations
|
assert "examples/codex_mcp.md" in integrations
|
||||||
assert "examples/opencode_mcp_config.json" in integrations
|
assert "examples/opencode_mcp_config.json" in integrations
|
||||||
assert "generic no-mode path" in integrations
|
assert (
|
||||||
assert "--project-path /abs/path/to/repo" in integrations
|
'`Pyro.create_server()` for most chat hosts now that `workspace-core` '
|
||||||
assert "--repo-url https://github.com/example/project.git" in integrations
|
"is the default profile" in integrations
|
||||||
|
)
|
||||||
|
|
||||||
assert "Recommended named modes for most chat hosts in `4.x`:" in mcp_config
|
assert "Default for most chat hosts in `4.x`: `workspace-core`." in mcp_config
|
||||||
assert "Use the host-specific examples first when they apply:" 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 "claude_code_mcp.md" in mcp_config
|
||||||
assert "codex_mcp.md" in mcp_config
|
assert "codex_mcp.md" in mcp_config
|
||||||
assert "opencode_mcp_config.json" 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_cmd in claude_code
|
||||||
assert "claude mcp list" 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 "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_cmd in codex
|
||||||
assert "codex mcp list" in codex
|
assert "codex mcp list" in codex
|
||||||
assert "pyro host repair codex --mode repro-fix" in codex
|
|
||||||
assert "workspace-full" in codex
|
assert "workspace-full" in codex
|
||||||
assert "--project-path /abs/path/to/repo" in codex
|
|
||||||
|
|
||||||
assert opencode == {
|
assert opencode == {
|
||||||
"mcp": {
|
"mcp": {
|
||||||
|
|
@ -3201,8 +2868,6 @@ def test_chat_host_docs_and_examples_recommend_modes_first() -> None:
|
||||||
"pyro",
|
"pyro",
|
||||||
"mcp",
|
"mcp",
|
||||||
"serve",
|
"serve",
|
||||||
"--mode",
|
|
||||||
"repro-fix",
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3217,32 +2882,7 @@ 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 readme
|
||||||
assert 'workspace file read "$WORKSPACE_ID" note.txt --content-only' in install
|
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 file read "$WORKSPACE_ID" note.txt --content-only' in first_run
|
||||||
assert 'workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch' in first_run
|
assert 'workspace disk read "$WORKSPACE_ID" note.txt --content-only' 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(
|
def test_cli_workspace_shell_write_signal_close_json(
|
||||||
|
|
@ -4325,163 +3965,22 @@ def test_cli_doctor_prints_human(
|
||||||
) -> None:
|
) -> None:
|
||||||
class StubParser:
|
class StubParser:
|
||||||
def parse_args(self) -> argparse.Namespace:
|
def parse_args(self) -> argparse.Namespace:
|
||||||
return argparse.Namespace(
|
return argparse.Namespace(command="doctor", platform="linux-x86_64", json=False)
|
||||||
command="doctor",
|
|
||||||
platform="linux-x86_64",
|
|
||||||
environment="debian:12",
|
|
||||||
json=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
cli,
|
cli,
|
||||||
"doctor_report",
|
"doctor_report",
|
||||||
lambda *, platform, environment: {
|
lambda platform: {
|
||||||
"platform": platform,
|
"platform": platform,
|
||||||
"runtime_ok": True,
|
"runtime_ok": True,
|
||||||
"issues": [],
|
"issues": [],
|
||||||
"kvm": {"exists": True, "readable": True, "writable": True},
|
"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()
|
cli.main()
|
||||||
output = capsys.readouterr().out
|
output = capsys.readouterr().out
|
||||||
assert "Runtime: PASS" in output
|
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(
|
def test_cli_run_json_error_exits_nonzero(
|
||||||
|
|
@ -4518,25 +4017,11 @@ def test_cli_run_json_error_exits_nonzero(
|
||||||
|
|
||||||
|
|
||||||
def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
observed: dict[str, Any] = {}
|
observed: dict[str, str] = {}
|
||||||
|
|
||||||
class StubPyro:
|
class StubPyro:
|
||||||
def create_server(
|
def create_server(self, *, profile: str) -> Any:
|
||||||
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["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(
|
return type(
|
||||||
"StubServer",
|
"StubServer",
|
||||||
(),
|
(),
|
||||||
|
|
@ -4545,29 +4030,12 @@ def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
|
||||||
class StubParser:
|
class StubParser:
|
||||||
def parse_args(self) -> argparse.Namespace:
|
def parse_args(self) -> argparse.Namespace:
|
||||||
return argparse.Namespace(
|
return argparse.Namespace(command="mcp", mcp_command="serve", profile="workspace-core")
|
||||||
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, "_build_parser", lambda: StubParser())
|
||||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||||
cli.main()
|
cli.main()
|
||||||
assert observed == {
|
assert observed == {"profile": "workspace-core", "transport": "stdio"}
|
||||||
"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(
|
def test_cli_demo_default_prints_json(
|
||||||
|
|
@ -4685,7 +4153,7 @@ def test_cli_workspace_exec_passes_secret_env(
|
||||||
class StubPyro:
|
class StubPyro:
|
||||||
def exec_workspace(self, workspace_id: str, **kwargs: Any) -> dict[str, Any]:
|
def exec_workspace(self, workspace_id: str, **kwargs: Any) -> dict[str, Any]:
|
||||||
assert workspace_id == "ws-123"
|
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"}
|
assert kwargs["secret_env"] == {"API_TOKEN": "API_TOKEN", "TOKEN": "PIP_TOKEN"}
|
||||||
return {"exit_code": 0, "stdout": "", "stderr": ""}
|
return {"exit_code": 0, "stdout": "", "stderr": ""}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,359 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
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"]
|
|
||||||
|
|
@ -15,18 +15,13 @@ def test_doctor_main_prints_json(
|
||||||
) -> None:
|
) -> None:
|
||||||
class StubParser:
|
class StubParser:
|
||||||
def parse_args(self) -> argparse.Namespace:
|
def parse_args(self) -> argparse.Namespace:
|
||||||
return argparse.Namespace(platform="linux-x86_64", environment="debian:12")
|
return argparse.Namespace(platform="linux-x86_64")
|
||||||
|
|
||||||
monkeypatch.setattr(doctor_module, "_build_parser", lambda: StubParser())
|
monkeypatch.setattr(doctor_module, "_build_parser", lambda: StubParser())
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
doctor_module,
|
doctor_module,
|
||||||
"doctor_report",
|
"doctor_report",
|
||||||
lambda *, platform, environment: {
|
lambda platform: {"platform": platform, "runtime_ok": True, "issues": []},
|
||||||
"platform": platform,
|
|
||||||
"environment": environment,
|
|
||||||
"runtime_ok": True,
|
|
||||||
"issues": [],
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
doctor_module.main()
|
doctor_module.main()
|
||||||
output = json.loads(capsys.readouterr().out)
|
output = json.loads(capsys.readouterr().out)
|
||||||
|
|
@ -37,4 +32,3 @@ def test_doctor_build_parser_defaults_platform() -> None:
|
||||||
parser = doctor_module._build_parser()
|
parser = doctor_module._build_parser()
|
||||||
args = parser.parse_args([])
|
args = parser.parse_args([])
|
||||||
assert args.platform == DEFAULT_PLATFORM
|
assert args.platform == DEFAULT_PLATFORM
|
||||||
assert args.environment == "debian:12"
|
|
||||||
|
|
|
||||||
|
|
@ -1,501 +0,0 @@
|
||||||
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"]]
|
|
||||||
|
|
@ -1,236 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -15,16 +15,9 @@ from pyro_mcp.cli import _build_parser
|
||||||
from pyro_mcp.contract import (
|
from pyro_mcp.contract import (
|
||||||
PUBLIC_CLI_COMMANDS,
|
PUBLIC_CLI_COMMANDS,
|
||||||
PUBLIC_CLI_DEMO_SUBCOMMANDS,
|
PUBLIC_CLI_DEMO_SUBCOMMANDS,
|
||||||
PUBLIC_CLI_DOCTOR_FLAGS,
|
|
||||||
PUBLIC_CLI_ENV_SUBCOMMANDS,
|
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_SERVE_FLAGS,
|
||||||
PUBLIC_CLI_MCP_SUBCOMMANDS,
|
PUBLIC_CLI_MCP_SUBCOMMANDS,
|
||||||
PUBLIC_CLI_PREPARE_FLAGS,
|
|
||||||
PUBLIC_CLI_RUN_FLAGS,
|
PUBLIC_CLI_RUN_FLAGS,
|
||||||
PUBLIC_CLI_WORKSPACE_CREATE_FLAGS,
|
PUBLIC_CLI_WORKSPACE_CREATE_FLAGS,
|
||||||
PUBLIC_CLI_WORKSPACE_DIFF_FLAGS,
|
PUBLIC_CLI_WORKSPACE_DIFF_FLAGS,
|
||||||
|
|
@ -60,16 +53,10 @@ from pyro_mcp.contract import (
|
||||||
PUBLIC_CLI_WORKSPACE_START_FLAGS,
|
PUBLIC_CLI_WORKSPACE_START_FLAGS,
|
||||||
PUBLIC_CLI_WORKSPACE_STOP_FLAGS,
|
PUBLIC_CLI_WORKSPACE_STOP_FLAGS,
|
||||||
PUBLIC_CLI_WORKSPACE_SUBCOMMANDS,
|
PUBLIC_CLI_WORKSPACE_SUBCOMMANDS,
|
||||||
PUBLIC_CLI_WORKSPACE_SUMMARY_FLAGS,
|
|
||||||
PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS,
|
PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS,
|
||||||
PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS,
|
PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS,
|
||||||
PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS,
|
PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS,
|
||||||
PUBLIC_MCP_COLD_START_MODE_TOOLS,
|
|
||||||
PUBLIC_MCP_INSPECT_MODE_TOOLS,
|
|
||||||
PUBLIC_MCP_MODES,
|
|
||||||
PUBLIC_MCP_PROFILES,
|
PUBLIC_MCP_PROFILES,
|
||||||
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
|
|
||||||
PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS,
|
|
||||||
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
|
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
|
||||||
PUBLIC_SDK_METHODS,
|
PUBLIC_SDK_METHODS,
|
||||||
)
|
)
|
||||||
|
|
@ -115,35 +102,6 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
|
||||||
env_help_text = _subparser_choice(parser, "env").format_help()
|
env_help_text = _subparser_choice(parser, "env").format_help()
|
||||||
for subcommand_name in PUBLIC_CLI_ENV_SUBCOMMANDS:
|
for subcommand_name in PUBLIC_CLI_ENV_SUBCOMMANDS:
|
||||||
assert subcommand_name in env_help_text
|
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()
|
mcp_help_text = _subparser_choice(parser, "mcp").format_help()
|
||||||
for subcommand_name in PUBLIC_CLI_MCP_SUBCOMMANDS:
|
for subcommand_name in PUBLIC_CLI_MCP_SUBCOMMANDS:
|
||||||
assert subcommand_name in mcp_help_text
|
assert subcommand_name in mcp_help_text
|
||||||
|
|
@ -152,8 +110,6 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
|
||||||
assert flag in mcp_serve_help_text
|
assert flag in mcp_serve_help_text
|
||||||
for profile_name in PUBLIC_MCP_PROFILES:
|
for profile_name in PUBLIC_MCP_PROFILES:
|
||||||
assert profile_name in mcp_serve_help_text
|
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()
|
workspace_help_text = _subparser_choice(parser, "workspace").format_help()
|
||||||
for subcommand_name in PUBLIC_CLI_WORKSPACE_SUBCOMMANDS:
|
for subcommand_name in PUBLIC_CLI_WORKSPACE_SUBCOMMANDS:
|
||||||
|
|
@ -288,11 +244,6 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
|
||||||
).format_help()
|
).format_help()
|
||||||
for flag in PUBLIC_CLI_WORKSPACE_STOP_FLAGS:
|
for flag in PUBLIC_CLI_WORKSPACE_STOP_FLAGS:
|
||||||
assert flag in workspace_stop_help_text
|
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(
|
workspace_shell_help_text = _subparser_choice(
|
||||||
_subparser_choice(parser, "workspace"),
|
_subparser_choice(parser, "workspace"),
|
||||||
"shell",
|
"shell",
|
||||||
|
|
@ -387,14 +338,6 @@ def test_public_mcp_tools_match_contract(tmp_path: Path) -> None:
|
||||||
assert tool_names == tuple(sorted(PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS))
|
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:
|
def test_pyproject_exposes_single_public_cli_script() -> None:
|
||||||
pyproject = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
|
pyproject = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
|
||||||
scripts = pyproject["project"]["scripts"]
|
scripts = pyproject["project"]["scripts"]
|
||||||
|
|
|
||||||
|
|
@ -6,32 +6,7 @@ from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
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.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:
|
def test_resolve_runtime_paths_default_bundle() -> None:
|
||||||
|
|
@ -134,7 +109,6 @@ def test_doctor_report_has_runtime_fields() -> None:
|
||||||
assert "runtime_ok" in report
|
assert "runtime_ok" in report
|
||||||
assert "kvm" in report
|
assert "kvm" in report
|
||||||
assert "networking" in report
|
assert "networking" in report
|
||||||
assert "daily_loop" in report
|
|
||||||
if report["runtime_ok"]:
|
if report["runtime_ok"]:
|
||||||
runtime = report.get("runtime")
|
runtime = report.get("runtime")
|
||||||
assert isinstance(runtime, dict)
|
assert isinstance(runtime, dict)
|
||||||
|
|
@ -148,61 +122,6 @@ def test_doctor_report_has_runtime_fields() -> None:
|
||||||
assert "tun_available" in networking
|
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:
|
def test_runtime_capabilities_reports_real_bundle_flags() -> None:
|
||||||
paths = resolve_runtime_paths()
|
paths = resolve_runtime_paths()
|
||||||
capabilities = runtime_capabilities(paths)
|
capabilities = runtime_capabilities(paths)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
|
|
@ -9,8 +8,6 @@ import pytest
|
||||||
|
|
||||||
import pyro_mcp.server as server_module
|
import pyro_mcp.server as server_module
|
||||||
from pyro_mcp.contract import (
|
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_VM_RUN_PROFILE_TOOLS,
|
||||||
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
|
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
|
||||||
)
|
)
|
||||||
|
|
@ -19,28 +16,6 @@ from pyro_mcp.vm_manager import VmManager
|
||||||
from pyro_mcp.vm_network import TapNetworkManager
|
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:
|
def test_create_server_registers_vm_tools(tmp_path: Path) -> None:
|
||||||
manager = VmManager(
|
manager = VmManager(
|
||||||
backend_name="mock",
|
backend_name="mock",
|
||||||
|
|
@ -87,151 +62,6 @@ 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))
|
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:
|
def test_vm_run_round_trip(tmp_path: Path) -> None:
|
||||||
manager = VmManager(
|
manager = VmManager(
|
||||||
backend_name="mock",
|
backend_name="mock",
|
||||||
|
|
@ -634,9 +464,6 @@ 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(
|
reset = _extract_structured(
|
||||||
await server.call_tool(
|
await server.call_tool(
|
||||||
"workspace_reset",
|
"workspace_reset",
|
||||||
|
|
@ -674,7 +501,6 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
||||||
service_status,
|
service_status,
|
||||||
service_logs,
|
service_logs,
|
||||||
service_stopped,
|
service_stopped,
|
||||||
summary,
|
|
||||||
reset,
|
reset,
|
||||||
deleted_snapshot,
|
deleted_snapshot,
|
||||||
logs,
|
logs,
|
||||||
|
|
@ -700,7 +526,6 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
||||||
service_status,
|
service_status,
|
||||||
service_logs,
|
service_logs,
|
||||||
service_stopped,
|
service_stopped,
|
||||||
summary,
|
|
||||||
reset,
|
reset,
|
||||||
deleted_snapshot,
|
deleted_snapshot,
|
||||||
logs,
|
logs,
|
||||||
|
|
@ -737,10 +562,6 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
||||||
assert service_logs["stderr"].count("[REDACTED]") >= 1
|
assert service_logs["stderr"].count("[REDACTED]") >= 1
|
||||||
assert service_logs["tail_lines"] is None
|
assert service_logs["tail_lines"] is None
|
||||||
assert service_stopped["state"] == "stopped"
|
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["workspace_reset"]["snapshot_name"] == "checkpoint"
|
||||||
assert reset["secrets"] == created["secrets"]
|
assert reset["secrets"] == created["secrets"]
|
||||||
assert reset["command_count"] == 0
|
assert reset["command_count"] == 0
|
||||||
|
|
|
||||||
|
|
@ -699,124 +699,6 @@ def test_workspace_diff_and_export_round_trip(tmp_path: Path) -> None:
|
||||||
assert logs["count"] == 0
|
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:
|
def test_workspace_file_ops_and_patch_round_trip(tmp_path: Path) -> None:
|
||||||
seed_dir = tmp_path / "seed"
|
seed_dir = tmp_path / "seed"
|
||||||
seed_dir.mkdir()
|
seed_dir.mkdir()
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,6 @@ import pytest
|
||||||
from pyro_mcp import workspace_ports
|
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):
|
class _EchoHandler(socketserver.BaseRequestHandler):
|
||||||
def handle(self) -> None:
|
def handle(self) -> None:
|
||||||
data = self.request.recv(65536)
|
data = self.request.recv(65536)
|
||||||
|
|
@ -57,26 +50,18 @@ def test_workspace_port_proxy_handler_ignores_upstream_connect_failure(
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_port_proxy_forwards_tcp_traffic() -> None:
|
def test_workspace_port_proxy_forwards_tcp_traffic() -> None:
|
||||||
try:
|
|
||||||
upstream = socketserver.ThreadingTCPServer(
|
upstream = socketserver.ThreadingTCPServer(
|
||||||
(workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0),
|
(workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0),
|
||||||
_EchoHandler,
|
_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 = threading.Thread(target=upstream.serve_forever, daemon=True)
|
||||||
upstream_thread.start()
|
upstream_thread.start()
|
||||||
upstream_host = str(upstream.server_address[0])
|
upstream_host = str(upstream.server_address[0])
|
||||||
upstream_port = int(upstream.server_address[1])
|
upstream_port = int(upstream.server_address[1])
|
||||||
try:
|
|
||||||
proxy = workspace_ports._ProxyServer( # noqa: SLF001
|
proxy = workspace_ports._ProxyServer( # noqa: SLF001
|
||||||
(workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0),
|
(workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0),
|
||||||
(upstream_host, upstream_port),
|
(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 = threading.Thread(target=proxy.serve_forever, daemon=True)
|
||||||
proxy_thread.start()
|
proxy_thread.start()
|
||||||
try:
|
try:
|
||||||
|
|
@ -217,8 +202,8 @@ def test_workspace_ports_main_shutdown_handler_stops_server(
|
||||||
def test_workspace_port_proxy_handler_handles_empty_and_invalid_selector_events(
|
def test_workspace_port_proxy_handler_handles_empty_and_invalid_selector_events(
|
||||||
monkeypatch: Any,
|
monkeypatch: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
source, source_peer = _socketpair_or_skip()
|
source, source_peer = socket.socketpair()
|
||||||
upstream, upstream_peer = _socketpair_or_skip()
|
upstream, upstream_peer = socket.socketpair()
|
||||||
source_peer.close()
|
source_peer.close()
|
||||||
|
|
||||||
class FakeSelector:
|
class FakeSelector:
|
||||||
|
|
@ -261,17 +246,10 @@ def test_workspace_port_proxy_handler_handles_recv_and_send_errors(
|
||||||
monkeypatch: Any,
|
monkeypatch: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
def _run_once(*, close_source: bool) -> None:
|
def _run_once(*, close_source: bool) -> None:
|
||||||
source, source_peer = _socketpair_or_skip()
|
source, source_peer = socket.socketpair()
|
||||||
upstream, upstream_peer = _socketpair_or_skip()
|
upstream, upstream_peer = socket.socketpair()
|
||||||
if not close_source:
|
if not close_source:
|
||||||
try:
|
|
||||||
source_peer.sendall(b"hello")
|
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:
|
class FakeSelector:
|
||||||
def register(self, *_args: Any, **_kwargs: Any) -> None:
|
def register(self, *_args: Any, **_kwargs: Any) -> None:
|
||||||
|
|
|
||||||
|
|
@ -391,69 +391,6 @@ class _FakePyro:
|
||||||
"workspace_reset": {"snapshot_name": snapshot},
|
"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]:
|
def open_shell(self, workspace_id: str, **_: Any) -> dict[str, Any]:
|
||||||
workspace = self._resolve_workspace(workspace_id)
|
workspace = self._resolve_workspace(workspace_id)
|
||||||
self._shell_counter += 1
|
self._shell_counter += 1
|
||||||
|
|
@ -499,43 +436,6 @@ class _FakePyro:
|
||||||
workspace.shells.pop(shell_id, None)
|
workspace.shells.pop(shell_id, None)
|
||||||
return {"workspace_id": workspace_id, "shell_id": shell_id, "closed": True}
|
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:
|
def test_use_case_registry_has_expected_scenarios() -> None:
|
||||||
expected = (
|
expected = (
|
||||||
|
|
@ -561,7 +461,7 @@ def test_use_case_docs_and_targets_stay_aligned() -> None:
|
||||||
recipe_text = (repo_root / recipe.doc_path).read_text(encoding="utf-8")
|
recipe_text = (repo_root / recipe.doc_path).read_text(encoding="utf-8")
|
||||||
assert recipe.smoke_target in index_text
|
assert recipe.smoke_target in index_text
|
||||||
assert recipe.doc_path.rsplit("/", 1)[-1] in index_text
|
assert recipe.doc_path.rsplit("/", 1)[-1] in index_text
|
||||||
assert recipe.mode in recipe_text
|
assert recipe.profile in recipe_text
|
||||||
assert recipe.smoke_target in recipe_text
|
assert recipe.smoke_target in recipe_text
|
||||||
assert f"{recipe.smoke_target}:" in makefile_text
|
assert f"{recipe.smoke_target}:" in makefile_text
|
||||||
|
|
||||||
|
|
|
||||||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -715,7 +715,7 @@ crypto = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyro-mcp"
|
name = "pyro-mcp"
|
||||||
version = "4.5.0"
|
version = "4.0.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "mcp" },
|
{ name = "mcp" },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue