Compare commits
8 commits
6433847185
...
aeed5e1943
| Author | SHA1 | Date | |
|---|---|---|---|
| aeed5e1943 | |||
| 663241d5d2 | |||
| d0cf6d8f21 | |||
| dc86d84e96 | |||
| 899a6760c4 | |||
| 535efc6919 | |||
| 9b9b83ebeb | |||
| 999fe1b23a |
58 changed files with 7145 additions and 1935 deletions
57
CHANGELOG.md
57
CHANGELOG.md
|
|
@ -2,6 +2,63 @@
|
|||
|
||||
All notable user-visible changes to `pyro-mcp` are documented here.
|
||||
|
||||
## 4.5.0
|
||||
|
||||
- Added `pyro prepare` as the machine-level warmup path for the daily local
|
||||
loop, with cached reuse when the runtime, catalog, and environment state are
|
||||
already warm.
|
||||
- Extended `pyro doctor` with daily-loop readiness output so users can see
|
||||
whether the machine is cold, warm, or stale for `debian:12` before they
|
||||
reconnect a chat host.
|
||||
- Added `make smoke-daily-loop` to prove the warmed repro/fix/reset path end
|
||||
to end on a real guest-backed machine.
|
||||
|
||||
## 4.4.0
|
||||
|
||||
- Added explicit named MCP/server modes for the main workspace workflows:
|
||||
`repro-fix`, `inspect`, `cold-start`, and `review-eval`.
|
||||
- Kept the generic no-mode `workspace-core` path available as the escape hatch,
|
||||
while making named modes the first user-facing story across help text, host
|
||||
helpers, and the recipe docs.
|
||||
- Aligned the shared use-case smoke runner with those modes so the repro/fix
|
||||
and cold-start flows now prove a mode-backed happy path instead of only the
|
||||
generic profile path.
|
||||
|
||||
## 4.3.0
|
||||
|
||||
- Added `pyro workspace summary`, `Pyro.summarize_workspace()`, and MCP
|
||||
`workspace_summary` so users and chat hosts can review a concise view of the
|
||||
current workspace session since the last reset.
|
||||
- Added a lightweight review-event log for edits, syncs, exports, service
|
||||
lifecycle, and snapshot activity without duplicating the command journal.
|
||||
- Updated the main workspace walkthroughs and review/eval recipe so
|
||||
`workspace summary` is the first review surface before dropping down to raw
|
||||
diffs, logs, and exported files.
|
||||
|
||||
## 4.2.0
|
||||
|
||||
- Added host bootstrap and repair helpers with `pyro host connect`,
|
||||
`pyro host print-config`, `pyro host doctor`, and `pyro host repair` for the
|
||||
supported Claude Code, Codex, and OpenCode flows.
|
||||
- Repositioned the docs and examples so supported hosts now start from the
|
||||
helper flow first, while keeping raw `pyro mcp serve` commands as the
|
||||
underlying MCP entrypoint and advanced fallback.
|
||||
- Added deterministic host-helper coverage so the shipped helper commands and
|
||||
OpenCode config snippet stay aligned with the canonical `pyro mcp serve`
|
||||
command shape.
|
||||
|
||||
## 4.1.0
|
||||
|
||||
- Added project-aware MCP startup so bare `pyro mcp serve` from a repo root can
|
||||
auto-detect the current Git checkout and let `workspace_create` omit
|
||||
`seed_path` safely.
|
||||
- Added explicit fallback startup flags for chat hosts that do not preserve the
|
||||
server working directory: `--project-path`, `--repo-url`, `--repo-ref`, and
|
||||
`--no-project-source`.
|
||||
- Extended workspace seed metadata with startup origin fields so chat-facing
|
||||
workspace creation can show whether a workspace came from a manual seed path,
|
||||
the current project, or a clean cloned repo source.
|
||||
|
||||
## 4.0.0
|
||||
|
||||
- Flipped the default MCP/server profile from `workspace-full` to
|
||||
|
|
|
|||
27
Makefile
27
Makefile
|
|
@ -1,6 +1,7 @@
|
|||
PYTHON ?= uv run python
|
||||
UV_CACHE_DIR ?= .uv-cache
|
||||
PYTEST_FLAGS ?= -n auto
|
||||
PYTEST_WORKERS ?= $(shell sh -c 'n=$$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc 2>/dev/null || echo 2); if [ "$$n" -gt 8 ]; then n=8; fi; if [ "$$n" -lt 2 ]; then echo 1; else echo $$n; fi')
|
||||
PYTEST_FLAGS ?= -n $(PYTEST_WORKERS)
|
||||
OLLAMA_BASE_URL ?= http://localhost:11434/v1
|
||||
OLLAMA_MODEL ?= llama3.2:3b
|
||||
OLLAMA_DEMO_FLAGS ?=
|
||||
|
|
@ -17,8 +18,9 @@ TWINE_USERNAME ?= __token__
|
|||
PYPI_REPOSITORY_URL ?=
|
||||
USE_CASE_ENVIRONMENT ?= debian:12
|
||||
USE_CASE_SMOKE_FLAGS ?=
|
||||
DAILY_LOOP_ENVIRONMENT ?= debian:12
|
||||
|
||||
.PHONY: help setup lint format typecheck test check dist-check pypi-publish demo network-demo doctor ollama ollama-demo run-server install-hooks smoke-use-cases smoke-cold-start-validation smoke-repro-fix-loop smoke-parallel-workspaces smoke-untrusted-inspection smoke-review-eval runtime-bundle runtime-binaries runtime-kernel runtime-rootfs runtime-agent runtime-validate runtime-manifest runtime-sync runtime-clean runtime-fetch-binaries runtime-build-kernel-real runtime-build-rootfs-real runtime-materialize runtime-export-environment-oci runtime-export-official-environments-oci runtime-publish-environment-oci runtime-publish-official-environments-oci runtime-boot-check runtime-network-check
|
||||
.PHONY: help setup lint format typecheck test check dist-check pypi-publish demo network-demo doctor ollama ollama-demo run-server install-hooks smoke-daily-loop smoke-use-cases smoke-cold-start-validation smoke-repro-fix-loop smoke-parallel-workspaces smoke-untrusted-inspection smoke-review-eval runtime-bundle runtime-binaries runtime-kernel runtime-rootfs runtime-agent runtime-validate runtime-manifest runtime-sync runtime-clean runtime-fetch-binaries runtime-build-kernel-real runtime-build-rootfs-real runtime-materialize runtime-export-environment-oci runtime-export-official-environments-oci runtime-publish-environment-oci runtime-publish-official-environments-oci runtime-boot-check runtime-network-check
|
||||
|
||||
help:
|
||||
@printf '%s\n' \
|
||||
|
|
@ -35,6 +37,7 @@ help:
|
|||
' demo Run the deterministic VM demo' \
|
||||
' network-demo Run the deterministic VM demo with guest networking enabled' \
|
||||
' doctor Show runtime and host diagnostics' \
|
||||
' smoke-daily-loop Run the real guest-backed prepare plus reset daily-loop smoke' \
|
||||
' smoke-use-cases Run all real guest-backed workspace use-case smokes' \
|
||||
' smoke-cold-start-validation Run the cold-start repo validation smoke' \
|
||||
' smoke-repro-fix-loop Run the repro-plus-fix loop smoke' \
|
||||
|
|
@ -82,13 +85,16 @@ test:
|
|||
check: lint typecheck test
|
||||
|
||||
dist-check:
|
||||
.venv/bin/pyro --version
|
||||
.venv/bin/pyro --help >/dev/null
|
||||
.venv/bin/pyro mcp --help >/dev/null
|
||||
.venv/bin/pyro run --help >/dev/null
|
||||
.venv/bin/pyro env list >/dev/null
|
||||
.venv/bin/pyro env inspect debian:12 >/dev/null
|
||||
.venv/bin/pyro doctor >/dev/null
|
||||
uv run python -m pyro_mcp.cli --version
|
||||
uv run python -m pyro_mcp.cli --help >/dev/null
|
||||
uv run python -m pyro_mcp.cli prepare --help >/dev/null
|
||||
uv run python -m pyro_mcp.cli host --help >/dev/null
|
||||
uv run python -m pyro_mcp.cli host doctor >/dev/null
|
||||
uv run python -m pyro_mcp.cli mcp --help >/dev/null
|
||||
uv run python -m pyro_mcp.cli run --help >/dev/null
|
||||
uv run python -m pyro_mcp.cli env list >/dev/null
|
||||
uv run python -m pyro_mcp.cli env inspect debian:12 >/dev/null
|
||||
uv run python -m pyro_mcp.cli doctor --environment debian:12 >/dev/null
|
||||
|
||||
pypi-publish:
|
||||
@if [ -z "$$TWINE_PASSWORD" ]; then \
|
||||
|
|
@ -113,6 +119,9 @@ network-demo:
|
|||
doctor:
|
||||
uv run pyro doctor
|
||||
|
||||
smoke-daily-loop:
|
||||
uv run python scripts/daily_loop_smoke.py --environment "$(DAILY_LOOP_ENVIRONMENT)"
|
||||
|
||||
smoke-use-cases:
|
||||
uv run python scripts/workspace_use_case_smoke.py --scenario all --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS)
|
||||
|
||||
|
|
|
|||
698
README.md
698
README.md
|
|
@ -1,34 +1,50 @@
|
|||
# pyro-mcp
|
||||
|
||||
`pyro-mcp` is a stable agent workspace product for one-shot commands and persistent work inside ephemeral Firecracker microVMs using curated Linux environments such as `debian:12`.
|
||||
`pyro-mcp` is a disposable MCP workspace for chat-based coding agents such as
|
||||
Claude Code, Codex, and OpenCode.
|
||||
|
||||
It is built for Linux `x86_64` hosts with working KVM. The product path is:
|
||||
|
||||
1. prove the host works
|
||||
2. connect a chat host over MCP
|
||||
3. let the agent work inside a disposable workspace
|
||||
4. validate the workflow with the recipe-backed smoke pack
|
||||
|
||||
`pyro-mcp` currently has no users. Expect breaking changes while this chat-host
|
||||
path is still being shaped.
|
||||
|
||||
This repo is not trying to be a generic VM toolkit, a CI runner, or an
|
||||
SDK-first platform.
|
||||
|
||||
[](https://pypi.org/project/pyro-mcp/)
|
||||
|
||||
This is for coding agents, MCP clients, and developers who want isolated command execution and stable disposable workspaces in ephemeral microVMs.
|
||||
|
||||
It exposes the same runtime in three public forms:
|
||||
|
||||
- the `pyro` CLI
|
||||
- the Python SDK via `from pyro_mcp import Pyro`
|
||||
- an MCP server so LLM clients can call VM tools directly
|
||||
|
||||
## Start Here
|
||||
|
||||
- Install: [docs/install.md](docs/install.md)
|
||||
- Vision: [docs/vision.md](docs/vision.md)
|
||||
- Workspace GA roadmap: [docs/roadmap/task-workspace-ga.md](docs/roadmap/task-workspace-ga.md)
|
||||
- LLM chat roadmap: [docs/roadmap/llm-chat-ergonomics.md](docs/roadmap/llm-chat-ergonomics.md)
|
||||
- Use-case recipes: [docs/use-cases/README.md](docs/use-cases/README.md)
|
||||
- Install and zero-to-hero path: [docs/install.md](docs/install.md)
|
||||
- First run transcript: [docs/first-run.md](docs/first-run.md)
|
||||
- Chat host integrations: [docs/integrations.md](docs/integrations.md)
|
||||
- Use-case recipes: [docs/use-cases/README.md](docs/use-cases/README.md)
|
||||
- Vision: [docs/vision.md](docs/vision.md)
|
||||
- Public contract: [docs/public-contract.md](docs/public-contract.md)
|
||||
- Host requirements: [docs/host-requirements.md](docs/host-requirements.md)
|
||||
- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md)
|
||||
- Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif)
|
||||
- Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif)
|
||||
- What's new in 4.5.0: [CHANGELOG.md#450](CHANGELOG.md#450)
|
||||
- PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/)
|
||||
- What's new in 4.0.0: [CHANGELOG.md#400](CHANGELOG.md#400)
|
||||
- Host requirements: [docs/host-requirements.md](docs/host-requirements.md)
|
||||
- Integration targets: [docs/integrations.md](docs/integrations.md)
|
||||
- Public contract: [docs/public-contract.md](docs/public-contract.md)
|
||||
- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md)
|
||||
- Changelog: [CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## Who It's For
|
||||
|
||||
- Claude Code users who want disposable workspaces instead of running directly
|
||||
on the host
|
||||
- Codex users who want an MCP-backed sandbox for repo setup, bug fixing, and
|
||||
evaluation loops
|
||||
- OpenCode users who want the same disposable workspace model
|
||||
- people evaluating repo setup, test, and app-start workflows from a chat
|
||||
interface on a clean machine
|
||||
|
||||
If you want a general VM platform, a queueing system, or a broad SDK product,
|
||||
this repo is intentionally biased away from that story.
|
||||
|
||||
## Quickstart
|
||||
|
||||
|
|
@ -38,8 +54,7 @@ Use either of these equivalent quickstart paths:
|
|||
# Package without install
|
||||
python -m pip install uv
|
||||
uvx --from pyro-mcp pyro doctor
|
||||
uvx --from pyro-mcp pyro env list
|
||||
uvx --from pyro-mcp pyro env pull debian:12
|
||||
uvx --from pyro-mcp pyro prepare debian:12
|
||||
uvx --from pyro-mcp pyro run debian:12 -- git --version
|
||||
```
|
||||
|
||||
|
|
@ -48,8 +63,7 @@ uvx --from pyro-mcp pyro run debian:12 -- git --version
|
|||
```bash
|
||||
# Already installed
|
||||
pyro doctor
|
||||
pyro env list
|
||||
pyro env pull debian:12
|
||||
pyro prepare debian:12
|
||||
pyro run debian:12 -- git --version
|
||||
```
|
||||
|
||||
|
|
@ -60,7 +74,7 @@ What success looks like:
|
|||
```bash
|
||||
Platform: linux-x86_64
|
||||
Runtime: PASS
|
||||
Catalog version: 4.0.0
|
||||
Catalog version: 4.4.0
|
||||
...
|
||||
[pull] phase=install environment=debian:12
|
||||
[pull] phase=ready environment=debian:12
|
||||
|
|
@ -73,89 +87,76 @@ Pulled: debian:12
|
|||
git version ...
|
||||
```
|
||||
|
||||
The first pull downloads an OCI environment from public Docker Hub, requires outbound HTTPS
|
||||
access to `registry-1.docker.io`, and needs local cache space for the guest image.
|
||||
|
||||
## Stable Workspace Path
|
||||
|
||||
`pyro run` is the stable one-shot entrypoint. `pyro workspace ...` is the stable path when an
|
||||
agent needs one sandbox to stay alive across repeated commands, shells, services, checkpoints,
|
||||
diffs, exports, and reset.
|
||||
|
||||
After that stable walkthrough works, continue with the recipe set in
|
||||
[docs/use-cases/README.md](docs/use-cases/README.md). It packages the five core workspace stories
|
||||
into documented flows plus real guest-backed smoke targets such as `make smoke-use-cases` and
|
||||
`make smoke-repro-fix-loop`. At this point `make smoke-use-cases` is the
|
||||
trustworthy guest-backed release-gate path for the advertised workspace workflows.
|
||||
|
||||
The commands below use plain `pyro ...`. Run the same flow with `uvx --from pyro-mcp pyro ...`
|
||||
for the published package, or `uv run pyro ...` from a source checkout.
|
||||
|
||||
```bash
|
||||
uv tool install pyro-mcp
|
||||
WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --id-only)"
|
||||
pyro workspace list
|
||||
pyro workspace update "$WORKSPACE_ID" --label owner=codex
|
||||
pyro workspace sync push "$WORKSPACE_ID" ./changes
|
||||
pyro workspace file read "$WORKSPACE_ID" note.txt --content-only
|
||||
pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch
|
||||
pyro workspace exec "$WORKSPACE_ID" -- cat note.txt
|
||||
pyro workspace snapshot create "$WORKSPACE_ID" checkpoint
|
||||
pyro workspace service start "$WORKSPACE_ID" web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
|
||||
pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint
|
||||
pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt
|
||||
pyro workspace delete "$WORKSPACE_ID"
|
||||
```
|
||||
|
||||

|
||||
|
||||
That stable workspace path gives you:
|
||||
|
||||
- initial host-in seeding with `--seed-path`
|
||||
- discovery metadata with `--name`, `--label`, `workspace list`, and `workspace update`
|
||||
- later host-in updates with `workspace sync push`
|
||||
- model-native file inspection and text edits with `workspace file *` and `workspace patch apply`
|
||||
- one-shot commands with `workspace exec` and persistent PTYs with `workspace shell *`
|
||||
- long-running processes with `workspace service *`
|
||||
- explicit checkpoints with `workspace snapshot *`
|
||||
- full-sandbox recovery with `workspace reset`
|
||||
- baseline comparison with `workspace diff`
|
||||
- explicit host-out export with `workspace export`
|
||||
- secondary stopped-workspace disk inspection with `workspace stop|start` and `workspace disk *`
|
||||
|
||||
After the quickstart works:
|
||||
|
||||
- prove the full one-shot lifecycle with `uvx --from pyro-mcp pyro demo`
|
||||
- start most chat hosts with `uvx --from pyro-mcp pyro mcp serve`
|
||||
- create a persistent workspace with `uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo`
|
||||
- add a human-friendly workspace name with `uvx --from pyro-mcp pyro workspace create debian:12 --name repro-fix --label issue=123`
|
||||
- rediscover or retag workspaces with `uvx --from pyro-mcp pyro workspace list` and `uvx --from pyro-mcp pyro workspace update WORKSPACE_ID --label owner=codex`
|
||||
- update a live workspace from the host with `uvx --from pyro-mcp pyro workspace sync push WORKSPACE_ID ./changes`
|
||||
- enable outbound guest networking for one workspace with `uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress`
|
||||
- add literal or file-backed secrets with `uvx --from pyro-mcp pyro workspace create debian:12 --secret API_TOKEN=expected --secret-file PIP_TOKEN=./token.txt`
|
||||
- map one persisted secret into one exec, shell, or service call with `--secret-env API_TOKEN`
|
||||
- inspect and edit files without shell quoting with `uvx --from pyro-mcp pyro workspace file read WORKSPACE_ID src/app.py --content-only`, `uvx --from pyro-mcp pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py`, and `uvx --from pyro-mcp pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch`
|
||||
- diff the live workspace against its create-time baseline with `uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID`
|
||||
- capture a checkpoint with `uvx --from pyro-mcp pyro workspace snapshot create WORKSPACE_ID checkpoint`
|
||||
- reset a broken workspace with `uvx --from pyro-mcp pyro workspace reset WORKSPACE_ID --snapshot checkpoint`
|
||||
- export a changed file or directory with `uvx --from pyro-mcp pyro workspace export WORKSPACE_ID note.txt --output ./note.txt`
|
||||
- open a persistent interactive shell with `uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID --id-only`
|
||||
- start long-running workspace services with `uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done'`
|
||||
- publish one guest service port to the host with `uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress+published-ports` and `uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app`
|
||||
- stop a workspace for offline inspection with `uvx --from pyro-mcp pyro workspace stop WORKSPACE_ID`
|
||||
- inspect or export one stopped guest rootfs with `uvx --from pyro-mcp pyro workspace disk list WORKSPACE_ID`, `uvx --from pyro-mcp pyro workspace disk read WORKSPACE_ID note.txt --content-only`, and `uvx --from pyro-mcp pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4`
|
||||
- move to Python or MCP via [docs/integrations.md](docs/integrations.md)
|
||||
The first pull downloads an OCI environment from public Docker Hub, requires
|
||||
outbound HTTPS access to `registry-1.docker.io`, and needs local cache space
|
||||
for the guest image. `pyro prepare debian:12` performs that install step
|
||||
automatically, then proves create, exec, reset, and delete on one throwaway
|
||||
workspace so the daily loop is warm before the chat host connects.
|
||||
|
||||
## Chat Host Quickstart
|
||||
|
||||
For most MCP chat hosts, bare `pyro mcp serve` now starts `workspace-core`. It exposes the practical
|
||||
persistent editing loop without shells, services, snapshots, secrets, network
|
||||
policy, or disk tools.
|
||||
After the quickstart works, make the daily loop explicit before you connect the
|
||||
chat host:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro doctor --environment debian:12
|
||||
uvx --from pyro-mcp pyro prepare debian:12
|
||||
```
|
||||
|
||||
Then connect a chat host in one named mode. Use the helper flow first:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro host connect codex --mode repro-fix
|
||||
uvx --from pyro-mcp pyro host connect codex --mode inspect
|
||||
uvx --from pyro-mcp pyro host connect claude-code --mode cold-start
|
||||
uvx --from pyro-mcp pyro host connect claude-code --mode review-eval
|
||||
uvx --from pyro-mcp pyro host print-config opencode --mode repro-fix
|
||||
```
|
||||
|
||||
If setup drifts or you want to inspect it first:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro host doctor
|
||||
uvx --from pyro-mcp pyro host repair claude-code
|
||||
uvx --from pyro-mcp pyro host repair codex
|
||||
uvx --from pyro-mcp pyro host repair opencode
|
||||
```
|
||||
|
||||
Those helpers wrap the same `pyro mcp serve` entrypoint. Use a named mode when
|
||||
one workflow already matches the job. Fall back to the generic no-mode path
|
||||
when the mode feels too narrow.
|
||||
|
||||
Mode examples:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
||||
uvx --from pyro-mcp pyro mcp serve --mode inspect
|
||||
uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
||||
uvx --from pyro-mcp pyro mcp serve --mode review-eval
|
||||
```
|
||||
|
||||
Generic escape hatch:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro mcp serve
|
||||
```
|
||||
|
||||
From a repo root, the generic path auto-detects the current Git checkout and
|
||||
lets the first `workspace_create` omit `seed_path`. If the host does not
|
||||
preserve the server working directory, use:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro host connect codex --project-path /abs/path/to/repo
|
||||
uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
|
||||
```
|
||||
|
||||
If you are starting outside a local checkout, use a clean clone source:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro host connect codex --repo-url https://github.com/example/project.git
|
||||
uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git
|
||||
```
|
||||
|
||||
Copy-paste host-specific starts:
|
||||
|
||||
- Claude Code: [examples/claude_code_mcp.md](examples/claude_code_mcp.md)
|
||||
|
|
@ -163,16 +164,16 @@ Copy-paste host-specific starts:
|
|||
- OpenCode: [examples/opencode_mcp_config.json](examples/opencode_mcp_config.json)
|
||||
- Generic MCP config: [examples/mcp_client_config.md](examples/mcp_client_config.md)
|
||||
|
||||
Claude Code:
|
||||
Claude Code cold-start or review-eval:
|
||||
|
||||
```bash
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
||||
```
|
||||
|
||||
Codex:
|
||||
Codex repro-fix or inspect:
|
||||
|
||||
```bash
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
||||
```
|
||||
|
||||
OpenCode `opencode.json` snippet:
|
||||
|
|
@ -183,229 +184,72 @@ OpenCode `opencode.json` snippet:
|
|||
"pyro": {
|
||||
"type": "local",
|
||||
"enabled": true,
|
||||
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]
|
||||
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If `pyro-mcp` is already installed, replace the `uvx --from pyro-mcp pyro`
|
||||
command with `pyro` in the same host-specific command or config shape. Use
|
||||
`--profile workspace-full` only when the host truly needs the full advanced
|
||||
workspace surface.
|
||||
If OpenCode launches the server from an unexpected cwd, use
|
||||
`pyro host print-config opencode --project-path /abs/path/to/repo` or add
|
||||
`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same command
|
||||
array.
|
||||
|
||||
Profile progression:
|
||||
If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with
|
||||
`pyro` in the same command or config shape.
|
||||
|
||||
- `workspace-core`: default and recommended first profile for normal persistent chat editing
|
||||
- `vm-run`: smallest one-shot-only surface
|
||||
- `workspace-full`: explicit advanced opt-in when the chat truly needs shells, services, snapshots, secrets, network policy, or disk tools
|
||||
Use the generic no-mode path when the named mode feels too narrow. Move to
|
||||
`--profile workspace-full` only when the chat truly needs shells, services,
|
||||
snapshots, secrets, network policy, or disk tools.
|
||||
|
||||
## Supported Hosts
|
||||
## Zero To Hero
|
||||
|
||||
Supported today:
|
||||
1. Validate the host with `pyro doctor`.
|
||||
2. Warm the machine-level daily loop with `pyro prepare debian:12`.
|
||||
3. Prove guest execution with `pyro run debian:12 -- git --version`.
|
||||
4. Connect Claude Code, Codex, or OpenCode with one named mode such as
|
||||
`pyro host connect codex --mode repro-fix`, then fall back to raw
|
||||
`pyro mcp serve --mode ...` or the generic no-mode path when needed.
|
||||
5. Start with one recipe from [docs/use-cases/README.md](docs/use-cases/README.md).
|
||||
`repro-fix` is the shortest chat-first mode and story.
|
||||
6. Use `workspace reset` as the normal retry step inside that warmed loop.
|
||||
7. Use `make smoke-use-cases` as the trustworthy guest-backed verification path
|
||||
for the advertised workflows.
|
||||
|
||||
- Linux x86_64
|
||||
- Python 3.12+
|
||||
- `uv`
|
||||
- `/dev/kvm`
|
||||
That is the intended user journey. The terminal commands exist to validate and
|
||||
debug that chat-host path, not to replace it as the main product story.
|
||||
|
||||
Optional for outbound guest networking:
|
||||
## Manual Terminal Workspace Flow
|
||||
|
||||
- `ip`
|
||||
- `nft` or `iptables`
|
||||
- privilege to create TAP devices and configure NAT
|
||||
|
||||
Not supported today:
|
||||
|
||||
- macOS
|
||||
- Windows
|
||||
- Linux hosts without working KVM at `/dev/kvm`
|
||||
|
||||
## Detailed Walkthrough
|
||||
|
||||
If you want the expanded version of the canonical quickstart, use the step-by-step flow below.
|
||||
|
||||
### 1. Check the host
|
||||
If you want to understand what the agent gets inside the sandbox, or debug a
|
||||
recipe outside the chat host, use the terminal companion flow below:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro doctor
|
||||
uv tool install pyro-mcp
|
||||
WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --id-only)"
|
||||
pyro workspace list
|
||||
pyro workspace sync push "$WORKSPACE_ID" ./changes
|
||||
pyro workspace file read "$WORKSPACE_ID" note.txt --content-only
|
||||
pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch
|
||||
pyro workspace exec "$WORKSPACE_ID" -- cat note.txt
|
||||
pyro workspace summary "$WORKSPACE_ID"
|
||||
pyro workspace snapshot create "$WORKSPACE_ID" checkpoint
|
||||
pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint
|
||||
pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt
|
||||
pyro workspace delete "$WORKSPACE_ID"
|
||||
```
|
||||
|
||||
Expected success signals:
|
||||
Add `workspace-full` only when the chat or your manual debugging loop really
|
||||
needs:
|
||||
|
||||
```bash
|
||||
Platform: linux-x86_64
|
||||
Runtime: PASS
|
||||
KVM: exists=yes readable=yes writable=yes
|
||||
Environment cache: /home/you/.cache/pyro-mcp/environments
|
||||
Capabilities: vm_boot=yes guest_exec=yes guest_network=yes
|
||||
Networking: tun=yes ip_forward=yes
|
||||
```
|
||||
- persistent PTY shells
|
||||
- long-running services and readiness probes
|
||||
- guest networking and published ports
|
||||
- secrets
|
||||
- stopped-workspace disk inspection
|
||||
|
||||
### 2. Inspect the catalog
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro env list
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```bash
|
||||
Catalog version: 4.0.0
|
||||
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
|
||||
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
|
||||
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
|
||||
```
|
||||
|
||||
### 3. Pull the default environment
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro env pull debian:12
|
||||
```
|
||||
|
||||
The first pull downloads an OCI environment from public Docker Hub, requires outbound HTTPS
|
||||
access to `registry-1.docker.io`, and needs local cache space for the guest image.
|
||||
See [docs/host-requirements.md](docs/host-requirements.md) for the full host requirements.
|
||||
|
||||
### 4. Run one command in a guest
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro run debian:12 -- git --version
|
||||
```
|
||||
|
||||
Expected success signals:
|
||||
|
||||
```bash
|
||||
[run] environment=debian:12 execution_mode=guest_vsock exit_code=0 duration_ms=...
|
||||
git version ...
|
||||
```
|
||||
|
||||
The guest command output and the `[run] ...` summary are written to different streams, so they
|
||||
may appear in either order in terminals or capture tools. Use `--json` if you need a
|
||||
deterministic structured result.
|
||||
|
||||
### 5. Optional demos
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro demo
|
||||
uvx --from pyro-mcp pyro demo --network
|
||||
```
|
||||
|
||||
`pyro demo` proves the one-shot create/start/exec/delete VM lifecycle works end to end.
|
||||
|
||||
Example output:
|
||||
|
||||
```json
|
||||
{
|
||||
"cleanup": {
|
||||
"deleted": true,
|
||||
"reason": "post_exec_cleanup",
|
||||
"vm_id": "..."
|
||||
},
|
||||
"command": "git --version",
|
||||
"environment": "debian:12",
|
||||
"execution_mode": "guest_vsock",
|
||||
"exit_code": 0,
|
||||
"stdout": "git version ...\n"
|
||||
}
|
||||
```
|
||||
|
||||
When you are done evaluating and want to remove stale cached environments, run `pyro env prune`.
|
||||
|
||||
If you prefer a fuller copy-pasteable transcript, see [docs/first-run.md](docs/first-run.md).
|
||||
The walkthrough GIF above was rendered from [docs/assets/first-run.tape](docs/assets/first-run.tape) using [scripts/render_tape.sh](scripts/render_tape.sh).
|
||||
|
||||
## Stable Workspaces
|
||||
|
||||
Use `pyro run` for one-shot commands. Use `pyro workspace ...` when you need repeated commands in one
|
||||
workspace without recreating the sandbox every time.
|
||||
|
||||
The project direction is an agent workspace, not a CI job runner. Persistent
|
||||
workspaces are meant to let an agent stay inside one bounded sandbox across multiple
|
||||
steps. See [docs/vision.md](docs/vision.md) for the product thesis and the
|
||||
longer-term interaction model.
|
||||
|
||||
```bash
|
||||
pyro workspace create debian:12 --seed-path ./repo
|
||||
pyro workspace create debian:12 --network-policy egress
|
||||
pyro workspace create debian:12 --seed-path ./repo --secret API_TOKEN=expected
|
||||
pyro workspace create debian:12 --network-policy egress+published-ports
|
||||
pyro workspace sync push WORKSPACE_ID ./changes --dest src
|
||||
pyro workspace file list WORKSPACE_ID src --recursive
|
||||
pyro workspace file read WORKSPACE_ID src/note.txt --content-only
|
||||
pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py
|
||||
pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch
|
||||
pyro workspace exec WORKSPACE_ID -- cat src/note.txt
|
||||
pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"'
|
||||
pyro workspace diff WORKSPACE_ID
|
||||
pyro workspace snapshot create WORKSPACE_ID checkpoint
|
||||
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
|
||||
pyro workspace reset WORKSPACE_ID
|
||||
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
|
||||
pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN --id-only
|
||||
pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'
|
||||
pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300
|
||||
pyro workspace shell close WORKSPACE_ID SHELL_ID
|
||||
pyro workspace service start WORKSPACE_ID web --secret-env API_TOKEN --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
|
||||
pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done'
|
||||
pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app
|
||||
pyro workspace service list WORKSPACE_ID
|
||||
pyro workspace service status WORKSPACE_ID web
|
||||
pyro workspace service logs WORKSPACE_ID web --tail-lines 50
|
||||
pyro workspace service stop WORKSPACE_ID web
|
||||
pyro workspace service stop WORKSPACE_ID worker
|
||||
pyro workspace stop WORKSPACE_ID
|
||||
pyro workspace disk list WORKSPACE_ID
|
||||
pyro workspace disk read WORKSPACE_ID src/note.txt --content-only
|
||||
pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4
|
||||
pyro workspace start WORKSPACE_ID
|
||||
pyro workspace logs WORKSPACE_ID
|
||||
pyro workspace delete WORKSPACE_ID
|
||||
```
|
||||
|
||||
Persistent workspaces start in `/workspace` and keep command history until you delete them. For
|
||||
machine consumption, use `--id-only` for only the identifier or `--json` for the full
|
||||
workspace payload. Use `--seed-path` when
|
||||
you want the workspace to start from a host directory or a local `.tar` / `.tar.gz` / `.tgz`
|
||||
archive instead of an empty workspace. Use `pyro workspace sync push` when you want to import
|
||||
later host-side changes into a started workspace. Sync is non-atomic in `4.0.0`; if it fails
|
||||
partway through, prefer `pyro workspace reset` to recover from `baseline` or one named snapshot.
|
||||
Use `pyro workspace diff` to compare the live `/workspace` tree to its immutable create-time
|
||||
baseline, and `pyro workspace export` to copy one changed file or directory back to the host. Use
|
||||
`pyro workspace snapshot *` and `pyro workspace reset` when you want explicit checkpoints and
|
||||
full-sandbox recovery. Use `pyro workspace exec` for one-shot
|
||||
non-interactive commands inside a live workspace, and `pyro workspace shell *` when you need a
|
||||
persistent PTY session that keeps interactive shell state between calls. Prefer
|
||||
`pyro workspace shell read --plain --wait-for-idle-ms 300` for chat-facing shell reads. Use
|
||||
`pyro workspace service *` when the workspace needs one or more long-running background processes.
|
||||
Typed readiness checks prefer `--ready-file`, `--ready-tcp`, or `--ready-http`; keep
|
||||
`--ready-command` as the escape hatch. Service metadata and logs live outside `/workspace`, so the
|
||||
internal service state does not appear in `pyro workspace diff` or `pyro workspace export`.
|
||||
Use `--network-policy egress` when the workspace needs outbound guest networking, and
|
||||
`--network-policy egress+published-ports` plus `workspace service start --publish` when one
|
||||
service must be probed from the host on `127.0.0.1`.
|
||||
Use `--secret` and `--secret-file` at workspace creation when the sandbox needs private tokens or
|
||||
config. Persisted secrets are materialized inside the guest at `/run/pyro-secrets/<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.
|
||||
The five recipe docs show when those capabilities are justified:
|
||||
[docs/use-cases/README.md](docs/use-cases/README.md)
|
||||
|
||||
## Official Environments
|
||||
|
||||
|
|
@ -415,216 +259,10 @@ Current official environments in the shipped catalog:
|
|||
- `debian:12-base`
|
||||
- `debian:12-build`
|
||||
|
||||
The package ships the embedded Firecracker runtime and a package-controlled environment catalog.
|
||||
Official environments are pulled as OCI artifacts from public Docker Hub repositories into a local
|
||||
cache on first use or through `pyro env pull`.
|
||||
End users do not need registry credentials to pull or run official environments.
|
||||
The default cache location is `~/.cache/pyro-mcp/environments`; override it with
|
||||
`PYRO_ENVIRONMENT_CACHE_DIR`.
|
||||
|
||||
## CLI
|
||||
|
||||
List available environments:
|
||||
|
||||
```bash
|
||||
pyro env list
|
||||
```
|
||||
|
||||
Prefetch one environment:
|
||||
|
||||
```bash
|
||||
pyro env pull debian:12
|
||||
```
|
||||
|
||||
Run one command in an ephemeral VM:
|
||||
|
||||
```bash
|
||||
pyro run debian:12 -- git --version
|
||||
```
|
||||
|
||||
Run with outbound internet enabled:
|
||||
|
||||
```bash
|
||||
pyro run debian:12 --network -- \
|
||||
'python3 -c "import urllib.request; print(urllib.request.urlopen(\"https://example.com\", timeout=10).status)"'
|
||||
```
|
||||
|
||||
Show runtime and host diagnostics:
|
||||
|
||||
```bash
|
||||
pyro doctor
|
||||
pyro doctor --json
|
||||
```
|
||||
|
||||
`pyro run` defaults to `1 vCPU / 1024 MiB`.
|
||||
It fails closed when guest boot or guest exec is unavailable.
|
||||
Use `--allow-host-compat` only if you explicitly want host execution.
|
||||
|
||||
Run the MCP server after the CLI path above works. Start most chat hosts with
|
||||
`workspace-core`:
|
||||
|
||||
```bash
|
||||
pyro mcp serve
|
||||
```
|
||||
|
||||
Profile progression for chat hosts:
|
||||
|
||||
- `workspace-core`: recommended first profile for normal persistent chat editing
|
||||
- `vm-run`: expose only `vm_run` for one-shot-only hosts
|
||||
- `workspace-full`: expose shells, services, snapshots, secrets, network policy, and disk tools when the chat truly needs the full stable surface
|
||||
|
||||
Run the deterministic demo:
|
||||
|
||||
```bash
|
||||
pyro demo
|
||||
pyro demo --network
|
||||
```
|
||||
|
||||
Run the Ollama demo:
|
||||
|
||||
```bash
|
||||
ollama serve
|
||||
ollama pull llama3.2:3b
|
||||
pyro demo ollama
|
||||
```
|
||||
|
||||
## Python SDK
|
||||
|
||||
```python
|
||||
from pyro_mcp import Pyro
|
||||
|
||||
pyro = Pyro()
|
||||
result = pyro.run_in_vm(
|
||||
environment="debian:12",
|
||||
command="git --version",
|
||||
timeout_seconds=30,
|
||||
network=False,
|
||||
)
|
||||
print(result["stdout"])
|
||||
```
|
||||
|
||||
Lower-level lifecycle control remains available:
|
||||
|
||||
```python
|
||||
from pyro_mcp import Pyro
|
||||
|
||||
pyro = Pyro()
|
||||
created = pyro.create_vm(
|
||||
environment="debian:12",
|
||||
ttl_seconds=600,
|
||||
network=True,
|
||||
)
|
||||
vm_id = created["vm_id"]
|
||||
pyro.start_vm(vm_id)
|
||||
result = pyro.exec_vm(vm_id, command="git --version", timeout_seconds=30)
|
||||
print(result["stdout"])
|
||||
```
|
||||
|
||||
`exec_vm()` is a one-command auto-cleaning call. After it returns, the VM is already deleted.
|
||||
|
||||
Environment management is also available through the SDK:
|
||||
|
||||
```python
|
||||
from pyro_mcp import Pyro
|
||||
|
||||
pyro = Pyro()
|
||||
print(pyro.list_environments())
|
||||
print(pyro.inspect_environment("debian:12"))
|
||||
```
|
||||
|
||||
For repeated commands in one workspace:
|
||||
|
||||
```python
|
||||
from pyro_mcp import Pyro
|
||||
|
||||
pyro = Pyro()
|
||||
workspace = pyro.create_workspace(environment="debian:12", seed_path="./repo")
|
||||
workspace_id = workspace["workspace_id"]
|
||||
try:
|
||||
pyro.push_workspace_sync(workspace_id, "./changes", dest="src")
|
||||
result = pyro.exec_workspace(workspace_id, command="cat src/note.txt")
|
||||
print(result["stdout"], end="")
|
||||
finally:
|
||||
pyro.delete_workspace(workspace_id)
|
||||
```
|
||||
|
||||
## MCP Tools
|
||||
|
||||
Primary agent-facing tool:
|
||||
|
||||
- `vm_run(environment, command, vcpu_count=1, mem_mib=1024, timeout_seconds=30, ttl_seconds=600, network=false, allow_host_compat=false)`
|
||||
|
||||
Advanced lifecycle tools:
|
||||
|
||||
- `vm_list_environments()`
|
||||
- `vm_create(environment, vcpu_count=1, mem_mib=1024, ttl_seconds=600, network=false, allow_host_compat=false)`
|
||||
- `vm_start(vm_id)`
|
||||
- `vm_exec(vm_id, command, timeout_seconds=30)` auto-cleans the VM after that command
|
||||
- `vm_stop(vm_id)`
|
||||
- `vm_delete(vm_id)`
|
||||
- `vm_status(vm_id)`
|
||||
- `vm_network_info(vm_id)`
|
||||
- `vm_reap_expired()`
|
||||
|
||||
Persistent workspace tools:
|
||||
|
||||
- `workspace_create(environment, vcpu_count=1, mem_mib=1024, ttl_seconds=600, network_policy="off", allow_host_compat=false, seed_path=null, secrets=null)`
|
||||
- `workspace_sync_push(workspace_id, source_path, dest="/workspace")`
|
||||
- `workspace_exec(workspace_id, command, timeout_seconds=30, secret_env=null)`
|
||||
- `workspace_export(workspace_id, path, output_path)`
|
||||
- `workspace_diff(workspace_id)`
|
||||
- `snapshot_create(workspace_id, snapshot_name)`
|
||||
- `snapshot_list(workspace_id)`
|
||||
- `snapshot_delete(workspace_id, snapshot_name)`
|
||||
- `workspace_reset(workspace_id, snapshot="baseline")`
|
||||
- `service_start(workspace_id, service_name, command, cwd="/workspace", readiness=null, ready_timeout_seconds=30, ready_interval_ms=500, secret_env=null, published_ports=null)`
|
||||
- `service_list(workspace_id)`
|
||||
- `service_status(workspace_id, service_name)`
|
||||
- `service_logs(workspace_id, service_name, tail_lines=200)`
|
||||
- `service_stop(workspace_id, service_name)`
|
||||
- `shell_open(workspace_id, cwd="/workspace", cols=120, rows=30, secret_env=null)`
|
||||
- `shell_read(workspace_id, shell_id, cursor=0, max_chars=65536, plain=False, wait_for_idle_ms=None)`
|
||||
- `shell_write(workspace_id, shell_id, input, append_newline=true)`
|
||||
- `shell_signal(workspace_id, shell_id, signal_name="INT")`
|
||||
- `shell_close(workspace_id, shell_id)`
|
||||
- `workspace_status(workspace_id)`
|
||||
- `workspace_logs(workspace_id)`
|
||||
- `workspace_delete(workspace_id)`
|
||||
|
||||
Recommended MCP tool profiles:
|
||||
|
||||
- `vm-run`: `vm_run` only
|
||||
- `workspace-core`: `vm_run`, `workspace_create`, `workspace_list`, `workspace_update`, `workspace_status`, `workspace_sync_push`, `workspace_exec`, `workspace_logs`, `workspace_file_list`, `workspace_file_read`, `workspace_file_write`, `workspace_patch_apply`, `workspace_diff`, `workspace_export`, `workspace_reset`, `workspace_delete`
|
||||
- `workspace-full`: the complete stable MCP surface above
|
||||
|
||||
## Integration Examples
|
||||
|
||||
- Python one-shot SDK example: [examples/python_run.py](examples/python_run.py)
|
||||
- Python lifecycle example: [examples/python_lifecycle.py](examples/python_lifecycle.py)
|
||||
- Python workspace example: [examples/python_workspace.py](examples/python_workspace.py)
|
||||
- Claude Code MCP setup: [examples/claude_code_mcp.md](examples/claude_code_mcp.md)
|
||||
- Codex MCP setup: [examples/codex_mcp.md](examples/codex_mcp.md)
|
||||
- OpenCode MCP config: [examples/opencode_mcp_config.json](examples/opencode_mcp_config.json)
|
||||
- Generic MCP client config: [examples/mcp_client_config.md](examples/mcp_client_config.md)
|
||||
- Claude Desktop MCP config: [examples/claude_desktop_mcp_config.json](examples/claude_desktop_mcp_config.json)
|
||||
- Cursor MCP config: [examples/cursor_mcp_config.json](examples/cursor_mcp_config.json)
|
||||
- OpenAI Responses API example: [examples/openai_responses_vm_run.py](examples/openai_responses_vm_run.py)
|
||||
- OpenAI Responses `workspace-core` example: [examples/openai_responses_workspace_core.py](examples/openai_responses_workspace_core.py)
|
||||
- LangChain wrapper example: [examples/langchain_vm_run.py](examples/langchain_vm_run.py)
|
||||
- Agent-ready `vm_run` example: [examples/agent_vm_run.py](examples/agent_vm_run.py)
|
||||
|
||||
## Runtime
|
||||
|
||||
The package ships an embedded Linux x86_64 runtime payload with:
|
||||
|
||||
- Firecracker
|
||||
- Jailer
|
||||
- guest agent
|
||||
- runtime manifest and diagnostics
|
||||
|
||||
No system Firecracker installation is required.
|
||||
`pyro` installs curated environments into a local cache and reports their status through `pyro env inspect` and `pyro doctor`.
|
||||
The public CLI is human-readable by default; add `--json` for structured output.
|
||||
The embedded Firecracker runtime ships with the package. Official environments
|
||||
are pulled as OCI artifacts from public Docker Hub into a local cache on first
|
||||
use or through `pyro env pull`. End users do not need registry credentials to
|
||||
pull or run the official environments.
|
||||
|
||||
## Contributor Workflow
|
||||
|
||||
|
|
@ -637,11 +275,12 @@ make check
|
|||
make dist-check
|
||||
```
|
||||
|
||||
Contributor runtime sources live under `runtime_sources/`. The packaged runtime bundle under
|
||||
`src/pyro_mcp/runtime_bundle/` contains the embedded boot/runtime assets plus manifest metadata;
|
||||
end-user environment installs pull OCI-published environments by default. Use
|
||||
`PYRO_RUNTIME_BUNDLE_DIR=build/runtime_bundle` only when you are explicitly validating a locally
|
||||
built contributor runtime bundle.
|
||||
Contributor runtime sources live under `runtime_sources/`. The packaged runtime
|
||||
bundle under `src/pyro_mcp/runtime_bundle/` contains the embedded boot/runtime
|
||||
assets plus manifest metadata. End-user environment installs pull
|
||||
OCI-published environments by default. Use
|
||||
`PYRO_RUNTIME_BUNDLE_DIR=build/runtime_bundle` only when you are explicitly
|
||||
validating a locally built contributor runtime bundle.
|
||||
|
||||
Official environment publication is performed locally against Docker Hub:
|
||||
|
||||
|
|
@ -652,20 +291,9 @@ make runtime-materialize
|
|||
make runtime-publish-official-environments-oci
|
||||
```
|
||||
|
||||
`make runtime-publish-environment-oci` auto-exports the OCI layout for the selected
|
||||
environment if it is missing.
|
||||
The publisher accepts either `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` or
|
||||
`OCI_REGISTRY_USERNAME` and `OCI_REGISTRY_PASSWORD`.
|
||||
Docker Hub uploads are chunked by default for large rootfs layers; if you need to tune a slow
|
||||
link, use `PYRO_OCI_UPLOAD_TIMEOUT_SECONDS`, `PYRO_OCI_UPLOAD_CHUNK_SIZE_BYTES`, and
|
||||
`PYRO_OCI_REQUEST_TIMEOUT_SECONDS`.
|
||||
|
||||
For a local PyPI publish:
|
||||
|
||||
```bash
|
||||
export TWINE_PASSWORD='pypi-...'
|
||||
make pypi-publish
|
||||
```
|
||||
|
||||
`make pypi-publish` defaults `TWINE_USERNAME` to `__token__`.
|
||||
Set `PYPI_REPOSITORY_URL=https://test.pypi.org/legacy/` to publish to TestPyPI instead.
|
||||
|
|
|
|||
|
|
@ -1,28 +1,36 @@
|
|||
# First Run Transcript
|
||||
|
||||
This is the intended evaluator path for a first successful run on a supported host.
|
||||
This is the intended evaluator-to-chat-host path for a first successful run on
|
||||
a supported host.
|
||||
|
||||
Copy the commands as-is. Paths and timing values will differ on your machine.
|
||||
The same sequence works with an installed `pyro` binary by dropping the
|
||||
`uvx --from pyro-mcp` prefix. If you are running from a source checkout instead
|
||||
of the published package, replace `pyro` with `uv run pyro`.
|
||||
`uvx --from pyro-mcp` prefix. If you are running from a source checkout
|
||||
instead of the published package, replace `pyro` with `uv run pyro`.
|
||||
|
||||
`pyro-mcp` currently has no users. Expect breaking changes while the chat-host
|
||||
path is still being shaped.
|
||||
|
||||
## 1. Verify the host
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro doctor
|
||||
$ uvx --from pyro-mcp pyro doctor --environment debian:12
|
||||
Platform: linux-x86_64
|
||||
Runtime: PASS
|
||||
KVM: exists=yes readable=yes writable=yes
|
||||
Environment cache: /home/you/.cache/pyro-mcp/environments
|
||||
Catalog version: 4.5.0
|
||||
Capabilities: vm_boot=yes guest_exec=yes guest_network=yes
|
||||
Networking: tun=yes ip_forward=yes
|
||||
Daily loop: COLD (debian:12)
|
||||
Run: pyro prepare debian:12
|
||||
```
|
||||
|
||||
## 2. Inspect the catalog
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro env list
|
||||
Catalog version: 4.0.0
|
||||
Catalog version: 4.4.0
|
||||
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
|
||||
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
|
||||
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
|
||||
|
|
@ -30,9 +38,10 @@ debian:12-build [installed|not installed] Debian 12 environment with Git and com
|
|||
|
||||
## 3. Pull the default environment
|
||||
|
||||
The first pull downloads an OCI environment from public Docker Hub, requires outbound HTTPS
|
||||
access to `registry-1.docker.io`, and needs local cache space for the guest image. See
|
||||
[host-requirements.md](host-requirements.md) for the full host requirements.
|
||||
The first pull downloads an OCI environment from public Docker Hub, requires
|
||||
outbound HTTPS access to `registry-1.docker.io`, and needs local cache space
|
||||
for the guest image. See [host-requirements.md](host-requirements.md) for the
|
||||
full host requirements.
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro env pull debian:12
|
||||
|
|
@ -45,9 +54,6 @@ Installed: yes
|
|||
Cache dir: /home/you/.cache/pyro-mcp/environments
|
||||
Default packages: bash, coreutils, git
|
||||
Install dir: /home/you/.cache/pyro-mcp/environments/linux-x86_64/debian_12-1.0.0
|
||||
Install manifest: /home/you/.cache/pyro-mcp/environments/linux-x86_64/debian_12-1.0.0/environment.json
|
||||
Kernel image: /home/you/.cache/pyro-mcp/environments/linux-x86_64/debian_12-1.0.0/vmlinux
|
||||
Rootfs image: /home/you/.cache/pyro-mcp/environments/linux-x86_64/debian_12-1.0.0/rootfs.ext4
|
||||
OCI source: registry-1.docker.io/thalesmaciel/pyro-environment-debian-12:1.0.0
|
||||
```
|
||||
|
||||
|
|
@ -62,239 +68,152 @@ $ uvx --from pyro-mcp pyro run debian:12 -- git --version
|
|||
git version ...
|
||||
```
|
||||
|
||||
The guest command output and the `[run] ...` summary are written to different streams, so they
|
||||
may appear in either order in terminals or capture tools. Use `--json` if you need a
|
||||
deterministic structured result.
|
||||
The guest command output and the `[run] ...` summary are written to different
|
||||
streams, so they may appear in either order in terminals or capture tools. Use
|
||||
`--json` if you need a deterministic structured result.
|
||||
|
||||
## 5. Continue into the stable workspace path
|
||||
## 5. Start the MCP server
|
||||
|
||||
The commands below use the published-package form. The same stable workspace path works with an
|
||||
installed `pyro` binary by dropping the `uvx --from pyro-mcp` prefix, or with `uv run pyro` from
|
||||
a source checkout.
|
||||
Warm the daily loop first so the host is already ready for repeated create and
|
||||
reset cycles:
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro prepare debian:12
|
||||
Prepare: debian:12
|
||||
Daily loop: WARM
|
||||
Result: prepared network_prepared=no
|
||||
```
|
||||
|
||||
Use a named mode when one workflow already matches the job:
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
||||
$ uvx --from pyro-mcp pyro mcp serve --mode inspect
|
||||
$ uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
||||
$ uvx --from pyro-mcp pyro mcp serve --mode review-eval
|
||||
```
|
||||
|
||||
Use the generic no-mode path when the mode feels too narrow. Bare
|
||||
`pyro mcp serve` still starts `workspace-core`. From a repo root, it also
|
||||
auto-detects the current Git checkout so the first `workspace_create` can omit
|
||||
`seed_path`:
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro mcp serve
|
||||
```
|
||||
|
||||
If the host does not preserve the server working directory:
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
|
||||
```
|
||||
|
||||
If you are outside a local checkout:
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git
|
||||
```
|
||||
|
||||
## 6. Connect a chat host
|
||||
|
||||
Use the helper flow first:
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro host connect codex --mode repro-fix
|
||||
$ uvx --from pyro-mcp pyro host connect codex --mode inspect
|
||||
$ uvx --from pyro-mcp pyro host connect claude-code --mode cold-start
|
||||
$ uvx --from pyro-mcp pyro host connect claude-code --mode review-eval
|
||||
$ uvx --from pyro-mcp pyro host print-config opencode --mode repro-fix
|
||||
```
|
||||
|
||||
If setup drifts later:
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro host doctor
|
||||
$ uvx --from pyro-mcp pyro host repair claude-code
|
||||
$ uvx --from pyro-mcp pyro host repair codex
|
||||
$ uvx --from pyro-mcp pyro host repair opencode
|
||||
```
|
||||
|
||||
Claude Code cold-start or review-eval:
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro host connect claude-code --mode cold-start
|
||||
$ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
||||
$ claude mcp list
|
||||
```
|
||||
|
||||
Codex repro-fix or inspect:
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro host connect codex --mode repro-fix
|
||||
$ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
||||
$ codex mcp list
|
||||
```
|
||||
|
||||
OpenCode uses the local config shape shown in:
|
||||
|
||||
- [opencode_mcp_config.json](../examples/opencode_mcp_config.json)
|
||||
|
||||
Other host-specific references:
|
||||
|
||||
- [claude_code_mcp.md](../examples/claude_code_mcp.md)
|
||||
- [codex_mcp.md](../examples/codex_mcp.md)
|
||||
- [mcp_client_config.md](../examples/mcp_client_config.md)
|
||||
|
||||
## 7. Continue into a real workflow
|
||||
|
||||
Once the host is connected, move to one of the five recipe docs in
|
||||
[use-cases/README.md](use-cases/README.md).
|
||||
|
||||
The shortest chat-first mode and story is:
|
||||
|
||||
- [use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md)
|
||||
|
||||
If you want terminal-level visibility into what the agent gets, use the manual
|
||||
workspace flow below:
|
||||
|
||||
```bash
|
||||
$ export WORKSPACE_ID="$(uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --id-only)"
|
||||
$ uvx --from pyro-mcp pyro workspace list
|
||||
$ uvx --from pyro-mcp pyro workspace update "$WORKSPACE_ID" --label owner=codex
|
||||
$ uvx --from pyro-mcp pyro workspace sync push "$WORKSPACE_ID" ./changes
|
||||
$ uvx --from pyro-mcp pyro workspace file read "$WORKSPACE_ID" note.txt --content-only
|
||||
$ uvx --from pyro-mcp pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch
|
||||
$ uvx --from pyro-mcp pyro workspace exec "$WORKSPACE_ID" -- cat note.txt
|
||||
$ uvx --from pyro-mcp pyro workspace summary "$WORKSPACE_ID"
|
||||
$ uvx --from pyro-mcp pyro workspace snapshot create "$WORKSPACE_ID" checkpoint
|
||||
$ uvx --from pyro-mcp pyro workspace service start "$WORKSPACE_ID" web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
|
||||
$ uvx --from pyro-mcp pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint
|
||||
$ uvx --from pyro-mcp pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt
|
||||
$ uvx --from pyro-mcp pyro workspace stop "$WORKSPACE_ID"
|
||||
$ uvx --from pyro-mcp pyro workspace disk list "$WORKSPACE_ID"
|
||||
$ uvx --from pyro-mcp pyro workspace disk read "$WORKSPACE_ID" note.txt --content-only
|
||||
$ uvx --from pyro-mcp pyro workspace disk export "$WORKSPACE_ID" --output ./workspace.ext4
|
||||
$ uvx --from pyro-mcp pyro workspace start "$WORKSPACE_ID"
|
||||
$ uvx --from pyro-mcp pyro workspace delete "$WORKSPACE_ID"
|
||||
```
|
||||
|
||||
## 6. Optional one-shot demo and expanded workspace flow
|
||||
Move to the generic no-mode path when the named mode is too narrow. Move to
|
||||
`--profile workspace-full` only when the chat really needs shells, services,
|
||||
snapshots, secrets, network policy, or disk tools.
|
||||
|
||||
## 8. Trust the smoke pack
|
||||
|
||||
The repo now treats the full smoke pack as the trustworthy guest-backed
|
||||
verification path for the advertised workflows:
|
||||
|
||||
```bash
|
||||
$ make smoke-use-cases
|
||||
```
|
||||
|
||||
That runner creates real guest-backed workspaces, exercises all five documented
|
||||
stories, exports concrete results where relevant, and cleans up on both success
|
||||
and failure.
|
||||
|
||||
For the machine-level warmup plus retry story specifically:
|
||||
|
||||
```bash
|
||||
$ make smoke-daily-loop
|
||||
```
|
||||
|
||||
## 9. Optional one-shot demo
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro demo
|
||||
$ uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123
|
||||
$ uvx --from pyro-mcp pyro workspace list
|
||||
$ uvx --from pyro-mcp pyro workspace update WORKSPACE_ID --label owner=codex
|
||||
$ uvx --from pyro-mcp pyro workspace sync push WORKSPACE_ID ./changes
|
||||
$ uvx --from pyro-mcp pyro workspace file list WORKSPACE_ID src --recursive
|
||||
$ uvx --from pyro-mcp pyro workspace file read WORKSPACE_ID src/app.py --content-only
|
||||
$ uvx --from pyro-mcp pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py
|
||||
$ uvx --from pyro-mcp pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch
|
||||
$ uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress
|
||||
$ uvx --from pyro-mcp pyro workspace create debian:12 --secret API_TOKEN=expected --secret-file PIP_TOKEN=./token.txt
|
||||
$ uvx --from pyro-mcp pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"'
|
||||
$ uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID
|
||||
$ uvx --from pyro-mcp pyro workspace snapshot create WORKSPACE_ID checkpoint
|
||||
$ uvx --from pyro-mcp pyro workspace reset WORKSPACE_ID --snapshot checkpoint
|
||||
$ uvx --from pyro-mcp pyro workspace export WORKSPACE_ID note.txt --output ./note.txt
|
||||
$ uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN --id-only
|
||||
$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --secret-env API_TOKEN --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||
$ uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress+published-ports
|
||||
$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app
|
||||
$ uvx --from pyro-mcp pyro mcp serve
|
||||
$ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||
$ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||
```
|
||||
|
||||
For most chat hosts, bare `pyro mcp serve` now starts `workspace-core`, the
|
||||
recommended first MCP profile.
|
||||
Move to `workspace-full` only when the host truly needs shells, services,
|
||||
snapshots, secrets, network policy, or disk tools.
|
||||
|
||||
Host-specific MCP starts:
|
||||
|
||||
- Claude Code: [examples/claude_code_mcp.md](../examples/claude_code_mcp.md)
|
||||
- Codex: [examples/codex_mcp.md](../examples/codex_mcp.md)
|
||||
- OpenCode: [examples/opencode_mcp_config.json](../examples/opencode_mcp_config.json)
|
||||
- Generic MCP config: [examples/mcp_client_config.md](../examples/mcp_client_config.md)
|
||||
|
||||
`pyro demo` proves the one-shot create/start/exec/delete VM lifecycle works end to end.
|
||||
|
||||
Once that stable workspace flow works, continue with the five recipe docs in
|
||||
[use-cases/README.md](use-cases/README.md) or run the real guest-backed smoke packs directly with
|
||||
`make smoke-use-cases`. Treat that smoke pack as the trustworthy guest-backed
|
||||
verification path for the advertised workspace workflows.
|
||||
|
||||
When you need repeated commands in one sandbox, switch to `pyro workspace ...`:
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo
|
||||
Workspace ID: ...
|
||||
Environment: debian:12
|
||||
State: started
|
||||
Workspace: /workspace
|
||||
Workspace seed: directory from ...
|
||||
Network policy: off
|
||||
Execution mode: guest_vsock
|
||||
Resources: 1 vCPU / 1024 MiB
|
||||
Command count: 0
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace sync push WORKSPACE_ID ./changes --dest src
|
||||
[workspace-sync] workspace_id=... mode=directory source=... destination=/workspace/src entry_count=... bytes_written=... execution_mode=guest_vsock
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace file list WORKSPACE_ID src --recursive
|
||||
Workspace file path: /workspace/src
|
||||
- /workspace/src/note.txt [file] bytes=...
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace file read WORKSPACE_ID src/note.txt
|
||||
hello from synced workspace
|
||||
[workspace-file-read] workspace_id=... path=/workspace/src/note.txt size_bytes=... truncated=False execution_mode=guest_vsock
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace exec WORKSPACE_ID -- cat src/note.txt
|
||||
hello from synced workspace
|
||||
[workspace-exec] workspace_id=... sequence=1 cwd=/workspace execution_mode=guest_vsock exit_code=0 duration_ms=...
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"'
|
||||
[workspace-exec] workspace_id=... sequence=2 cwd=/workspace execution_mode=guest_vsock exit_code=0 duration_ms=...
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID
|
||||
[workspace-diff] workspace_id=... total=... added=... modified=... deleted=... type_changed=... text_patched=... non_text=...
|
||||
--- a/src/note.txt
|
||||
+++ b/src/note.txt
|
||||
@@ ...
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace snapshot create WORKSPACE_ID checkpoint
|
||||
[workspace-snapshot-create] snapshot_name=checkpoint kind=named entry_count=... bytes_written=...
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace reset WORKSPACE_ID --snapshot checkpoint
|
||||
Workspace reset from snapshot: checkpoint (named)
|
||||
[workspace-reset] destination=/workspace entry_count=... bytes_written=...
|
||||
Workspace ID: ...
|
||||
State: started
|
||||
Command count: 0
|
||||
Reset count: 1
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
|
||||
[workspace-export] workspace_id=... workspace_path=/workspace/src/note.txt output_path=... artifact_type=file entry_count=... bytes_written=... execution_mode=guest_vsock
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN --id-only
|
||||
[workspace-shell-open] workspace_id=... shell_id=... state=running cwd=/workspace cols=120 rows=30 execution_mode=guest_vsock
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'
|
||||
[workspace-shell-write] workspace_id=... shell_id=... state=running cwd=/workspace cols=120 rows=30 execution_mode=guest_vsock
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300
|
||||
/workspace
|
||||
[workspace-shell-read] workspace_id=... shell_id=... state=running cursor=0 next_cursor=... truncated=False plain=True wait_for_idle_ms=300 execution_mode=guest_vsock
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID web --secret-env API_TOKEN --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
|
||||
[workspace-service-start] workspace_id=... service=web state=running cwd=/workspace ready_type=file execution_mode=guest_vsock
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done'
|
||||
[workspace-service-start] workspace_id=... service=worker state=running cwd=/workspace ready_type=file execution_mode=guest_vsock
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress+published-ports
|
||||
Workspace ID: ...
|
||||
Network policy: egress+published-ports
|
||||
...
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app
|
||||
[workspace-service-start] workspace_id=... service=app state=running cwd=/workspace ready_type=http execution_mode=guest_vsock published=127.0.0.1:18080->8080/tcp
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace service list WORKSPACE_ID
|
||||
Workspace: ...
|
||||
Services: 2 total, 2 running
|
||||
- web [running] cwd=/workspace readiness=file
|
||||
- worker [running] cwd=/workspace readiness=file
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace service status WORKSPACE_ID web
|
||||
Workspace: ...
|
||||
Service: web
|
||||
State: running
|
||||
Command: sh -lc 'touch .web-ready && while true; do sleep 60; done'
|
||||
Cwd: /workspace
|
||||
Readiness: file /workspace/.web-ready
|
||||
Execution mode: guest_vsock
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace service logs WORKSPACE_ID web --tail-lines 50
|
||||
Workspace: ...
|
||||
Service: web
|
||||
State: running
|
||||
...
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace service stop WORKSPACE_ID web
|
||||
[workspace-service-stop] workspace_id=... service=web state=stopped execution_mode=guest_vsock
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace service stop WORKSPACE_ID worker
|
||||
[workspace-service-stop] workspace_id=... service=worker state=stopped execution_mode=guest_vsock
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace stop WORKSPACE_ID
|
||||
Workspace ID: ...
|
||||
State: stopped
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace disk list WORKSPACE_ID src --recursive
|
||||
Workspace: ...
|
||||
Path: /workspace/src
|
||||
- /workspace/src [directory]
|
||||
- /workspace/src/note.txt [file] bytes=...
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace disk read WORKSPACE_ID src/note.txt
|
||||
hello from synced workspace
|
||||
[workspace-disk-read] workspace_id=... path=/workspace/src/note.txt size_bytes=... truncated=False
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4
|
||||
[workspace-disk-export] workspace_id=... output_path=... disk_format=ext4 bytes_written=...
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace start WORKSPACE_ID
|
||||
Workspace ID: ...
|
||||
State: started
|
||||
```
|
||||
|
||||
Use `--seed-path` when the workspace should start from a host directory or a local
|
||||
`.tar` / `.tar.gz` / `.tgz` archive instead of an empty `/workspace`. Use
|
||||
`pyro workspace sync push` when you need to import later host-side changes into a started
|
||||
workspace. Sync is non-atomic in `4.0.0`; if it fails partway through, prefer `pyro workspace reset`
|
||||
to recover from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the current
|
||||
`/workspace` tree to its immutable create-time baseline, `pyro workspace snapshot *` to create
|
||||
named checkpoints, and `pyro workspace export` to copy one changed file or directory back to the
|
||||
host. Use `pyro workspace file *` and `pyro workspace patch apply` for model-native text edits,
|
||||
`pyro workspace exec` for one-shot commands, and `pyro workspace shell *` when you
|
||||
need a persistent interactive PTY session in that same workspace. Use `pyro workspace service *`
|
||||
when the workspace needs long-running background processes with typed readiness checks. Internal
|
||||
service state and logs stay outside `/workspace`, so service runtime data does not appear in
|
||||
workspace diff or export results. Use `--network-policy egress` for outbound guest networking, and
|
||||
`--network-policy egress+published-ports` plus `workspace service start --publish` when one
|
||||
service must be reachable from the host on `127.0.0.1`. Use `--secret` and `--secret-file` at
|
||||
workspace creation when the sandbox needs private tokens or config. Persisted secret files are
|
||||
materialized at `/run/pyro-secrets/<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": {
|
||||
"deleted": true,
|
||||
|
|
@ -309,7 +228,5 @@ Example output:
|
|||
}
|
||||
```
|
||||
|
||||
When you are done evaluating and want to remove stale cached environments, run `pyro env prune`.
|
||||
|
||||
If `pyro doctor` reports `Runtime: FAIL`, or if the `pyro run` summary does not show
|
||||
`execution_mode=guest_vsock`, stop and use [troubleshooting.md](troubleshooting.md).
|
||||
`pyro demo` proves the one-shot create/start/exec/delete VM lifecycle works end
|
||||
to end.
|
||||
|
|
|
|||
358
docs/install.md
358
docs/install.md
|
|
@ -1,11 +1,17 @@
|
|||
# Install
|
||||
|
||||
`pyro-mcp` is built for chat-based coding agents on Linux `x86_64` with KVM.
|
||||
This document is intentionally biased toward that path.
|
||||
|
||||
`pyro-mcp` currently has no users. Expect breaking changes while the chat-host
|
||||
flow is still being shaped.
|
||||
|
||||
## Support Matrix
|
||||
|
||||
Supported today:
|
||||
|
||||
- Linux x86_64
|
||||
- Python 3.12+
|
||||
- Linux `x86_64`
|
||||
- Python `3.12+`
|
||||
- `uv`
|
||||
- `/dev/kvm`
|
||||
|
||||
|
|
@ -40,27 +46,27 @@ Use either of these equivalent evaluator paths:
|
|||
```bash
|
||||
# Package without install
|
||||
uvx --from pyro-mcp pyro doctor
|
||||
uvx --from pyro-mcp pyro env list
|
||||
uvx --from pyro-mcp pyro env pull debian:12
|
||||
uvx --from pyro-mcp pyro prepare debian:12
|
||||
uvx --from pyro-mcp pyro run debian:12 -- git --version
|
||||
```
|
||||
|
||||
```bash
|
||||
# Already installed
|
||||
pyro doctor
|
||||
pyro env list
|
||||
pyro env pull debian:12
|
||||
pyro prepare debian:12
|
||||
pyro run debian:12 -- git --version
|
||||
```
|
||||
|
||||
If you are running from a repo checkout instead, replace `pyro` with `uv run pyro`.
|
||||
If you are running from a repo checkout instead, replace `pyro` with
|
||||
`uv run pyro`.
|
||||
|
||||
After that one-shot proof works, continue into the stable workspace path with `pyro workspace ...`.
|
||||
After that one-shot proof works, the intended next step is a warmed daily loop
|
||||
plus a named chat mode through `pyro host connect` or `pyro host print-config`.
|
||||
|
||||
### 1. Check the host first
|
||||
## 1. Check the host
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro doctor
|
||||
uvx --from pyro-mcp pyro doctor --environment debian:12
|
||||
```
|
||||
|
||||
Expected success signals:
|
||||
|
|
@ -70,13 +76,16 @@ Platform: linux-x86_64
|
|||
Runtime: PASS
|
||||
KVM: exists=yes readable=yes writable=yes
|
||||
Environment cache: /home/you/.cache/pyro-mcp/environments
|
||||
Catalog version: 4.5.0
|
||||
Capabilities: vm_boot=yes guest_exec=yes guest_network=yes
|
||||
Networking: tun=yes ip_forward=yes
|
||||
Daily loop: COLD (debian:12)
|
||||
Run: pyro prepare debian:12
|
||||
```
|
||||
|
||||
If `Runtime: FAIL`, stop here and use [troubleshooting.md](troubleshooting.md).
|
||||
|
||||
### 2. Inspect the catalog
|
||||
## 2. Inspect the catalog
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro env list
|
||||
|
|
@ -85,21 +94,22 @@ uvx --from pyro-mcp pyro env list
|
|||
Expected output:
|
||||
|
||||
```bash
|
||||
Catalog version: 4.0.0
|
||||
Catalog version: 4.4.0
|
||||
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
|
||||
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
|
||||
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
|
||||
```
|
||||
|
||||
### 3. Pull the default environment
|
||||
## 3. Pull the default environment
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro env pull debian:12
|
||||
```
|
||||
|
||||
The first pull downloads an OCI environment from public Docker Hub, requires outbound HTTPS
|
||||
access to `registry-1.docker.io`, and needs local cache space for the guest image. See
|
||||
[host-requirements.md](host-requirements.md) for the full host requirements.
|
||||
The first pull downloads an OCI environment from public Docker Hub, requires
|
||||
outbound HTTPS access to `registry-1.docker.io`, and needs local cache space
|
||||
for the guest image. See [host-requirements.md](host-requirements.md) for the
|
||||
full host requirements.
|
||||
|
||||
Expected success signals:
|
||||
|
||||
|
|
@ -110,7 +120,7 @@ Pulled: debian:12
|
|||
...
|
||||
```
|
||||
|
||||
### 4. Run one command in a guest
|
||||
## 4. Run one command in a guest
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro run debian:12 -- git --version
|
||||
|
|
@ -126,17 +136,124 @@ Expected success signals:
|
|||
git version ...
|
||||
```
|
||||
|
||||
The guest command output and the `[run] ...` summary are written to different streams, so they
|
||||
may appear in either order in terminals or capture tools. Use `--json` if you need a
|
||||
The guest command output and the `[run] ...` summary are written to different
|
||||
streams, so they may appear in either order. Use `--json` if you need a
|
||||
deterministic structured result.
|
||||
|
||||
If guest execution is unavailable, the command fails unless you explicitly pass
|
||||
`--allow-host-compat`.
|
||||
## 5. Warm the daily loop
|
||||
|
||||
## 5. Continue into the stable workspace path
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro prepare debian:12
|
||||
```
|
||||
|
||||
The commands below use plain `pyro ...`. Run the same flow with `uvx --from pyro-mcp pyro ...`
|
||||
for the published package, or `uv run pyro ...` from a source checkout.
|
||||
That one command ensures the environment is installed, proves one guest-backed
|
||||
create/exec/reset/delete loop, and records a warm manifest so the next
|
||||
`pyro prepare debian:12` call can reuse it instead of repeating the full cycle.
|
||||
|
||||
## 6. Connect a chat host
|
||||
|
||||
Use the helper flow first:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro host connect codex --mode repro-fix
|
||||
uvx --from pyro-mcp pyro host connect codex --mode inspect
|
||||
uvx --from pyro-mcp pyro host connect claude-code --mode cold-start
|
||||
uvx --from pyro-mcp pyro host connect claude-code --mode review-eval
|
||||
uvx --from pyro-mcp pyro host print-config opencode --mode repro-fix
|
||||
```
|
||||
|
||||
If setup drifts later, inspect and repair it with:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro host doctor
|
||||
uvx --from pyro-mcp pyro host repair claude-code
|
||||
uvx --from pyro-mcp pyro host repair codex
|
||||
uvx --from pyro-mcp pyro host repair opencode
|
||||
```
|
||||
|
||||
Use a named mode when one workflow already matches the job:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
||||
uvx --from pyro-mcp pyro mcp serve --mode inspect
|
||||
uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
||||
uvx --from pyro-mcp pyro mcp serve --mode review-eval
|
||||
```
|
||||
|
||||
Use the generic no-mode path when the mode feels too narrow. Bare
|
||||
`pyro mcp serve` still starts `workspace-core`. From a repo root, it also
|
||||
auto-detects the current Git checkout so the first `workspace_create` can omit
|
||||
`seed_path`.
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro mcp serve
|
||||
```
|
||||
|
||||
If the host does not preserve the server working directory, use:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
|
||||
```
|
||||
|
||||
If you are starting outside a local checkout, use a clean clone source:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git
|
||||
```
|
||||
|
||||
Copy-paste host-specific starts:
|
||||
|
||||
- Claude Code setup: [claude_code_mcp.md](../examples/claude_code_mcp.md)
|
||||
- Codex setup: [codex_mcp.md](../examples/codex_mcp.md)
|
||||
- OpenCode config: [opencode_mcp_config.json](../examples/opencode_mcp_config.json)
|
||||
- Generic MCP fallback: [mcp_client_config.md](../examples/mcp_client_config.md)
|
||||
|
||||
Claude Code cold-start or review-eval:
|
||||
|
||||
```bash
|
||||
pyro host connect claude-code --mode cold-start
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
||||
```
|
||||
|
||||
Codex repro-fix or inspect:
|
||||
|
||||
```bash
|
||||
pyro host connect codex --mode repro-fix
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
||||
```
|
||||
|
||||
OpenCode uses the `mcp` / `type: "local"` config shape shown in
|
||||
[opencode_mcp_config.json](../examples/opencode_mcp_config.json).
|
||||
|
||||
If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with
|
||||
`pyro` in the same command or config shape.
|
||||
|
||||
Use the generic no-mode path when the named mode is too narrow. Move to
|
||||
`--profile workspace-full` only when the chat truly needs shells, services,
|
||||
snapshots, secrets, network policy, or disk tools.
|
||||
|
||||
## 7. Go from zero to hero
|
||||
|
||||
The intended user journey is:
|
||||
|
||||
1. validate the host with `pyro doctor --environment debian:12`
|
||||
2. warm the machine with `pyro prepare debian:12`
|
||||
3. prove guest execution with `pyro run debian:12 -- git --version`
|
||||
4. connect Claude Code, Codex, or OpenCode with one named mode such as
|
||||
`pyro host connect codex --mode repro-fix`, then use raw
|
||||
`pyro mcp serve --mode ...` or the generic no-mode path when needed
|
||||
5. use `workspace reset` as the normal retry step inside that warmed loop
|
||||
6. start with one use-case recipe from [use-cases/README.md](use-cases/README.md)
|
||||
7. trust but verify with `make smoke-use-cases`
|
||||
|
||||
If you want the shortest chat-first story, start with
|
||||
[use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md).
|
||||
|
||||
## 8. Manual terminal workspace flow
|
||||
|
||||
If you want to inspect the workspace model directly from the terminal, use the
|
||||
companion flow below. This is for understanding and debugging the chat-host
|
||||
product, not the primary story.
|
||||
|
||||
```bash
|
||||
uv tool install pyro-mcp
|
||||
|
|
@ -147,202 +264,49 @@ pyro workspace sync push "$WORKSPACE_ID" ./changes
|
|||
pyro workspace file read "$WORKSPACE_ID" note.txt --content-only
|
||||
pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch
|
||||
pyro workspace exec "$WORKSPACE_ID" -- cat note.txt
|
||||
pyro workspace summary "$WORKSPACE_ID"
|
||||
pyro workspace snapshot create "$WORKSPACE_ID" checkpoint
|
||||
pyro workspace service start "$WORKSPACE_ID" web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
|
||||
pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint
|
||||
pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt
|
||||
pyro workspace delete "$WORKSPACE_ID"
|
||||
```
|
||||
|
||||
This is the stable persistent-workspace contract:
|
||||
When you need deeper debugging or richer recipes, add:
|
||||
|
||||
- `workspace create` seeds `/workspace`
|
||||
- `workspace create --name/--label`, `workspace list`, and `workspace update` make workspaces discoverable
|
||||
- `workspace sync push` imports later host-side changes
|
||||
- `workspace file *` and `workspace patch apply` cover model-native text inspection and edits
|
||||
- `workspace exec` and `workspace shell *` keep work inside one sandbox
|
||||
- `workspace service *` manages long-running processes with typed readiness
|
||||
- `workspace snapshot *` and `workspace reset` make reset-over-repair explicit
|
||||
- `workspace diff` compares against the immutable create-time baseline
|
||||
- `workspace export` copies results back to the host
|
||||
- `workspace stop|start` and `workspace disk *` add secondary stopped-workspace inspection and raw ext4 export
|
||||
- `pyro workspace shell *` for interactive PTY state
|
||||
- `pyro workspace service *` for long-running processes and readiness probes
|
||||
- `pyro workspace create --network-policy egress+published-ports` plus
|
||||
`workspace service start --publish` for host-probed services
|
||||
- `pyro workspace create --secret` and `--secret-file` when the sandbox needs
|
||||
private tokens
|
||||
- `pyro workspace stop` plus `workspace disk *` for offline inspection
|
||||
|
||||
When that stable workspace path is working, continue with the recipe index at
|
||||
[use-cases/README.md](use-cases/README.md). It groups the five core workspace stories and the
|
||||
real smoke targets behind them, starting with `make smoke-use-cases` or one of the per-scenario
|
||||
targets such as `make smoke-repro-fix-loop`.
|
||||
Treat `make smoke-use-cases` as the trustworthy guest-backed verification path for the advertised
|
||||
workspace workflows.
|
||||
## 9. Trustworthy verification path
|
||||
|
||||
## 6. Optional demo proof point
|
||||
The five recipe docs in [use-cases/README.md](use-cases/README.md) are backed
|
||||
by a real Firecracker smoke pack:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro demo
|
||||
make smoke-use-cases
|
||||
```
|
||||
|
||||
`pyro demo` proves the one-shot create/start/exec/delete VM lifecycle works end to end.
|
||||
|
||||
Example output:
|
||||
|
||||
```json
|
||||
{
|
||||
"cleanup": {
|
||||
"deleted": true,
|
||||
"reason": "post_exec_cleanup",
|
||||
"vm_id": "..."
|
||||
},
|
||||
"command": "git --version",
|
||||
"environment": "debian:12",
|
||||
"execution_mode": "guest_vsock",
|
||||
"exit_code": 0,
|
||||
"stdout": "git version ...\n"
|
||||
}
|
||||
```
|
||||
|
||||
For a fuller copy-pasteable transcript, see [first-run.md](first-run.md).
|
||||
When you are done evaluating and want to remove stale cached environments, run `pyro env prune`.
|
||||
Treat that smoke pack as the trustworthy guest-backed verification path for the
|
||||
advertised chat-host workflows.
|
||||
|
||||
## Installed CLI
|
||||
|
||||
If you already installed the package, the same evaluator path works with plain `pyro ...`:
|
||||
If you already installed the package, the same path works with plain `pyro ...`:
|
||||
|
||||
```bash
|
||||
uv tool install pyro-mcp
|
||||
pyro --version
|
||||
pyro doctor
|
||||
pyro env list
|
||||
pyro env pull debian:12
|
||||
pyro doctor --environment debian:12
|
||||
pyro prepare debian:12
|
||||
pyro run debian:12 -- git --version
|
||||
pyro mcp serve
|
||||
```
|
||||
|
||||
After the CLI path works, you can move on to:
|
||||
|
||||
- persistent workspaces: `pyro workspace create debian:12 --seed-path ./repo`
|
||||
- workspace discovery metadata: `pyro workspace create debian:12 --name repro-fix --label issue=123`
|
||||
- workspace discovery commands: `pyro workspace list` and `pyro workspace update WORKSPACE_ID --label owner=codex`
|
||||
- live workspace updates: `pyro workspace sync push WORKSPACE_ID ./changes`
|
||||
- guest networking policy: `pyro workspace create debian:12 --network-policy egress`
|
||||
- workspace secrets: `pyro workspace create debian:12 --secret API_TOKEN=expected --secret-file PIP_TOKEN=./token.txt`
|
||||
- model-native file editing: `pyro workspace file read WORKSPACE_ID src/app.py --content-only`, `pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py`, and `pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch`
|
||||
- baseline diff: `pyro workspace diff WORKSPACE_ID`
|
||||
- snapshots and reset: `pyro workspace snapshot create WORKSPACE_ID checkpoint` and `pyro workspace reset WORKSPACE_ID --snapshot checkpoint`
|
||||
- host export: `pyro workspace export WORKSPACE_ID note.txt --output ./note.txt`
|
||||
- stopped-workspace inspection: `pyro workspace stop WORKSPACE_ID`, `pyro workspace disk list WORKSPACE_ID`, `pyro workspace disk read WORKSPACE_ID note.txt --content-only`, and `pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4`
|
||||
- interactive shells: `pyro workspace shell open WORKSPACE_ID --id-only`
|
||||
- long-running services: `pyro workspace service start WORKSPACE_ID app --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done'`
|
||||
- localhost-published ports: `pyro workspace create debian:12 --network-policy egress+published-ports` and `pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app`
|
||||
- MCP: `pyro mcp serve`
|
||||
- Python SDK: `from pyro_mcp import Pyro`
|
||||
- Demos: `pyro demo` or `pyro demo --network`
|
||||
|
||||
## Chat Host Quickstart
|
||||
|
||||
For most chat-host integrations, bare `pyro mcp serve` now starts
|
||||
`workspace-core`:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro mcp serve
|
||||
```
|
||||
|
||||
Copy-paste host-specific starts:
|
||||
|
||||
- Claude Code: [examples/claude_code_mcp.md](../examples/claude_code_mcp.md)
|
||||
- Codex: [examples/codex_mcp.md](../examples/codex_mcp.md)
|
||||
- OpenCode: [examples/opencode_mcp_config.json](../examples/opencode_mcp_config.json)
|
||||
- Generic MCP config: [examples/mcp_client_config.md](../examples/mcp_client_config.md)
|
||||
|
||||
Claude Code:
|
||||
|
||||
```bash
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||
```
|
||||
|
||||
Codex:
|
||||
|
||||
```bash
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||
```
|
||||
|
||||
OpenCode uses the `mcp`/`type: "local"` config shape shown in
|
||||
[examples/opencode_mcp_config.json](../examples/opencode_mcp_config.json). If
|
||||
`pyro-mcp` is already installed, replace the `uvx --from pyro-mcp pyro`
|
||||
command with `pyro` in the same host-specific command or config shape. Use
|
||||
`--profile workspace-full` only when the host truly needs the full advanced
|
||||
workspace surface.
|
||||
|
||||
Use profile progression like this:
|
||||
|
||||
- `workspace-core`: default and recommended first profile for normal persistent chat editing
|
||||
- `vm-run`: one-shot-only integrations
|
||||
- `workspace-full`: explicit advanced opt-in when the host truly needs shells, services, snapshots, secrets, network policy, or disk tools
|
||||
|
||||
## Stable Workspace
|
||||
|
||||
Use `pyro workspace ...` when you need repeated commands in one sandbox instead of one-shot `pyro run`.
|
||||
|
||||
```bash
|
||||
pyro workspace create debian:12 --seed-path ./repo
|
||||
pyro workspace create debian:12 --network-policy egress
|
||||
pyro workspace create debian:12 --seed-path ./repo --secret API_TOKEN=expected
|
||||
pyro workspace create debian:12 --network-policy egress+published-ports
|
||||
pyro workspace sync push WORKSPACE_ID ./changes --dest src
|
||||
pyro workspace file list WORKSPACE_ID src --recursive
|
||||
pyro workspace file read WORKSPACE_ID src/note.txt --content-only
|
||||
pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py
|
||||
pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch
|
||||
pyro workspace exec WORKSPACE_ID -- cat src/note.txt
|
||||
pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"'
|
||||
pyro workspace diff WORKSPACE_ID
|
||||
pyro workspace snapshot create WORKSPACE_ID checkpoint
|
||||
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
|
||||
pyro workspace reset WORKSPACE_ID
|
||||
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
|
||||
pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN --id-only
|
||||
pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'
|
||||
pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300
|
||||
pyro workspace shell close WORKSPACE_ID SHELL_ID
|
||||
pyro workspace service start WORKSPACE_ID web --secret-env API_TOKEN --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
|
||||
pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done'
|
||||
pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app
|
||||
pyro workspace service list WORKSPACE_ID
|
||||
pyro workspace service status WORKSPACE_ID web
|
||||
pyro workspace service logs WORKSPACE_ID web --tail-lines 50
|
||||
pyro workspace service stop WORKSPACE_ID web
|
||||
pyro workspace service stop WORKSPACE_ID worker
|
||||
pyro workspace stop WORKSPACE_ID
|
||||
pyro workspace disk list WORKSPACE_ID
|
||||
pyro workspace disk read WORKSPACE_ID src/note.txt --content-only
|
||||
pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4
|
||||
pyro workspace start WORKSPACE_ID
|
||||
pyro workspace logs WORKSPACE_ID
|
||||
pyro workspace delete WORKSPACE_ID
|
||||
```
|
||||
|
||||
Workspace commands default to the persistent `/workspace` directory inside the guest. If you need
|
||||
the identifier programmatically, use `--id-only` for only the identifier or `--json` for the full
|
||||
workspace payload. Use `--seed-path`
|
||||
when the workspace should start from a host directory or a local `.tar` / `.tar.gz` / `.tgz`
|
||||
archive. Use `pyro workspace sync push` for later host-side changes to a started workspace. Sync
|
||||
is non-atomic in `4.0.0`; if it fails partway through, prefer `pyro workspace reset` to recover
|
||||
from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the current workspace
|
||||
tree to its immutable create-time baseline, `pyro workspace snapshot *` to capture named
|
||||
checkpoints, and `pyro workspace export` to copy one changed file or directory back to the host. Use
|
||||
`pyro workspace exec` for one-shot commands and `pyro workspace shell *` when you need an
|
||||
interactive PTY that survives across separate calls. Prefer
|
||||
`pyro workspace shell read --plain --wait-for-idle-ms 300` for chat-facing shell loops. Use `pyro workspace service *` when the
|
||||
workspace needs long-running background processes with typed readiness probes. Service metadata and
|
||||
logs stay outside `/workspace`, so the service runtime itself does not show up in workspace diff or
|
||||
export results. Use `--network-policy egress` when the workspace needs outbound guest networking,
|
||||
and `--network-policy egress+published-ports` plus `workspace service start --publish` when one
|
||||
service must be reachable from the host on `127.0.0.1`. Use `--secret` and `--secret-file` at
|
||||
workspace creation when the sandbox needs private tokens or config, and
|
||||
`--secret-env SECRET_NAME[=ENV_VAR]` when one exec, shell, or service call needs that secret as an
|
||||
environment variable. Persisted secret files are available in the guest at
|
||||
`/run/pyro-secrets/<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
|
||||
## Contributor clone
|
||||
|
||||
```bash
|
||||
git lfs install
|
||||
|
|
|
|||
|
|
@ -1,164 +1,257 @@
|
|||
# Integration Targets
|
||||
# Chat Host Integrations
|
||||
|
||||
These are the main ways to integrate `pyro-mcp` into an LLM application.
|
||||
This page documents the intended product path for `pyro-mcp`:
|
||||
|
||||
Use this page after you have already validated the host and guest execution through the
|
||||
CLI path in [install.md](install.md) or [first-run.md](first-run.md).
|
||||
- validate the host with the CLI
|
||||
- warm the daily loop with `pyro prepare debian:12`
|
||||
- run `pyro mcp serve`
|
||||
- connect a chat host
|
||||
- let the agent work inside disposable workspaces
|
||||
|
||||
## Recommended Default
|
||||
`pyro-mcp` currently has no users. Expect breaking changes while this chat-host
|
||||
path is still being shaped.
|
||||
|
||||
Bare `pyro mcp serve` now starts `workspace-core`. Use `vm_run` only for one-shot
|
||||
integrations, and promote the chat surface to `workspace-full` only when it
|
||||
truly needs shells, services, snapshots, secrets, network policy, or disk
|
||||
tools.
|
||||
Use this page after you have already validated the host and guest execution
|
||||
through [install.md](install.md) or [first-run.md](first-run.md).
|
||||
|
||||
That keeps the model-facing contract small:
|
||||
Recommended first commands before connecting a host:
|
||||
|
||||
- one tool
|
||||
- one command
|
||||
- one ephemeral VM
|
||||
- automatic cleanup
|
||||
```bash
|
||||
pyro doctor --environment debian:12
|
||||
pyro prepare debian:12
|
||||
```
|
||||
|
||||
Profile progression:
|
||||
## Recommended Modes
|
||||
|
||||
- `workspace-core`: default and recommended first profile for persistent chat editing
|
||||
- `vm-run`: one-shot only
|
||||
- `workspace-full`: the full stable workspace surface, including shells, services, snapshots, secrets, network policy, and disk tools
|
||||
Use a named mode when one workflow already matches the job:
|
||||
|
||||
## OpenAI Responses API
|
||||
```bash
|
||||
pyro host connect codex --mode repro-fix
|
||||
pyro host connect codex --mode inspect
|
||||
pyro host connect claude-code --mode cold-start
|
||||
pyro host connect claude-code --mode review-eval
|
||||
```
|
||||
|
||||
Best when:
|
||||
The mode-backed raw server forms are:
|
||||
|
||||
- your agent already uses OpenAI models directly
|
||||
- you want a normal tool-calling loop instead of MCP transport
|
||||
- you want the smallest amount of integration code
|
||||
```bash
|
||||
pyro mcp serve --mode repro-fix
|
||||
pyro mcp serve --mode inspect
|
||||
pyro mcp serve --mode cold-start
|
||||
pyro mcp serve --mode review-eval
|
||||
```
|
||||
|
||||
Recommended surface:
|
||||
Use the generic no-mode path only when the named mode feels too narrow.
|
||||
|
||||
- `vm_run` for one-shot loops
|
||||
- the `workspace-core` tool set for the normal persistent chat loop
|
||||
- the `workspace-full` tool set only when the host explicitly needs advanced workspace capabilities
|
||||
## Generic Default
|
||||
|
||||
Canonical example:
|
||||
Bare `pyro mcp serve` starts `workspace-core`. From a repo root, it also
|
||||
auto-detects the current Git checkout so the first `workspace_create` can omit
|
||||
`seed_path`. That is the product path.
|
||||
|
||||
- [examples/openai_responses_vm_run.py](../examples/openai_responses_vm_run.py)
|
||||
- [examples/openai_responses_workspace_core.py](../examples/openai_responses_workspace_core.py)
|
||||
- [docs/use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md)
|
||||
```bash
|
||||
pyro mcp serve
|
||||
```
|
||||
|
||||
## MCP Clients
|
||||
If the host does not preserve cwd, fall back to:
|
||||
|
||||
Best when:
|
||||
```bash
|
||||
pyro mcp serve --project-path /abs/path/to/repo
|
||||
```
|
||||
|
||||
- your host application already supports MCP
|
||||
- you want `pyro` to run as an external stdio server
|
||||
- you want tool schemas to be discovered directly from the server
|
||||
If you are outside a repo checkout entirely, start from a clean clone source:
|
||||
|
||||
Recommended entrypoint:
|
||||
```bash
|
||||
pyro mcp serve --repo-url https://github.com/example/project.git
|
||||
```
|
||||
|
||||
- `pyro mcp serve`
|
||||
Use `--profile workspace-full` only when the chat truly needs shells, services,
|
||||
snapshots, secrets, network policy, or disk tools.
|
||||
|
||||
Profile progression:
|
||||
## Helper First
|
||||
|
||||
- `pyro mcp serve --profile vm-run` for the smallest one-shot surface
|
||||
- `pyro mcp serve` for the normal persistent chat loop
|
||||
- `pyro mcp serve --profile workspace-full` only when the model truly needs advanced workspace tools
|
||||
Use the helper flow before the raw host CLI commands:
|
||||
|
||||
Host-specific onramps:
|
||||
```bash
|
||||
pyro host connect codex --mode repro-fix
|
||||
pyro host connect codex --mode inspect
|
||||
pyro host connect claude-code --mode cold-start
|
||||
pyro host connect claude-code --mode review-eval
|
||||
pyro host print-config opencode --mode repro-fix
|
||||
pyro host doctor
|
||||
pyro host repair opencode
|
||||
```
|
||||
|
||||
- Claude Code: [examples/claude_code_mcp.md](../examples/claude_code_mcp.md)
|
||||
- Codex: [examples/codex_mcp.md](../examples/codex_mcp.md)
|
||||
- OpenCode: [examples/opencode_mcp_config.json](../examples/opencode_mcp_config.json)
|
||||
- Generic MCP config: [examples/mcp_client_config.md](../examples/mcp_client_config.md)
|
||||
- Claude Desktop fallback: [examples/claude_desktop_mcp_config.json](../examples/claude_desktop_mcp_config.json)
|
||||
- Cursor fallback: [examples/cursor_mcp_config.json](../examples/cursor_mcp_config.json)
|
||||
- Use-case recipes: [docs/use-cases/README.md](use-cases/README.md)
|
||||
These helpers wrap the same `pyro mcp serve` entrypoint, make named modes the
|
||||
first user-facing story, and still leave the generic no-mode path available
|
||||
when a mode is too narrow.
|
||||
|
||||
## Direct Python SDK
|
||||
## Claude Code
|
||||
|
||||
Best when:
|
||||
Preferred:
|
||||
|
||||
- your application owns orchestration itself
|
||||
- you do not need MCP transport
|
||||
- you want direct access to `Pyro`
|
||||
```bash
|
||||
pyro host connect claude-code --mode cold-start
|
||||
```
|
||||
|
||||
Recommended default:
|
||||
Repair:
|
||||
|
||||
- `Pyro.run_in_vm(...)`
|
||||
- `Pyro.create_server()` for most chat hosts now that `workspace-core` is the default profile
|
||||
- `Pyro.create_workspace(name=..., labels=...)` + `Pyro.list_workspaces()` + `Pyro.update_workspace(...)` when repeated workspaces need human-friendly discovery metadata
|
||||
- `Pyro.create_workspace(seed_path=...)` + `Pyro.push_workspace_sync(...)` + `Pyro.exec_workspace(...)` when repeated workspace commands are required
|
||||
- `Pyro.list_workspace_files(...)` / `Pyro.read_workspace_file(...)` / `Pyro.write_workspace_file(...)` / `Pyro.apply_workspace_patch(...)` when the agent needs model-native file inspection and text edits inside one live workspace
|
||||
- `Pyro.create_workspace(..., secrets=...)` + `Pyro.exec_workspace(..., secret_env=...)` when the workspace needs private tokens or authenticated setup
|
||||
- `Pyro.create_workspace(..., network_policy="egress+published-ports")` + `Pyro.start_service(..., published_ports=[...])` when the host must probe one workspace service
|
||||
- `Pyro.diff_workspace(...)` + `Pyro.export_workspace(...)` when the agent needs baseline comparison or host-out file transfer
|
||||
- `Pyro.start_service(..., secret_env=...)` + `Pyro.list_services(...)` + `Pyro.logs_service(...)` when the agent needs long-running background processes in one workspace
|
||||
- `Pyro.open_shell(..., secret_env=...)` + `Pyro.write_shell(...)` + `Pyro.read_shell(..., plain=True, wait_for_idle_ms=300)` when the agent needs an interactive PTY inside the workspace
|
||||
```bash
|
||||
pyro host repair claude-code
|
||||
```
|
||||
|
||||
Lifecycle note:
|
||||
Package without install:
|
||||
|
||||
- `Pyro.exec_vm(...)` runs one command and auto-cleans the VM afterward
|
||||
- use `create_vm(...)` + `start_vm(...)` only when you need pre-exec inspection or status before
|
||||
that final exec
|
||||
- use `create_workspace(seed_path=...)` when the agent needs repeated commands in one persistent
|
||||
`/workspace` that starts from host content
|
||||
- use `create_workspace(name=..., labels=...)`, `list_workspaces()`, and `update_workspace(...)`
|
||||
when the agent or operator needs to rediscover the right workspace later without external notes
|
||||
- use `push_workspace_sync(...)` when later host-side changes need to be imported into that
|
||||
running workspace without recreating it
|
||||
- use `list_workspace_files(...)`, `read_workspace_file(...)`, `write_workspace_file(...)`, and
|
||||
`apply_workspace_patch(...)` when the agent should inspect or edit workspace files without shell
|
||||
quoting tricks
|
||||
- use `create_workspace(..., secrets=...)` plus `secret_env` on exec, shell, or service start when
|
||||
the agent needs private tokens or authenticated startup inside that workspace
|
||||
- use `create_workspace(..., network_policy="egress+published-ports")` plus
|
||||
`start_service(..., published_ports=[...])` when the host must probe one service from that
|
||||
workspace
|
||||
- use `diff_workspace(...)` when the agent needs a structured comparison against the immutable
|
||||
create-time baseline
|
||||
- use `export_workspace(...)` when the agent needs one file or directory copied back to the host
|
||||
- use `stop_workspace(...)` plus `list_workspace_disk(...)`, `read_workspace_disk(...)`, or
|
||||
`export_workspace_disk(...)` when the agent needs offline inspection or one raw ext4 copy from
|
||||
a stopped guest-backed workspace
|
||||
- use `start_service(...)` when the agent needs long-running processes and typed readiness inside
|
||||
one workspace
|
||||
- use `open_shell(...)` when the agent needs interactive shell state instead of one-shot execs
|
||||
```bash
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
||||
claude mcp list
|
||||
```
|
||||
|
||||
Examples:
|
||||
If Claude Code launches the server from an unexpected cwd, use:
|
||||
|
||||
- [examples/python_run.py](../examples/python_run.py)
|
||||
- [examples/python_lifecycle.py](../examples/python_lifecycle.py)
|
||||
- [examples/python_workspace.py](../examples/python_workspace.py)
|
||||
- [examples/python_shell.py](../examples/python_shell.py)
|
||||
- [docs/use-cases/README.md](use-cases/README.md)
|
||||
```bash
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start --project-path /abs/path/to/repo
|
||||
```
|
||||
|
||||
## Agent Framework Wrappers
|
||||
Already installed:
|
||||
|
||||
Examples:
|
||||
```bash
|
||||
claude mcp add pyro -- pyro mcp serve
|
||||
claude mcp list
|
||||
```
|
||||
|
||||
- LangChain tools
|
||||
- PydanticAI tools
|
||||
- custom in-house orchestration layers
|
||||
Reference:
|
||||
|
||||
Best when:
|
||||
- [claude_code_mcp.md](../examples/claude_code_mcp.md)
|
||||
|
||||
- you already have an application framework that expects a Python callable tool
|
||||
- you want to wrap `vm_run` behind framework-specific abstractions
|
||||
## Codex
|
||||
|
||||
Recommended pattern:
|
||||
Preferred:
|
||||
|
||||
- keep the framework wrapper thin
|
||||
- map one-shot framework tool input directly onto `vm_run`
|
||||
- expose `workspace_*` only when the framework truly needs repeated commands in one workspace
|
||||
```bash
|
||||
pyro host connect codex --mode repro-fix
|
||||
```
|
||||
|
||||
Concrete example:
|
||||
Repair:
|
||||
|
||||
- [examples/langchain_vm_run.py](../examples/langchain_vm_run.py)
|
||||
```bash
|
||||
pyro host repair codex
|
||||
```
|
||||
|
||||
## Selection Rule
|
||||
Package without install:
|
||||
|
||||
Choose the narrowest integration that matches the host environment:
|
||||
```bash
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
||||
codex mcp list
|
||||
```
|
||||
|
||||
1. OpenAI Responses API if you want a direct provider tool loop.
|
||||
2. MCP if your host already speaks MCP.
|
||||
3. Python SDK if you own orchestration and do not need transport.
|
||||
4. Framework wrappers only as thin adapters over the same `vm_run` contract.
|
||||
If Codex launches the server from an unexpected cwd, use:
|
||||
|
||||
```bash
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix --project-path /abs/path/to/repo
|
||||
```
|
||||
|
||||
Already installed:
|
||||
|
||||
```bash
|
||||
codex mcp add pyro -- pyro mcp serve
|
||||
codex mcp list
|
||||
```
|
||||
|
||||
Reference:
|
||||
|
||||
- [codex_mcp.md](../examples/codex_mcp.md)
|
||||
|
||||
## OpenCode
|
||||
|
||||
Preferred:
|
||||
|
||||
```bash
|
||||
pyro host print-config opencode
|
||||
pyro host repair opencode
|
||||
```
|
||||
|
||||
Use the local MCP config shape from:
|
||||
|
||||
- [opencode_mcp_config.json](../examples/opencode_mcp_config.json)
|
||||
|
||||
Minimal `opencode.json` snippet:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"pyro": {
|
||||
"type": "local",
|
||||
"enabled": true,
|
||||
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with
|
||||
`pyro` in the same config shape.
|
||||
|
||||
If OpenCode launches the server from an unexpected cwd, add
|
||||
`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same command
|
||||
array.
|
||||
|
||||
## Generic MCP Fallback
|
||||
|
||||
Use this only when the host expects a plain `mcpServers` JSON config, when the
|
||||
named modes are too narrow, and when it does not already have a dedicated
|
||||
example in the repo:
|
||||
|
||||
- [mcp_client_config.md](../examples/mcp_client_config.md)
|
||||
|
||||
Generic `mcpServers` shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"pyro": {
|
||||
"command": "uvx",
|
||||
"args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## When To Use `workspace-full`
|
||||
|
||||
Stay on bare `pyro mcp serve` unless the chat host truly needs:
|
||||
|
||||
- persistent PTY shell sessions
|
||||
- long-running services and readiness probes
|
||||
- secrets
|
||||
- guest networking and published ports
|
||||
- stopped-workspace disk inspection or raw ext4 export
|
||||
|
||||
When that is necessary:
|
||||
|
||||
```bash
|
||||
pyro mcp serve --profile workspace-full
|
||||
```
|
||||
|
||||
## Recipe-Backed Workflows
|
||||
|
||||
Once the host is connected, move to the five real workflows in
|
||||
[use-cases/README.md](use-cases/README.md):
|
||||
|
||||
- cold-start repo validation
|
||||
- repro plus fix loops
|
||||
- parallel isolated workspaces
|
||||
- unsafe or untrusted code inspection
|
||||
- review and evaluation workflows
|
||||
|
||||
Validate the whole story with:
|
||||
|
||||
```bash
|
||||
make smoke-use-cases
|
||||
```
|
||||
|
||||
For the machine-warmup plus reset/retry path specifically:
|
||||
|
||||
```bash
|
||||
make smoke-daily-loop
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,375 +1,192 @@
|
|||
# Public Contract
|
||||
|
||||
This document defines the stable public interface for `pyro-mcp` `3.x`.
|
||||
This document describes the chat way to use `pyro-mcp` in `4.x`.
|
||||
|
||||
`pyro-mcp` currently has no users. Expect breaking changes while this chat-host
|
||||
path is still being shaped.
|
||||
|
||||
This document is intentionally biased. It describes the path users are meant to
|
||||
follow today:
|
||||
|
||||
- prove the host with the terminal companion commands
|
||||
- serve disposable workspaces over MCP
|
||||
- connect Claude Code, Codex, or OpenCode
|
||||
- use the recipe-backed workflows
|
||||
|
||||
This page does not try to document every building block in the repo. It
|
||||
documents the chat-host path the project is actively shaping.
|
||||
|
||||
## Package Identity
|
||||
|
||||
- Distribution name: `pyro-mcp`
|
||||
- Public executable: `pyro`
|
||||
- Public Python import: `from pyro_mcp import Pyro`
|
||||
- Public package-level factory: `from pyro_mcp import create_server`
|
||||
- distribution name: `pyro-mcp`
|
||||
- public executable: `pyro`
|
||||
- primary product entrypoint: `pyro mcp serve`
|
||||
|
||||
Stable product framing:
|
||||
`pyro-mcp` is a disposable MCP workspace for chat-based coding agents on Linux
|
||||
`x86_64` KVM hosts.
|
||||
|
||||
- `pyro run` is the stable one-shot entrypoint.
|
||||
- `pyro workspace ...` is the stable persistent workspace contract.
|
||||
## Supported Product Path
|
||||
|
||||
## CLI Contract
|
||||
The intended user journey is:
|
||||
|
||||
Top-level commands:
|
||||
1. `pyro doctor`
|
||||
2. `pyro prepare debian:12`
|
||||
3. `pyro run debian:12 -- git --version`
|
||||
4. `pyro mcp serve`
|
||||
5. connect Claude Code, Codex, or OpenCode
|
||||
6. use `workspace reset` as the normal retry step
|
||||
7. run one of the documented recipe-backed workflows
|
||||
8. validate the whole story with `make smoke-use-cases`
|
||||
|
||||
## Evaluator CLI
|
||||
|
||||
These terminal commands are the documented companion path for the chat-host
|
||||
product:
|
||||
|
||||
- `pyro doctor`
|
||||
- `pyro prepare`
|
||||
- `pyro env list`
|
||||
- `pyro env pull`
|
||||
- `pyro env inspect`
|
||||
- `pyro env prune`
|
||||
- `pyro mcp serve`
|
||||
- `pyro run`
|
||||
- `pyro workspace create`
|
||||
- `pyro workspace list`
|
||||
- `pyro workspace sync push`
|
||||
- `pyro workspace stop`
|
||||
- `pyro workspace start`
|
||||
- `pyro workspace exec`
|
||||
- `pyro workspace file list`
|
||||
- `pyro workspace file read`
|
||||
- `pyro workspace file write`
|
||||
- `pyro workspace export`
|
||||
- `pyro workspace patch apply`
|
||||
- `pyro workspace disk export`
|
||||
- `pyro workspace disk list`
|
||||
- `pyro workspace disk read`
|
||||
- `pyro workspace diff`
|
||||
- `pyro workspace snapshot create`
|
||||
- `pyro workspace snapshot list`
|
||||
- `pyro workspace snapshot delete`
|
||||
- `pyro workspace reset`
|
||||
- `pyro workspace service start`
|
||||
- `pyro workspace service list`
|
||||
- `pyro workspace service status`
|
||||
- `pyro workspace service logs`
|
||||
- `pyro workspace service stop`
|
||||
- `pyro workspace shell open`
|
||||
- `pyro workspace shell read`
|
||||
- `pyro workspace shell write`
|
||||
- `pyro workspace shell signal`
|
||||
- `pyro workspace shell close`
|
||||
- `pyro workspace status`
|
||||
- `pyro workspace update`
|
||||
- `pyro workspace logs`
|
||||
- `pyro workspace delete`
|
||||
- `pyro doctor`
|
||||
- `pyro demo`
|
||||
- `pyro demo ollama`
|
||||
|
||||
Stable `pyro run` interface:
|
||||
What to expect from that path:
|
||||
|
||||
- positional environment name
|
||||
- `--vcpu-count`
|
||||
- `--mem-mib`
|
||||
- `--timeout-seconds`
|
||||
- `--ttl-seconds`
|
||||
- `--network`
|
||||
- `--allow-host-compat`
|
||||
- `--json`
|
||||
- `pyro run <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`, `pyro doctor`, and `pyro prepare` are human-readable by
|
||||
default and return structured JSON with `--json`
|
||||
- the first official environment pull downloads from public Docker Hub into the
|
||||
local environment cache
|
||||
- `pyro prepare debian:12` proves the warmed daily loop with one throwaway
|
||||
workspace create, exec, reset, and delete cycle
|
||||
- `pyro demo` proves the one-shot create/start/exec/delete VM lifecycle end to
|
||||
end
|
||||
|
||||
Behavioral guarantees:
|
||||
These commands exist to validate and debug the chat-host path. They are not the
|
||||
main product destination.
|
||||
|
||||
- `pyro run <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.
|
||||
## MCP Entry Point
|
||||
|
||||
## Python SDK Contract
|
||||
The product entrypoint is:
|
||||
|
||||
Primary facade:
|
||||
```bash
|
||||
pyro mcp serve
|
||||
```
|
||||
|
||||
- `Pyro`
|
||||
What to expect:
|
||||
|
||||
Supported public entrypoints:
|
||||
- named modes are now the first chat-host story:
|
||||
- `pyro mcp serve --mode repro-fix`
|
||||
- `pyro mcp serve --mode inspect`
|
||||
- `pyro mcp serve --mode cold-start`
|
||||
- `pyro mcp serve --mode review-eval`
|
||||
- bare `pyro mcp serve` remains the generic no-mode path and starts
|
||||
`workspace-core`
|
||||
- from a repo root, bare `pyro mcp serve` also auto-detects the current Git
|
||||
checkout so `workspace_create` can omit `seed_path`
|
||||
- `pyro mcp serve --profile workspace-full` explicitly opts into the larger
|
||||
tool surface
|
||||
- `pyro mcp serve --profile vm-run` exposes the smallest one-shot-only surface
|
||||
- `pyro mcp serve --project-path /abs/path/to/repo` is the fallback when the
|
||||
host does not preserve cwd
|
||||
- `pyro mcp serve --repo-url ... [--repo-ref ...]` starts from a clean clone
|
||||
source instead of a local checkout
|
||||
|
||||
- `create_server()`
|
||||
- `Pyro.create_server()`
|
||||
- `Pyro.list_environments()`
|
||||
- `Pyro.pull_environment(environment)`
|
||||
- `Pyro.inspect_environment(environment)`
|
||||
- `Pyro.prune_environments()`
|
||||
- `Pyro.create_vm(...)`
|
||||
- `Pyro.create_workspace(..., name=None, labels=None, network_policy="off", secrets=None)`
|
||||
- `Pyro.list_workspaces()`
|
||||
- `Pyro.push_workspace_sync(workspace_id, source_path, *, dest="/workspace")`
|
||||
- `Pyro.stop_workspace(workspace_id)`
|
||||
- `Pyro.start_workspace(workspace_id)`
|
||||
- `Pyro.list_workspace_files(workspace_id, path="/workspace", recursive=False)`
|
||||
- `Pyro.read_workspace_file(workspace_id, path, *, max_bytes=65536)`
|
||||
- `Pyro.write_workspace_file(workspace_id, path, *, text)`
|
||||
- `Pyro.export_workspace(workspace_id, path, *, output_path)`
|
||||
- `Pyro.apply_workspace_patch(workspace_id, *, patch)`
|
||||
- `Pyro.export_workspace_disk(workspace_id, *, output_path)`
|
||||
- `Pyro.list_workspace_disk(workspace_id, path="/workspace", recursive=False)`
|
||||
- `Pyro.read_workspace_disk(workspace_id, path, *, max_bytes=65536)`
|
||||
- `Pyro.diff_workspace(workspace_id)`
|
||||
- `Pyro.create_snapshot(workspace_id, snapshot_name)`
|
||||
- `Pyro.list_snapshots(workspace_id)`
|
||||
- `Pyro.delete_snapshot(workspace_id, snapshot_name)`
|
||||
- `Pyro.reset_workspace(workspace_id, *, snapshot="baseline")`
|
||||
- `Pyro.start_service(workspace_id, service_name, *, command, cwd="/workspace", readiness=None, ready_timeout_seconds=30, ready_interval_ms=500, secret_env=None, published_ports=None)`
|
||||
- `Pyro.list_services(workspace_id)`
|
||||
- `Pyro.status_service(workspace_id, service_name)`
|
||||
- `Pyro.logs_service(workspace_id, service_name, *, tail_lines=200, all=False)`
|
||||
- `Pyro.stop_service(workspace_id, service_name)`
|
||||
- `Pyro.open_shell(workspace_id, *, cwd="/workspace", cols=120, rows=30, secret_env=None)`
|
||||
- `Pyro.read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536, plain=False, wait_for_idle_ms=None)`
|
||||
- `Pyro.write_shell(workspace_id, shell_id, *, input, append_newline=True)`
|
||||
- `Pyro.signal_shell(workspace_id, shell_id, *, signal_name="INT")`
|
||||
- `Pyro.close_shell(workspace_id, shell_id)`
|
||||
- `Pyro.start_vm(vm_id)`
|
||||
- `Pyro.exec_vm(vm_id, *, command, timeout_seconds=30)`
|
||||
- `Pyro.exec_workspace(workspace_id, *, command, timeout_seconds=30, secret_env=None)`
|
||||
- `Pyro.stop_vm(vm_id)`
|
||||
- `Pyro.delete_vm(vm_id)`
|
||||
- `Pyro.delete_workspace(workspace_id)`
|
||||
- `Pyro.status_vm(vm_id)`
|
||||
- `Pyro.status_workspace(workspace_id)`
|
||||
- `Pyro.update_workspace(workspace_id, *, name=None, clear_name=False, labels=None, clear_labels=None)`
|
||||
- `Pyro.logs_workspace(workspace_id)`
|
||||
- `Pyro.network_info_vm(vm_id)`
|
||||
- `Pyro.reap_expired()`
|
||||
- `Pyro.run_in_vm(...)`
|
||||
Host-specific setup docs:
|
||||
|
||||
Stable public method names:
|
||||
- [claude_code_mcp.md](../examples/claude_code_mcp.md)
|
||||
- [codex_mcp.md](../examples/codex_mcp.md)
|
||||
- [opencode_mcp_config.json](../examples/opencode_mcp_config.json)
|
||||
- [mcp_client_config.md](../examples/mcp_client_config.md)
|
||||
|
||||
- `create_server()`
|
||||
- `list_environments()`
|
||||
- `pull_environment(environment)`
|
||||
- `inspect_environment(environment)`
|
||||
- `prune_environments()`
|
||||
- `create_vm(...)`
|
||||
- `create_workspace(..., name=None, labels=None, network_policy="off", secrets=None)`
|
||||
- `list_workspaces()`
|
||||
- `push_workspace_sync(workspace_id, source_path, *, dest="/workspace")`
|
||||
- `stop_workspace(workspace_id)`
|
||||
- `start_workspace(workspace_id)`
|
||||
- `list_workspace_files(workspace_id, path="/workspace", recursive=False)`
|
||||
- `read_workspace_file(workspace_id, path, *, max_bytes=65536)`
|
||||
- `write_workspace_file(workspace_id, path, *, text)`
|
||||
- `export_workspace(workspace_id, path, *, output_path)`
|
||||
- `apply_workspace_patch(workspace_id, *, patch)`
|
||||
- `export_workspace_disk(workspace_id, *, output_path)`
|
||||
- `list_workspace_disk(workspace_id, path="/workspace", recursive=False)`
|
||||
- `read_workspace_disk(workspace_id, path, *, max_bytes=65536)`
|
||||
- `diff_workspace(workspace_id)`
|
||||
- `create_snapshot(workspace_id, snapshot_name)`
|
||||
- `list_snapshots(workspace_id)`
|
||||
- `delete_snapshot(workspace_id, snapshot_name)`
|
||||
- `reset_workspace(workspace_id, *, snapshot="baseline")`
|
||||
- `start_service(workspace_id, service_name, *, command, cwd="/workspace", readiness=None, ready_timeout_seconds=30, ready_interval_ms=500, secret_env=None, published_ports=None)`
|
||||
- `list_services(workspace_id)`
|
||||
- `status_service(workspace_id, service_name)`
|
||||
- `logs_service(workspace_id, service_name, *, tail_lines=200, all=False)`
|
||||
- `stop_service(workspace_id, service_name)`
|
||||
- `open_shell(workspace_id, *, cwd="/workspace", cols=120, rows=30, secret_env=None)`
|
||||
- `read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536, plain=False, wait_for_idle_ms=None)`
|
||||
- `write_shell(workspace_id, shell_id, *, input, append_newline=True)`
|
||||
- `signal_shell(workspace_id, shell_id, *, signal_name="INT")`
|
||||
- `close_shell(workspace_id, shell_id)`
|
||||
- `start_vm(vm_id)`
|
||||
- `exec_vm(vm_id, *, command, timeout_seconds=30)`
|
||||
- `exec_workspace(workspace_id, *, command, timeout_seconds=30, secret_env=None)`
|
||||
- `stop_vm(vm_id)`
|
||||
- `delete_vm(vm_id)`
|
||||
- `delete_workspace(workspace_id)`
|
||||
- `status_vm(vm_id)`
|
||||
- `status_workspace(workspace_id)`
|
||||
- `update_workspace(workspace_id, *, name=None, clear_name=False, labels=None, clear_labels=None)`
|
||||
- `logs_workspace(workspace_id)`
|
||||
- `network_info_vm(vm_id)`
|
||||
- `reap_expired()`
|
||||
- `run_in_vm(...)`
|
||||
The chat-host bootstrap helper surface is:
|
||||
|
||||
Behavioral defaults:
|
||||
- `pyro host connect claude-code`
|
||||
- `pyro host connect codex`
|
||||
- `pyro host print-config opencode`
|
||||
- `pyro host doctor`
|
||||
- `pyro host repair HOST`
|
||||
|
||||
- `Pyro.create_vm(...)` and `Pyro.run_in_vm(...)` default to `vcpu_count=1` and `mem_mib=1024`.
|
||||
- `Pyro.create_workspace(...)` defaults to `vcpu_count=1` and `mem_mib=1024`.
|
||||
- `allow_host_compat` defaults to `False` on `create_vm(...)` and `run_in_vm(...)`.
|
||||
- `allow_host_compat` defaults to `False` on `create_workspace(...)`.
|
||||
- `Pyro.create_workspace(..., seed_path=...)` seeds `/workspace` from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive before the workspace is returned.
|
||||
- `Pyro.create_workspace(..., name=..., labels=...)` attaches human-oriented discovery metadata without changing the stable `workspace_id`.
|
||||
- `Pyro.create_workspace(..., network_policy="off"|"egress"|"egress+published-ports")` controls workspace guest networking and whether services may publish host ports.
|
||||
- `Pyro.create_workspace(..., secrets=...)` persists guest-only UTF-8 secrets outside `/workspace`.
|
||||
- `Pyro.list_workspaces()` returns persisted workspace summaries sorted by most recent `last_activity_at`.
|
||||
- `Pyro.push_workspace_sync(...)` imports later host-side directory or archive content into a started workspace.
|
||||
- `Pyro.stop_workspace(...)` stops one persistent workspace without deleting its `/workspace`, snapshots, or command history.
|
||||
- `Pyro.start_workspace(...)` restarts one stopped workspace without resetting `/workspace`.
|
||||
- `Pyro.list_workspace_files(...)`, `Pyro.read_workspace_file(...)`, and `Pyro.write_workspace_file(...)` provide structured live `/workspace` inspection and text edits without shell quoting.
|
||||
- `Pyro.export_workspace(...)` exports one file or directory from `/workspace` to an explicit host path.
|
||||
- `Pyro.apply_workspace_patch(...)` applies unified text patches for add/modify/delete operations under `/workspace`.
|
||||
- `Pyro.export_workspace_disk(...)` copies the stopped guest-backed workspace rootfs as raw ext4 to an explicit host path.
|
||||
- `Pyro.list_workspace_disk(...)` inspects a stopped guest-backed workspace rootfs offline without booting the guest.
|
||||
- `Pyro.read_workspace_disk(...)` reads one regular file from a stopped guest-backed workspace rootfs offline.
|
||||
- stopped-workspace disk helpers require `state=stopped` and a guest-backed workspace; they fail on `host_compat`.
|
||||
- `Pyro.diff_workspace(...)` compares the current `/workspace` tree to the immutable create-time baseline.
|
||||
- `Pyro.create_snapshot(...)` captures one named `/workspace` checkpoint.
|
||||
- `Pyro.list_snapshots(...)` lists the implicit `baseline` plus any named snapshots.
|
||||
- `Pyro.delete_snapshot(...)` deletes one named snapshot while leaving `baseline` intact.
|
||||
- `Pyro.reset_workspace(...)` recreates the full sandbox from `baseline` or one named snapshot and clears command, shell, and service history.
|
||||
- `Pyro.start_service(..., secret_env=...)` maps persisted workspace secrets into that service process as environment variables for that start call only.
|
||||
- `Pyro.start_service(...)` starts one named long-running process in a started workspace and waits for its typed readiness probe when configured.
|
||||
- `Pyro.start_service(..., published_ports=[...])` publishes one or more guest TCP ports to `127.0.0.1` on the host when the workspace network policy is `egress+published-ports`.
|
||||
- `Pyro.list_services(...)`, `Pyro.status_service(...)`, `Pyro.logs_service(...)`, and `Pyro.stop_service(...)` manage those persisted workspace services.
|
||||
- `Pyro.exec_vm(...)` runs one command and auto-cleans that VM after the exec completes.
|
||||
- `Pyro.exec_workspace(..., secret_env=...)` maps persisted workspace secrets into that exec call as environment variables for that call only.
|
||||
- `Pyro.exec_workspace(...)` runs one command in the persistent workspace and leaves it alive.
|
||||
- `Pyro.open_shell(..., secret_env=...)` maps persisted workspace secrets into the shell environment when that shell opens.
|
||||
- `Pyro.open_shell(...)` opens a persistent PTY shell attached to one started workspace.
|
||||
- `Pyro.read_shell(...)` reads merged text output from that shell by cursor, with optional plain rendering and idle batching for chat-facing consumers.
|
||||
- `Pyro.write_shell(...)`, `Pyro.signal_shell(...)`, and `Pyro.close_shell(...)` operate on that persistent shell session.
|
||||
- `Pyro.update_workspace(...)` changes only discovery metadata such as `name` and key/value `labels`.
|
||||
These helpers wrap the same `pyro mcp serve` entrypoint and are the preferred
|
||||
setup and repair path for supported hosts.
|
||||
|
||||
## MCP Contract
|
||||
## Named Modes
|
||||
|
||||
Stable MCP profiles:
|
||||
The supported named modes are:
|
||||
|
||||
- `vm-run`: exposes only `vm_run`
|
||||
- `workspace-core`: exposes `vm_run`, `workspace_create`, `workspace_list`, `workspace_update`, `workspace_status`, `workspace_sync_push`, `workspace_exec`, `workspace_logs`, `workspace_file_list`, `workspace_file_read`, `workspace_file_write`, `workspace_patch_apply`, `workspace_diff`, `workspace_export`, `workspace_reset`, and `workspace_delete`
|
||||
- `workspace-full`: exposes the complete stable MCP surface below
|
||||
| Mode | Intended workflow | Key tools |
|
||||
| --- | --- | --- |
|
||||
| `repro-fix` | reproduce, patch, rerun, diff, export, reset | file ops, patch, diff, export, reset, summary |
|
||||
| `inspect` | inspect suspicious or unfamiliar code with the smallest persistent surface | file list/read, exec, export, summary |
|
||||
| `cold-start` | validate a fresh repo and keep services alive long enough to prove readiness | exec, export, reset, summary, service tools |
|
||||
| `review-eval` | interactive review, checkpointing, shell-driven evaluation, and export | shell tools, snapshot tools, diff/export, summary |
|
||||
|
||||
Behavioral defaults:
|
||||
Use the generic no-mode path when one of those named modes feels too narrow for
|
||||
the job.
|
||||
|
||||
- `pyro mcp serve`, `create_server()`, and `Pyro.create_server()` default to `workspace-core`.
|
||||
- `workspace-core` is the default and recommended first profile for most new chat-host integrations.
|
||||
- `create_server(profile="workspace-full")` and `Pyro.create_server(profile="workspace-full")` opt into the full advanced workspace surface explicitly.
|
||||
- `workspace-core` narrows `workspace_create` by omitting `network_policy` and `secrets`.
|
||||
- `workspace-core` narrows `workspace_exec` by omitting `secret_env`.
|
||||
## Generic Workspace Contract
|
||||
|
||||
Primary tool:
|
||||
`workspace-core` is the normal chat path. It exposes:
|
||||
|
||||
- `vm_run`
|
||||
|
||||
Advanced lifecycle tools:
|
||||
|
||||
- `vm_list_environments`
|
||||
- `vm_create`
|
||||
- `vm_start`
|
||||
- `vm_exec`
|
||||
- `vm_stop`
|
||||
- `vm_delete`
|
||||
- `vm_status`
|
||||
- `vm_network_info`
|
||||
- `vm_reap_expired`
|
||||
|
||||
Persistent workspace tools:
|
||||
|
||||
- `workspace_create`
|
||||
- `workspace_list`
|
||||
- `workspace_update`
|
||||
- `workspace_status`
|
||||
- `workspace_sync_push`
|
||||
- `workspace_stop`
|
||||
- `workspace_start`
|
||||
- `workspace_exec`
|
||||
- `workspace_logs`
|
||||
- `workspace_summary`
|
||||
- `workspace_file_list`
|
||||
- `workspace_file_read`
|
||||
- `workspace_file_write`
|
||||
- `workspace_export`
|
||||
- `workspace_patch_apply`
|
||||
- `workspace_disk_export`
|
||||
- `workspace_disk_list`
|
||||
- `workspace_disk_read`
|
||||
- `workspace_diff`
|
||||
- `snapshot_create`
|
||||
- `snapshot_list`
|
||||
- `snapshot_delete`
|
||||
- `workspace_export`
|
||||
- `workspace_reset`
|
||||
- `service_start`
|
||||
- `service_list`
|
||||
- `service_status`
|
||||
- `service_logs`
|
||||
- `service_stop`
|
||||
- `shell_open`
|
||||
- `shell_read`
|
||||
- `shell_write`
|
||||
- `shell_signal`
|
||||
- `shell_close`
|
||||
- `workspace_status`
|
||||
- `workspace_update`
|
||||
- `workspace_logs`
|
||||
- `workspace_delete`
|
||||
|
||||
Behavioral defaults:
|
||||
That is enough for the normal persistent editing loop:
|
||||
|
||||
- `vm_run` and `vm_create` default to `vcpu_count=1` and `mem_mib=1024`.
|
||||
- `workspace_create` defaults to `vcpu_count=1` and `mem_mib=1024`.
|
||||
- `vm_run` and `vm_create` expose `allow_host_compat`, which defaults to `false`.
|
||||
- `workspace_create` exposes `allow_host_compat`, which defaults to `false`.
|
||||
- `workspace_create` accepts optional `seed_path` and seeds `/workspace` from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive before the workspace is returned.
|
||||
- `workspace_create` accepts optional `name` and `labels` metadata for human discovery without changing the stable `workspace_id`.
|
||||
- `workspace_create` accepts `network_policy` with `off`, `egress`, or `egress+published-ports` to control workspace guest networking and service port publication.
|
||||
- `workspace_create` accepts optional `secrets` and persists guest-only UTF-8 secret material outside `/workspace`.
|
||||
- `workspace_list` returns persisted workspace summaries sorted by most recent `last_activity_at`.
|
||||
- `workspace_sync_push` imports later host-side directory or archive content into a started workspace, with an optional `dest` under `/workspace`.
|
||||
- `workspace_stop` stops one persistent workspace without deleting its `/workspace`, snapshots, or command history.
|
||||
- `workspace_start` restarts one stopped workspace without resetting `/workspace`.
|
||||
- `workspace_file_list`, `workspace_file_read`, and `workspace_file_write` provide structured live `/workspace` inspection and text edits without shell quoting.
|
||||
- `workspace_export` exports one file or directory from `/workspace` to an explicit host path.
|
||||
- `workspace_patch_apply` applies unified text patches for add/modify/delete operations under `/workspace`.
|
||||
- `workspace_disk_export` copies the stopped guest-backed workspace rootfs as raw ext4 to an explicit host path.
|
||||
- `workspace_disk_list` inspects a stopped guest-backed workspace rootfs offline without booting the guest.
|
||||
- `workspace_disk_read` reads one regular file from a stopped guest-backed workspace rootfs offline.
|
||||
- stopped-workspace disk tools require `state=stopped` and a guest-backed workspace; they fail on `host_compat`.
|
||||
- `workspace_diff` compares the current `/workspace` tree to the immutable create-time baseline.
|
||||
- `snapshot_create`, `snapshot_list`, and `snapshot_delete` manage explicit named snapshots in addition to the implicit `baseline`.
|
||||
- `workspace_reset` recreates the full sandbox and restores `/workspace` from `baseline` or one named snapshot.
|
||||
- `service_start`, `service_list`, `service_status`, `service_logs`, and `service_stop` manage persistent named services inside a started workspace.
|
||||
- `service_start` accepts optional `published_ports` to expose guest TCP ports on `127.0.0.1` when the workspace network policy is `egress+published-ports`.
|
||||
- `vm_exec` runs one command and auto-cleans that VM after the exec completes.
|
||||
- `workspace_exec` accepts optional `secret_env` mappings for one exec call and leaves the workspace alive.
|
||||
- `workspace_update` changes only discovery metadata such as `name` and key/value `labels`.
|
||||
- `service_start` accepts optional `secret_env` mappings for one service start call.
|
||||
- `shell_open` accepts optional `secret_env` mappings for the opened shell session.
|
||||
- `shell_open`, `shell_read`, `shell_write`, `shell_signal`, and `shell_close` manage persistent PTY shells inside a started workspace.
|
||||
- create one workspace, often without `seed_path` when the server already has a
|
||||
project source
|
||||
- sync or seed repo content
|
||||
- inspect and edit files without shell quoting
|
||||
- run commands repeatedly in one sandbox
|
||||
- review the current session in one concise summary
|
||||
- diff and export results
|
||||
- reset and retry
|
||||
- delete the workspace when the task is done
|
||||
|
||||
## Versioning Rule
|
||||
Move to `workspace-full` only when the chat truly needs:
|
||||
|
||||
- `pyro-mcp` uses SemVer.
|
||||
- Environment names are stable identifiers in the shipped catalog.
|
||||
- Changing a public command name, public flag, public method name, public MCP tool name, or required request field is a breaking change.
|
||||
- persistent PTY shell sessions
|
||||
- long-running services and readiness probes
|
||||
- secrets
|
||||
- guest networking and published ports
|
||||
- stopped-workspace disk inspection
|
||||
|
||||
## Recipe-Backed Workflows
|
||||
|
||||
The documented product workflows are:
|
||||
|
||||
| Workflow | Recommended mode | Doc |
|
||||
| --- | --- | --- |
|
||||
| Cold-start repo validation | `cold-start` | [use-cases/cold-start-repo-validation.md](use-cases/cold-start-repo-validation.md) |
|
||||
| Repro plus fix loop | `repro-fix` | [use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md) |
|
||||
| Parallel isolated workspaces | `repro-fix` | [use-cases/parallel-workspaces.md](use-cases/parallel-workspaces.md) |
|
||||
| Unsafe or untrusted code inspection | `inspect` | [use-cases/untrusted-inspection.md](use-cases/untrusted-inspection.md) |
|
||||
| Review and evaluation workflows | `review-eval` | [use-cases/review-eval-workflows.md](use-cases/review-eval-workflows.md) |
|
||||
|
||||
Treat this smoke pack as the trustworthy guest-backed verification path for the
|
||||
advertised product:
|
||||
|
||||
```bash
|
||||
make smoke-use-cases
|
||||
```
|
||||
|
||||
The chat-host MCP path above is the thing the docs are intentionally shaping
|
||||
around.
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@ goal:
|
|||
make the core agent-workspace use cases feel trivial from a chat-driven LLM
|
||||
interface.
|
||||
|
||||
Current baseline is `4.0.0`:
|
||||
Current baseline is `4.5.0`:
|
||||
|
||||
- the stable workspace contract exists across CLI, SDK, and MCP
|
||||
- one-shot `pyro run` still exists as the narrow entrypoint
|
||||
- `pyro mcp serve` is now the default product entrypoint
|
||||
- `workspace-core` is now the default MCP profile
|
||||
- one-shot `pyro run` still exists as the terminal companion path
|
||||
- workspaces already support seeding, sync push, exec, export, diff, snapshots,
|
||||
reset, services, PTY shells, secrets, network policy, and published ports
|
||||
- host-specific onramps exist for Claude Code, Codex, and OpenCode
|
||||
- the five documented use cases are now recipe-backed and smoke-tested
|
||||
- stopped-workspace disk tools now exist, but remain explicitly secondary
|
||||
|
||||
## What "Trivial In Chat" Means
|
||||
|
|
@ -33,9 +36,16 @@ More concretely, the model should not need to:
|
|||
- choose from an unnecessarily large tool surface when a smaller profile would
|
||||
work
|
||||
|
||||
The remaining UX friction for a technically strong new user is now narrower:
|
||||
The next gaps for the narrowed persona are now about real-project credibility:
|
||||
|
||||
- no major chat-host ergonomics gaps remain in the current roadmap
|
||||
- current-checkout startup is still brittle for messy local repos with unreadable,
|
||||
generated, or permission-sensitive files
|
||||
- the guest-backed smoke pack is strong, but it still proves shaped scenarios
|
||||
better than arbitrary local-repo readiness
|
||||
- the chat-host path still does not let users choose the sandbox environment as
|
||||
a first-class part of host connection and server startup
|
||||
- the product should not claim full whole-project development readiness until it
|
||||
qualifies a real-project loop beyond fixture-shaped use cases
|
||||
|
||||
## Locked Decisions
|
||||
|
||||
|
|
@ -43,9 +53,24 @@ The remaining UX friction for a technically strong new user is now narrower:
|
|||
or runner abstractions
|
||||
- keep disk tools secondary and do not make them the main chat-facing surface
|
||||
- prefer narrow tool profiles and structured outputs over more raw shell calls
|
||||
- capability milestones should update CLI, SDK, and MCP together
|
||||
- optimize the MCP/chat-host path first and keep the CLI companion path good
|
||||
enough to validate and debug it
|
||||
- lower-level SDK and repo substrate work can continue, but they should not
|
||||
drive milestone scope or naming
|
||||
- CLI-only ergonomics are allowed when the SDK and MCP surfaces already have the
|
||||
structured behavior natively
|
||||
- prioritize repo-aware startup, trust, and daily-loop speed before adding more
|
||||
low-level workspace surface area
|
||||
- for repo-root auto-detection and `--project-path` inside a Git checkout, the
|
||||
default project source should become Git-tracked files only
|
||||
- `--repo-url` remains the clean-clone path when users do not want to trust the
|
||||
local checkout as the startup source
|
||||
- environment selection must become first-class in the chat-host path before the
|
||||
product claims whole-project development readiness
|
||||
- real-project readiness must be proven with guest-backed qualification smokes
|
||||
that cover ignored, generated, and unreadable-file cases
|
||||
- breaking changes are acceptable while there are still no users and the
|
||||
chat-host product is still being shaped
|
||||
- every milestone below must also update docs, help text, runnable examples,
|
||||
and at least one real smoke scenario
|
||||
|
||||
|
|
@ -62,6 +87,16 @@ The remaining UX friction for a technically strong new user is now narrower:
|
|||
9. [`3.10.0` Use-Case Smoke Trust And Recipe Fidelity](llm-chat-ergonomics/3.10.0-use-case-smoke-trust-and-recipe-fidelity.md) - Done
|
||||
10. [`3.11.0` Host-Specific MCP Onramps](llm-chat-ergonomics/3.11.0-host-specific-mcp-onramps.md) - Done
|
||||
11. [`4.0.0` Workspace-Core Default Profile](llm-chat-ergonomics/4.0.0-workspace-core-default-profile.md) - Done
|
||||
12. [`4.1.0` Project-Aware Chat Startup](llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md) - Done
|
||||
13. [`4.2.0` Host Bootstrap And Repair](llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md) - Done
|
||||
14. [`4.3.0` Reviewable Agent Output](llm-chat-ergonomics/4.3.0-reviewable-agent-output.md) - Done
|
||||
15. [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md) - Done
|
||||
16. [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md) - Done
|
||||
17. [`4.6.0` Git-Tracked Project Sources](llm-chat-ergonomics/4.6.0-git-tracked-project-sources.md) - Planned
|
||||
18. [`4.7.0` Project Source Diagnostics And Recovery](llm-chat-ergonomics/4.7.0-project-source-diagnostics-and-recovery.md) - Planned
|
||||
19. [`4.8.0` First-Class Chat Environment Selection](llm-chat-ergonomics/4.8.0-first-class-chat-environment-selection.md) - Planned
|
||||
20. [`4.9.0` Real-Repo Qualification Smokes](llm-chat-ergonomics/4.9.0-real-repo-qualification-smokes.md) - Planned
|
||||
21. [`5.0.0` Whole-Project Sandbox Development](llm-chat-ergonomics/5.0.0-whole-project-sandbox-development.md) - Planned
|
||||
|
||||
Completed so far:
|
||||
|
||||
|
|
@ -92,10 +127,29 @@ Completed so far:
|
|||
config manually.
|
||||
- `4.0.0` flipped the default MCP/server profile to `workspace-core`, so the bare entrypoint now
|
||||
matches the recommended narrow chat-host profile across CLI, SDK, and package-level factories.
|
||||
- `4.1.0` made repo-root startup native for chat hosts, so bare `pyro mcp serve` can auto-detect
|
||||
the current Git checkout and let the first `workspace_create` omit `seed_path`, with explicit
|
||||
`--project-path` and `--repo-url` fallbacks when cwd is not the source of truth.
|
||||
- `4.2.0` adds first-class host bootstrap and repair helpers so Claude Code,
|
||||
Codex, and OpenCode users can connect or repair the supported chat-host path
|
||||
without manually composing raw MCP commands or config edits.
|
||||
- `4.3.0` adds a concise workspace review surface so users can inspect what the
|
||||
agent changed and ran since the last reset without reconstructing the
|
||||
session from several lower-level views by hand.
|
||||
- `4.4.0` adds named use-case modes so chat hosts can start from `repro-fix`,
|
||||
`inspect`, `cold-start`, or `review-eval` instead of choosing from the full
|
||||
generic workspace surface first.
|
||||
- `4.5.0` adds `pyro prepare`, daily-loop readiness in `pyro doctor`, and a
|
||||
real `make smoke-daily-loop` verification path so the local machine warmup
|
||||
story is explicit before the chat host connects.
|
||||
|
||||
Planned next:
|
||||
|
||||
- no further chat-ergonomics milestones are currently planned in this roadmap.
|
||||
- [`4.6.0` Git-Tracked Project Sources](llm-chat-ergonomics/4.6.0-git-tracked-project-sources.md)
|
||||
- [`4.7.0` Project Source Diagnostics And Recovery](llm-chat-ergonomics/4.7.0-project-source-diagnostics-and-recovery.md)
|
||||
- [`4.8.0` First-Class Chat Environment Selection](llm-chat-ergonomics/4.8.0-first-class-chat-environment-selection.md)
|
||||
- [`4.9.0` Real-Repo Qualification Smokes](llm-chat-ergonomics/4.9.0-real-repo-qualification-smokes.md)
|
||||
- [`5.0.0` Whole-Project Sandbox Development](llm-chat-ergonomics/5.0.0-whole-project-sandbox-development.md)
|
||||
|
||||
## Expected Outcome
|
||||
|
||||
|
|
@ -117,3 +171,16 @@ The intended model-facing shape is:
|
|||
- human-mode content reads are copy-paste safe
|
||||
- the default bare MCP server entrypoint matches the recommended narrow profile
|
||||
- the five core use cases are documented and smoke-tested end to end
|
||||
- starting from the current repo feels native from the first chat-host setup
|
||||
- supported hosts can be connected or repaired without manual config spelunking
|
||||
- users can review one concise summary of what the agent changed and ran
|
||||
- the main workflows feel like named modes instead of one giant reference
|
||||
- reset and retry loops are fast enough to encourage daily use
|
||||
- repo-root startup is robust even when the local checkout contains ignored,
|
||||
generated, or unreadable files
|
||||
- chat-host users can choose the sandbox environment as part of the normal
|
||||
connect/start path
|
||||
- the product has guest-backed qualification for real local repos, not only
|
||||
shaped fixture scenarios
|
||||
- it becomes credible to tell a user they can develop a real project inside
|
||||
sandboxes, not just evaluate or patch one
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
# `4.1.0` Project-Aware Chat Startup
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Make "current repo to disposable sandbox" the default story for the narrowed
|
||||
chat-host user, without requiring manual workspace seeding choreography first.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
The chat entrypoint should gain one documented project-aware startup path:
|
||||
|
||||
- `pyro mcp serve` should accept an explicit local project source, such as the
|
||||
current checkout
|
||||
- the product path should optionally support a clean-clone source, such as a
|
||||
repo URL, when the user is not starting from a local checkout
|
||||
- the first useful chat turn should not depend on manually teaching
|
||||
`workspace create ... --seed-path ...` before the host can do real work
|
||||
|
||||
Exact flag names can still change, but the product needs one obvious "use this
|
||||
repo" path and one obvious "start from that repo" path.
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- keep host crossing explicit; do not silently mutate the user's checkout
|
||||
- prefer local checkout seeding first, because that is the most natural daily
|
||||
chat path
|
||||
- preserve existing explicit sync, export, diff, and reset primitives rather
|
||||
than inventing a hidden live-sync layer
|
||||
- keep the startup story compatible with the existing `workspace-core` product
|
||||
path
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no generic SCM integration platform
|
||||
- no background multi-repo workspace manager
|
||||
- no always-on bidirectional live sync between host checkout and guest
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- from a repo root, a user can connect Claude Code, Codex, or OpenCode and the
|
||||
first workspace starts from that repo without extra terminal choreography
|
||||
- from outside a repo checkout, a user can still start from a documented clean
|
||||
source such as a repo URL
|
||||
- the README and install docs can teach a repo-aware chat flow before the
|
||||
manual terminal workspace flow
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- README, install docs, first-run docs, integrations docs, and public contract
|
||||
updated to show the repo-aware chat startup path
|
||||
- help text updated so the repo-aware startup path is visible from `pyro` and
|
||||
`pyro mcp serve --help`
|
||||
- at least one recipe and one real smoke scenario updated to validate the new
|
||||
startup story
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
# `4.2.0` Host Bootstrap And Repair
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Make supported chat hosts feel one-command to connect and easy to repair when a
|
||||
local config drifts or the product changes shape.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
The CLI should grow a small host-helper surface for the supported chat hosts:
|
||||
|
||||
- `pyro host connect claude-code`
|
||||
- `pyro host connect codex`
|
||||
- `pyro host print-config opencode`
|
||||
- `pyro host doctor`
|
||||
- `pyro host repair HOST`
|
||||
|
||||
The exact names can still move, but the product needs a first-class bootstrap
|
||||
and repair path for Claude Code, Codex, and OpenCode.
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- host helpers should wrap the same `pyro mcp serve` entrypoint rather than
|
||||
introduce per-host runtime behavior
|
||||
- config changes should remain inspectable and predictable
|
||||
- support both installed-package and `uvx`-style usage where that materially
|
||||
reduces friction
|
||||
- keep the host helper story narrow to the current supported hosts
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no GUI installer or onboarding wizard
|
||||
- no attempt to support every possible MCP-capable editor or chat shell
|
||||
- no hidden network service or account-based control plane
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- a new Claude Code or Codex user can connect `pyro` with one command
|
||||
- an OpenCode user can print or materialize a correct config without hand-writing
|
||||
JSON
|
||||
- a user with a stale or broken local host config can run one repair or doctor
|
||||
flow instead of debugging MCP setup manually
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- new host-helper docs and examples for all supported chat hosts
|
||||
- README, install docs, and integrations docs updated to prefer the helper
|
||||
flows when available
|
||||
- help text updated with exact connect and repair commands
|
||||
- runnable verification or smoke coverage that proves the shipped host-helper
|
||||
examples stay current
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
# `4.3.0` Reviewable Agent Output
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Make it easy for a human to review what the agent actually did inside the
|
||||
sandbox without manually reconstructing the session from diffs, logs, and raw
|
||||
artifacts.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
The product should expose a concise workspace review surface, for example:
|
||||
|
||||
- `pyro workspace summary WORKSPACE_ID`
|
||||
- `workspace_summary` on the MCP side
|
||||
- structured JSON plus a short human-readable summary view
|
||||
|
||||
The summary should cover the things a chat-host user cares about:
|
||||
|
||||
- commands run
|
||||
- files changed
|
||||
- diff or patch summary
|
||||
- services started
|
||||
- artifacts exported
|
||||
- final workspace outcome
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- prefer concise review surfaces over raw event firehoses
|
||||
- keep raw logs, diffs, and exported files available as drill-down tools
|
||||
- summarize only the sandbox activity the product can actually observe
|
||||
- make the summary good enough to paste into a chat, bug report, or PR comment
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no full compliance or audit product
|
||||
- no attempt to summarize the model's hidden reasoning
|
||||
- no remote storage backend for session history
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- after a repro-fix or review-eval run, a user can inspect one summary and
|
||||
understand what changed and what to review next
|
||||
- the summary is useful enough to accompany exported patches or artifacts
|
||||
- unsafe-inspection and review-eval flows become easier to trust because the
|
||||
user can review agent-visible actions in one place
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- public contract, help text, README, and recipe docs updated with the new
|
||||
summary path
|
||||
- at least one host-facing example showing how to ask for or export the summary
|
||||
- at least one real smoke scenario validating the review surface end to end
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
# `4.4.0` Opinionated Use-Case Modes
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Stop making chat-host users think in terms of one giant workspace surface and
|
||||
let them start from a small mode that matches the job they want the agent to do.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
The chat entrypoint should gain named use-case modes, for example:
|
||||
|
||||
- `pyro mcp serve --mode repro-fix`
|
||||
- `pyro mcp serve --mode inspect`
|
||||
- `pyro mcp serve --mode cold-start`
|
||||
- `pyro mcp serve --mode review-eval`
|
||||
|
||||
Modes should narrow the product story by selecting the right defaults for:
|
||||
|
||||
- tool surface
|
||||
- workspace bootstrap behavior
|
||||
- docs and example prompts
|
||||
- expected export and review outputs
|
||||
|
||||
Parallel workspace use should come from opening more than one named workspace
|
||||
inside the same mode, not from introducing a scheduler or queue abstraction.
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- build modes on top of the existing `workspace-core` and `workspace-full`
|
||||
capabilities instead of inventing separate backends
|
||||
- keep the mode list short and mapped to the documented use cases
|
||||
- make modes visible from help text, host helpers, and recipe docs together
|
||||
- let users opt out to the generic workspace path when the mode is too narrow
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no user-defined mode DSL
|
||||
- no hidden host-specific behavior for the same mode name
|
||||
- no CI-style pipelines, matrix builds, or queueing abstractions
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- a new user can pick one mode and avoid reading the full workspace surface
|
||||
before starting
|
||||
- the documented use cases map cleanly to named entry modes
|
||||
- parallel issue or PR work feels like "open another workspace in the same
|
||||
mode", not "submit another job"
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- help text, README, install docs, integrations docs, and use-case recipes
|
||||
updated to teach the named modes
|
||||
- host-specific setup docs updated so supported hosts can start in a named mode
|
||||
- at least one smoke scenario proving a mode-specific happy path end to end
|
||||
57
docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md
Normal file
57
docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# `4.5.0` Faster Daily Loops
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Make the day-to-day chat-host loop feel cheap enough that users reach for it
|
||||
for normal work, not only for special high-isolation tasks.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
The product now adds an explicit fast-path for repeated local use:
|
||||
|
||||
- `pyro prepare [ENVIRONMENT] [--network] [--force] [--json]`
|
||||
- `pyro doctor --environment ENVIRONMENT` daily-loop readiness output
|
||||
- `make smoke-daily-loop` to prove the warmed machine plus reset/retry story
|
||||
|
||||
The exact command names can still move, but the user-visible story needs to be:
|
||||
|
||||
- set the machine up once
|
||||
- reconnect quickly
|
||||
- create or reset a workspace cheaply
|
||||
- keep iterating without redoing heavy setup work
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- optimize local-first loops on one machine before thinking about remote
|
||||
execution
|
||||
- focus on startup, create, reset, and retry latency rather than queue
|
||||
throughput
|
||||
- keep the fast path compatible with the repo-aware startup story and the
|
||||
supported chat hosts
|
||||
- prefer explicit caching and prewarm semantics over hidden long-running
|
||||
daemons
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no cloud prewarm service
|
||||
- no scheduler or queueing layer
|
||||
- no daemon requirement for normal daily use
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- after the first setup, entering the chat-host path again does not feel like
|
||||
redoing the whole product onboarding
|
||||
- reset and retry become cheap enough to recommend as the default repro-fix
|
||||
workflow
|
||||
- docs can present `pyro` as a daily coding-agent tool, not only as a special
|
||||
heavy-duty sandbox
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- docs now show the recommended daily-use fast path
|
||||
- diagnostics and help text now show whether the machine is already warm and
|
||||
ready
|
||||
- the repo now includes `make smoke-daily-loop` as a repeat-loop verification
|
||||
scenario for the daily workflow
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
# `4.6.0` Git-Tracked Project Sources
|
||||
|
||||
Status: Planned
|
||||
|
||||
## Goal
|
||||
|
||||
Make repo-root startup and `--project-path` robust for messy real checkouts by
|
||||
stopping the default chat-host path from trying to ingest every readable and
|
||||
unreadable file in the working tree.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
Project-aware startup should change its default local source semantics:
|
||||
|
||||
- bare `pyro mcp serve` from inside a Git checkout should seed from Git-tracked
|
||||
files only
|
||||
- `pyro mcp serve --project-path PATH` should also use Git-tracked files only
|
||||
when `PATH` is inside a Git checkout
|
||||
- `--repo-url` remains the clean-clone path when the user wants a host-side
|
||||
clone instead of the local checkout
|
||||
- explicit `workspace create --seed-path PATH` remains unchanged in this
|
||||
milestone
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- apply the new semantics only to project-aware startup sources, not every
|
||||
explicit directory seed
|
||||
- do not silently include ignored or untracked junk in the default chat-host
|
||||
path
|
||||
- preserve explicit diff, export, sync push, and reset behavior
|
||||
- surface the chosen project source clearly enough that users know what the
|
||||
sandbox started from
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no generic SCM abstraction layer
|
||||
- no silent live sync between the host checkout and the guest
|
||||
- no change to explicit archive seeding semantics in this milestone
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- starting `pyro mcp serve` from a repo root no longer fails on unreadable
|
||||
build artifacts or ignored runtime byproducts
|
||||
- starting from `--project-path` inside a Git repo behaves the same way
|
||||
- users can predict that the startup source matches the tracked project state
|
||||
rather than the entire working tree
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- README, install docs, integrations docs, and public contract updated to state
|
||||
what local project-aware startup actually includes
|
||||
- help text updated to distinguish project-aware startup from explicit
|
||||
`--seed-path` behavior
|
||||
- at least one guest-backed smoke scenario added for a repo with ignored,
|
||||
generated, and unreadable files
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
# `4.7.0` Project Source Diagnostics And Recovery
|
||||
|
||||
Status: Planned
|
||||
|
||||
## Goal
|
||||
|
||||
Make project-source selection and startup failures understandable enough that a
|
||||
chat-host user can recover without reading internals or raw tracebacks.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
The chat-host path should expose clearer project-source diagnostics:
|
||||
|
||||
- `pyro doctor` should report the active project-source kind and its readiness
|
||||
- `pyro mcp serve` and host helpers should explain whether they are using
|
||||
tracked local files, `--project-path`, `--repo-url`, or no project source
|
||||
- startup failures should recommend the right fallback:
|
||||
`--project-path`, `--repo-url`, `--no-project-source`, or explicit
|
||||
`seed_path`
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- keep diagnostics focused on the chat-host path rather than inventing a broad
|
||||
source-management subsystem
|
||||
- prefer actionable recovery guidance over long implementation detail dumps
|
||||
- make project-source diagnostics visible from the same surfaces users already
|
||||
touch: help text, `doctor`, host helpers, and startup errors
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no generic repo-health audit product
|
||||
- no attempt to auto-fix arbitrary local checkout corruption
|
||||
- no host-specific divergence in project-source behavior
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- a user can tell which project source the chat host will use before creating a
|
||||
workspace
|
||||
- a user who hits a project-source failure gets a concrete recovery path instead
|
||||
of a raw permission traceback
|
||||
- host helper doctor and repair flows can explain project-source problems, not
|
||||
only MCP config problems
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- docs, help text, and troubleshooting updated with project-source diagnostics
|
||||
and fallback guidance
|
||||
- at least one smoke or targeted CLI test covering the new failure guidance
|
||||
- host-helper docs updated to show when to prefer `--project-path`,
|
||||
`--repo-url`, or `--no-project-source`
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
# `4.8.0` First-Class Chat Environment Selection
|
||||
|
||||
Status: Planned
|
||||
|
||||
## Goal
|
||||
|
||||
Make curated environment choice part of the normal chat-host path so full
|
||||
project work is not implicitly tied to one default environment.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
Environment selection should become first-class in the chat-host path:
|
||||
|
||||
- `pyro mcp serve` should accept an explicit environment
|
||||
- `pyro host connect` should accept and preserve an explicit environment
|
||||
- `pyro host print-config` and `pyro host repair` should preserve the selected
|
||||
environment where relevant
|
||||
- named modes should be able to recommend a default environment when one is
|
||||
better for the workflow, without removing explicit user choice
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- keep environment selection aligned with the existing curated environment
|
||||
catalog
|
||||
- avoid inventing host-specific environment behavior for the same mode
|
||||
- keep the default environment path simple for the quickest evaluator flow
|
||||
- ensure the chosen environment is visible from generated config, help text, and
|
||||
diagnostics
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no custom user-built environment pipeline in this milestone
|
||||
- no per-host environment negotiation logic
|
||||
- no attempt to solve arbitrary dependency installation through environment
|
||||
sprawl alone
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- a user can choose a build-oriented environment such as `debian:12-build`
|
||||
before connecting the chat host
|
||||
- host helpers, raw server startup, and printed configs all preserve the same
|
||||
environment choice
|
||||
- docs can teach whole-project development without pretending every project fits
|
||||
the same default environment
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- README, install docs, integrations docs, public contract, and host examples
|
||||
updated to show environment selection in the chat-host path
|
||||
- help text updated for raw server startup and host helpers
|
||||
- at least one guest-backed smoke scenario updated to prove a non-default
|
||||
environment in the chat-host flow
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
# `4.9.0` Real-Repo Qualification Smokes
|
||||
|
||||
Status: Planned
|
||||
|
||||
## Goal
|
||||
|
||||
Replace fixture-only confidence with guest-backed proof that the chat-host path
|
||||
works against messy local repos and clean-clone startup sources.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
No new runtime surface is required in this milestone. The main additions are
|
||||
qualification smokes and their supporting fixtures.
|
||||
|
||||
The new coverage should prove:
|
||||
|
||||
- repo-root startup from a local Git checkout with ignored, generated, and
|
||||
unreadable files
|
||||
- `--repo-url` clean-clone startup
|
||||
- a realistic install, test, patch, rerun, and export loop
|
||||
- at least one nontrivial service-start or readiness loop
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- keep the smoke pack guest-backed and deterministic enough to use as a release
|
||||
gate
|
||||
- focus on realistic repo-shape and project-loop problems, not synthetic
|
||||
micro-feature assertions
|
||||
- prefer a small number of representative project fixtures over a large matrix
|
||||
of toy repos
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no promise to qualify every language ecosystem in one milestone
|
||||
- no cloud or remote execution qualification layer
|
||||
- no broad benchmark suite beyond what is needed to prove readiness
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- the repo has one clear smoke target for real-repo qualification
|
||||
- at least one local-checkout smoke proves the new Git-tracked startup behavior
|
||||
- at least one clean-clone smoke proves the `--repo-url` path
|
||||
- failures in these smokes clearly separate project-source issues from runtime
|
||||
or host issues
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- new guest-backed smoke targets and any supporting fixtures
|
||||
- roadmap, use-case docs, and release/readiness docs updated to point at the
|
||||
new qualification path
|
||||
- troubleshooting updated with the distinction between shaped use-case smokes
|
||||
and real-repo qualification smokes
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
# `5.0.0` Whole-Project Sandbox Development
|
||||
|
||||
Status: Planned
|
||||
|
||||
## Goal
|
||||
|
||||
Reach the point where it is credible to tell a user they can develop a real
|
||||
project inside sandboxes, not just validate, inspect, or patch one.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
No new generic VM breadth is required here. This milestone should consolidate
|
||||
the earlier pieces into one believable full-project product story:
|
||||
|
||||
- robust project-aware startup
|
||||
- explicit environment selection in the chat-host path
|
||||
- summaries, reset, export, and service workflows that hold up during longer
|
||||
work loops
|
||||
- qualification targets that prove a nontrivial development cycle
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- keep the product centered on the chat-host workspace path rather than a broad
|
||||
CLI or SDK platform story
|
||||
- use the existing named modes and generic workspace path where they fit, but
|
||||
teach one end-to-end full-project development walkthrough
|
||||
- prioritize daily development credibility over adding new low-level sandbox
|
||||
surfaces
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no attempt to become a generic remote dev environment platform
|
||||
- no scheduler, queue, or CI matrix abstractions
|
||||
- no claim that every possible project type is equally well supported
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- the docs contain one end-to-end “develop a project in sandboxes” walkthrough
|
||||
- that walkthrough covers dependency install, tests, patching, reruns, review,
|
||||
and export, with app/service startup when relevant
|
||||
- at least one guest-backed qualification target proves the story on a
|
||||
nontrivial project
|
||||
- the readiness docs can honestly say whole-project development is supported
|
||||
with explicit caveats instead of hedged aspirational language
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- README, install docs, integrations docs, use-case docs, and public contract
|
||||
updated to include the whole-project development story
|
||||
- at least one walkthrough asset or transcript added for the new end-to-end
|
||||
path
|
||||
- readiness and troubleshooting docs updated with the actual supported scope and
|
||||
remaining caveats
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Workspace Use-Case Recipes
|
||||
|
||||
These recipes turn the stable workspace surface into five concrete agent flows.
|
||||
These recipes turn the chat-host workspace path into five concrete agent flows.
|
||||
They are the canonical next step after the quickstart in [install.md](../install.md)
|
||||
or [first-run.md](../first-run.md).
|
||||
|
||||
|
|
@ -12,13 +12,13 @@ make smoke-use-cases
|
|||
|
||||
Recipe matrix:
|
||||
|
||||
| Use case | Recommended profile | Smoke target | Recipe |
|
||||
| Use case | Recommended mode | Smoke target | Recipe |
|
||||
| --- | --- | --- | --- |
|
||||
| Cold-start repo validation | `workspace-full` | `make smoke-cold-start-validation` | [cold-start-repo-validation.md](cold-start-repo-validation.md) |
|
||||
| Repro plus fix loop | `workspace-core` | `make smoke-repro-fix-loop` | [repro-fix-loop.md](repro-fix-loop.md) |
|
||||
| Parallel isolated workspaces | `workspace-core` | `make smoke-parallel-workspaces` | [parallel-workspaces.md](parallel-workspaces.md) |
|
||||
| Unsafe or untrusted code inspection | `workspace-core` | `make smoke-untrusted-inspection` | [untrusted-inspection.md](untrusted-inspection.md) |
|
||||
| Review and evaluation workflows | `workspace-full` | `make smoke-review-eval` | [review-eval-workflows.md](review-eval-workflows.md) |
|
||||
| Cold-start repo validation | `cold-start` | `make smoke-cold-start-validation` | [cold-start-repo-validation.md](cold-start-repo-validation.md) |
|
||||
| Repro plus fix loop | `repro-fix` | `make smoke-repro-fix-loop` | [repro-fix-loop.md](repro-fix-loop.md) |
|
||||
| Parallel isolated workspaces | `repro-fix` | `make smoke-parallel-workspaces` | [parallel-workspaces.md](parallel-workspaces.md) |
|
||||
| Unsafe or untrusted code inspection | `inspect` | `make smoke-untrusted-inspection` | [untrusted-inspection.md](untrusted-inspection.md) |
|
||||
| Review and evaluation workflows | `review-eval` | `make smoke-review-eval` | [review-eval-workflows.md](review-eval-workflows.md) |
|
||||
|
||||
All five recipes use the same real Firecracker-backed smoke runner:
|
||||
|
||||
|
|
@ -30,3 +30,9 @@ That runner generates its own host fixtures, creates real guest-backed workspace
|
|||
verifies the intended flow, exports one concrete result when relevant, and cleans
|
||||
up on both success and failure. Treat `make smoke-use-cases` as the trustworthy
|
||||
guest-backed verification path for the advertised workspace workflows.
|
||||
|
||||
For a concise review before exporting, resetting, or handing work off, use:
|
||||
|
||||
```bash
|
||||
pyro workspace summary WORKSPACE_ID
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
# Cold-Start Repo Validation
|
||||
|
||||
Recommended profile: `workspace-full`
|
||||
Recommended mode: `cold-start`
|
||||
|
||||
Recommended startup:
|
||||
|
||||
```bash
|
||||
pyro host connect claude-code --mode cold-start
|
||||
```
|
||||
|
||||
Smoke target:
|
||||
|
||||
|
|
@ -12,26 +18,18 @@ Use this flow when an agent needs to treat a fresh repo like a new user would:
|
|||
seed it into a workspace, run the validation script, keep one long-running
|
||||
process alive, probe it from another command, and export a validation report.
|
||||
|
||||
Canonical SDK flow:
|
||||
Chat-host recipe:
|
||||
|
||||
```python
|
||||
from pyro_mcp import Pyro
|
||||
1. Create one workspace from the repo seed.
|
||||
2. Run the validation command inside that workspace.
|
||||
3. Start the app as a long-running service with readiness configured.
|
||||
4. Probe the ready service from another command in the same workspace.
|
||||
5. Export the validation report back to the host.
|
||||
6. Delete the workspace when the evaluation is done.
|
||||
|
||||
pyro = Pyro()
|
||||
created = pyro.create_workspace(environment="debian:12", seed_path="./repo")
|
||||
workspace_id = str(created["workspace_id"])
|
||||
|
||||
pyro.exec_workspace(workspace_id, command="sh validate.sh")
|
||||
pyro.start_service(
|
||||
workspace_id,
|
||||
"app",
|
||||
command="sh serve.sh",
|
||||
readiness={"type": "file", "path": ".app-ready"},
|
||||
)
|
||||
pyro.exec_workspace(workspace_id, command="sh -lc 'test -f .app-ready && cat service-state.txt'")
|
||||
pyro.export_workspace(workspace_id, "validation-report.txt", output_path="./validation-report.txt")
|
||||
pyro.delete_workspace(workspace_id)
|
||||
```
|
||||
If the named mode feels too narrow, fall back to the generic no-mode path and
|
||||
then opt into `--profile workspace-full` only when you truly need the larger
|
||||
advanced surface.
|
||||
|
||||
This recipe is intentionally guest-local and deterministic. It proves startup,
|
||||
service readiness, validation, and host-out report capture without depending on
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
# Parallel Isolated Workspaces
|
||||
|
||||
Recommended profile: `workspace-core`
|
||||
Recommended mode: `repro-fix`
|
||||
|
||||
Recommended startup:
|
||||
|
||||
```bash
|
||||
pyro host connect codex --mode repro-fix
|
||||
```
|
||||
|
||||
Smoke target:
|
||||
|
||||
|
|
@ -11,33 +17,16 @@ make smoke-parallel-workspaces
|
|||
Use this flow when the agent needs one isolated workspace per issue, branch, or
|
||||
review thread and must rediscover the right one later.
|
||||
|
||||
Canonical SDK flow:
|
||||
Chat-host recipe:
|
||||
|
||||
```python
|
||||
from pyro_mcp import Pyro
|
||||
|
||||
pyro = Pyro()
|
||||
alpha = pyro.create_workspace(
|
||||
environment="debian:12",
|
||||
seed_path="./repo",
|
||||
name="parallel-alpha",
|
||||
labels={"branch": "alpha", "issue": "123"},
|
||||
)
|
||||
beta = pyro.create_workspace(
|
||||
environment="debian:12",
|
||||
seed_path="./repo",
|
||||
name="parallel-beta",
|
||||
labels={"branch": "beta", "issue": "456"},
|
||||
)
|
||||
|
||||
pyro.write_workspace_file(alpha["workspace_id"], "branch.txt", text="alpha\n")
|
||||
pyro.write_workspace_file(beta["workspace_id"], "branch.txt", text="beta\n")
|
||||
pyro.update_workspace(alpha["workspace_id"], labels={"branch": "alpha", "owner": "alice"})
|
||||
pyro.list_workspaces()
|
||||
pyro.delete_workspace(alpha["workspace_id"])
|
||||
pyro.delete_workspace(beta["workspace_id"])
|
||||
```
|
||||
1. Create one workspace per issue or branch with a human-friendly name and
|
||||
labels.
|
||||
2. Mutate each workspace independently.
|
||||
3. Rediscover the right workspace later with `workspace_list`.
|
||||
4. Update metadata when ownership or issue mapping changes.
|
||||
5. Delete each workspace independently when its task is done.
|
||||
|
||||
The important proof here is operational, not syntactic: names, labels, list
|
||||
ordering, and file contents stay isolated even when multiple workspaces are
|
||||
active at the same time.
|
||||
active at the same time. Parallel work still means “open another workspace in
|
||||
the same mode,” not “pick a special parallel-work mode.”
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
# Repro Plus Fix Loop
|
||||
|
||||
Recommended profile: `workspace-core`
|
||||
Recommended mode: `repro-fix`
|
||||
|
||||
Recommended startup:
|
||||
|
||||
```bash
|
||||
pyro host connect codex --mode repro-fix
|
||||
```
|
||||
|
||||
Smoke target:
|
||||
|
||||
|
|
@ -12,31 +18,21 @@ Use this flow when the agent has to reproduce a bug, patch files without shell
|
|||
quoting tricks, rerun the failing command, diff the result, export the fix, and
|
||||
reset back to baseline.
|
||||
|
||||
Canonical SDK flow:
|
||||
Chat-host recipe:
|
||||
|
||||
```python
|
||||
from pyro_mcp import Pyro
|
||||
1. Start the server from the repo root with bare `pyro mcp serve`, or use
|
||||
`--project-path` if the host does not preserve cwd.
|
||||
2. Create one workspace from that project-aware server without manually passing
|
||||
`seed_path`.
|
||||
3. Run the failing command.
|
||||
4. Inspect the broken file with structured file reads.
|
||||
5. Apply the fix with `workspace_patch_apply`.
|
||||
6. Rerun the failing command in the same workspace.
|
||||
7. Diff and export the changed result.
|
||||
8. Reset to baseline and delete the workspace.
|
||||
|
||||
pyro = Pyro()
|
||||
created = pyro.create_workspace(environment="debian:12", seed_path="./broken-repro")
|
||||
workspace_id = str(created["workspace_id"])
|
||||
If the mode feels too narrow for the job, fall back to the generic bare
|
||||
`pyro mcp serve` path.
|
||||
|
||||
pyro.exec_workspace(workspace_id, command="sh check.sh")
|
||||
pyro.read_workspace_file(workspace_id, "message.txt")
|
||||
pyro.apply_workspace_patch(
|
||||
workspace_id,
|
||||
patch="--- a/message.txt\n+++ b/message.txt\n@@ -1 +1 @@\n-broken\n+fixed\n",
|
||||
)
|
||||
pyro.exec_workspace(workspace_id, command="sh check.sh")
|
||||
pyro.diff_workspace(workspace_id)
|
||||
pyro.export_workspace(workspace_id, "message.txt", output_path="./message.txt")
|
||||
pyro.reset_workspace(workspace_id)
|
||||
pyro.delete_workspace(workspace_id)
|
||||
```
|
||||
|
||||
Canonical MCP/chat example:
|
||||
|
||||
- [examples/openai_responses_workspace_core.py](../../examples/openai_responses_workspace_core.py)
|
||||
|
||||
This is the main `workspace-core` story: model-native file ops, repeatable exec,
|
||||
This is the main `repro-fix` story: model-native file ops, repeatable exec,
|
||||
structured diff, explicit export, and reset-over-repair.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
# Review And Evaluation Workflows
|
||||
|
||||
Recommended profile: `workspace-full`
|
||||
Recommended mode: `review-eval`
|
||||
|
||||
Recommended startup:
|
||||
|
||||
```bash
|
||||
pyro host connect claude-code --mode review-eval
|
||||
```
|
||||
|
||||
Smoke target:
|
||||
|
||||
|
|
@ -11,30 +17,15 @@ make smoke-review-eval
|
|||
Use this flow when an agent needs to read a checklist interactively, run an
|
||||
evaluation script, checkpoint or reset its changes, and export the final report.
|
||||
|
||||
Canonical SDK flow:
|
||||
Chat-host recipe:
|
||||
|
||||
```python
|
||||
from pyro_mcp import Pyro
|
||||
|
||||
pyro = Pyro()
|
||||
created = pyro.create_workspace(environment="debian:12", seed_path="./review-fixture")
|
||||
workspace_id = str(created["workspace_id"])
|
||||
|
||||
pyro.create_snapshot(workspace_id, "pre-review")
|
||||
shell = pyro.open_shell(workspace_id)
|
||||
pyro.write_shell(workspace_id, shell["shell_id"], input="cat CHECKLIST.md")
|
||||
pyro.read_shell(
|
||||
workspace_id,
|
||||
shell["shell_id"],
|
||||
plain=True,
|
||||
wait_for_idle_ms=300,
|
||||
)
|
||||
pyro.close_shell(workspace_id, shell["shell_id"])
|
||||
pyro.exec_workspace(workspace_id, command="sh review.sh")
|
||||
pyro.export_workspace(workspace_id, "review-report.txt", output_path="./review-report.txt")
|
||||
pyro.reset_workspace(workspace_id, snapshot="pre-review")
|
||||
pyro.delete_workspace(workspace_id)
|
||||
```
|
||||
1. Create a named snapshot before the review starts.
|
||||
2. Open a readable PTY shell and inspect the checklist interactively.
|
||||
3. Run the review or evaluation script in the same workspace.
|
||||
4. Capture `workspace summary` to review what changed and what to export.
|
||||
5. Export the final report.
|
||||
6. Reset back to the snapshot if the review branch goes sideways.
|
||||
7. Delete the workspace when the evaluation is done.
|
||||
|
||||
This is the stable shell-facing story: readable PTY output for chat loops,
|
||||
checkpointed evaluation, explicit export, and reset when a review branch goes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
# Unsafe Or Untrusted Code Inspection
|
||||
|
||||
Recommended profile: `workspace-core`
|
||||
Recommended mode: `inspect`
|
||||
|
||||
Recommended startup:
|
||||
|
||||
```bash
|
||||
pyro host connect codex --mode inspect
|
||||
```
|
||||
|
||||
Smoke target:
|
||||
|
||||
|
|
@ -11,24 +17,13 @@ make smoke-untrusted-inspection
|
|||
Use this flow when the agent needs to inspect suspicious code or an unfamiliar
|
||||
repo without granting more capabilities than necessary.
|
||||
|
||||
Canonical SDK flow:
|
||||
Chat-host recipe:
|
||||
|
||||
```python
|
||||
from pyro_mcp import Pyro
|
||||
|
||||
pyro = Pyro()
|
||||
created = pyro.create_workspace(environment="debian:12", seed_path="./suspicious-repo")
|
||||
workspace_id = str(created["workspace_id"])
|
||||
|
||||
pyro.list_workspace_files(workspace_id, path="/workspace", recursive=True)
|
||||
pyro.read_workspace_file(workspace_id, "suspicious.sh")
|
||||
pyro.exec_workspace(
|
||||
workspace_id,
|
||||
command="sh -lc \"grep -n 'curl' suspicious.sh > inspection-report.txt\"",
|
||||
)
|
||||
pyro.export_workspace(workspace_id, "inspection-report.txt", output_path="./inspection-report.txt")
|
||||
pyro.delete_workspace(workspace_id)
|
||||
```
|
||||
1. Create one workspace from the suspicious repo seed.
|
||||
2. Inspect the tree with structured file listing and file reads.
|
||||
3. Run the smallest possible command that produces the inspection report.
|
||||
4. Export only the report the agent chose to materialize.
|
||||
5. Delete the workspace when inspection is complete.
|
||||
|
||||
This recipe stays offline-by-default, uses only explicit file reads and execs,
|
||||
and exports only the inspection report the agent chose to materialize.
|
||||
|
|
|
|||
135
docs/vision.md
135
docs/vision.md
|
|
@ -1,16 +1,19 @@
|
|||
# Vision
|
||||
|
||||
`pyro-mcp` should become the disposable sandbox where an agent can do real
|
||||
development work safely, repeatedly, and reproducibly.
|
||||
`pyro-mcp` should become the disposable MCP workspace for chat-based coding
|
||||
agents.
|
||||
|
||||
That is a different product from a generic VM wrapper, a secure CI runner, or a
|
||||
task queue with better isolation.
|
||||
That is a different product from a generic VM wrapper, a secure CI runner, or
|
||||
an SDK-first platform.
|
||||
|
||||
`pyro-mcp` currently has no users. That means we can still make breaking
|
||||
changes freely while we shape the chat-host path into the right product.
|
||||
|
||||
## Core Thesis
|
||||
|
||||
The goal is not just to run one command in a microVM.
|
||||
|
||||
The goal is to give an LLM or coding agent a bounded workspace where it can:
|
||||
The goal is to give a chat-hosted coding agent a bounded workspace where it can:
|
||||
|
||||
- inspect a repo
|
||||
- install dependencies
|
||||
|
|
@ -23,6 +26,25 @@ The goal is to give an LLM or coding agent a bounded workspace where it can:
|
|||
|
||||
The sandbox is the execution boundary for agentic software work.
|
||||
|
||||
## Current Product Focus
|
||||
|
||||
The product path should be obvious and narrow:
|
||||
|
||||
- Claude Code
|
||||
- Codex
|
||||
- OpenCode
|
||||
- Linux `x86_64` with KVM
|
||||
|
||||
The happy path is:
|
||||
|
||||
1. prove the host with the terminal companion commands
|
||||
2. run `pyro mcp serve`
|
||||
3. connect a chat host
|
||||
4. work through one disposable workspace per task
|
||||
|
||||
The repo can contain lower-level building blocks, but they should not drive the
|
||||
product story.
|
||||
|
||||
## What This Is Not
|
||||
|
||||
`pyro-mcp` should not drift into:
|
||||
|
|
@ -32,9 +54,10 @@ The sandbox is the execution boundary for agentic software work.
|
|||
- a generic CI job runner
|
||||
- a scheduler or queueing platform
|
||||
- a broad VM orchestration product
|
||||
- an SDK product that happens to have an MCP server on the side
|
||||
|
||||
Those products optimize for queued work, throughput, retries, matrix builds, and
|
||||
shared infrastructure.
|
||||
Those products optimize for queued work, throughput, retries, matrix builds, or
|
||||
library ergonomics.
|
||||
|
||||
`pyro-mcp` should optimize for agent loops:
|
||||
|
||||
|
|
@ -57,10 +80,15 @@ Any sandbox product starts to look like CI if the main abstraction is:
|
|||
That shape is useful, but it is not the center of the vision.
|
||||
|
||||
To stay aligned, the primary abstraction should be a workspace the agent
|
||||
inhabits, not a job the agent submits.
|
||||
inhabits from a chat host, not a job the agent submits to a runner.
|
||||
|
||||
## Product Principles
|
||||
|
||||
### Chat Hosts First
|
||||
|
||||
The product should be shaped around the MCP path used from chat interfaces.
|
||||
Everything else is there to support, debug, or build that path.
|
||||
|
||||
### Workspace-First
|
||||
|
||||
The default mental model should be "open a disposable workspace" rather than
|
||||
|
|
@ -85,11 +113,6 @@ Anything that crosses the host boundary should be intentional and visible:
|
|||
Agents should be able to checkpoint, reset, and retry cheaply. Disposable state
|
||||
is a feature, not a limitation.
|
||||
|
||||
### Same Contract Across Surfaces
|
||||
|
||||
CLI, Python, and MCP should expose the same underlying workspace model so the
|
||||
product feels coherent no matter how it is consumed.
|
||||
|
||||
### Agent-Native Observability
|
||||
|
||||
The sandbox should expose the things an agent actually needs to reason about:
|
||||
|
|
@ -101,10 +124,16 @@ The sandbox should expose the things an agent actually needs to reason about:
|
|||
- readiness
|
||||
- exported results
|
||||
|
||||
## The Shape Of An LLM-First Sandbox
|
||||
## The Shape Of The Product
|
||||
|
||||
The strongest future direction is a small, agent-native contract built around
|
||||
workspaces, shells, files, services, and reset.
|
||||
The strongest direction is a small chat-facing contract built around:
|
||||
|
||||
- one MCP server
|
||||
- one disposable workspace model
|
||||
- structured file inspection and edits
|
||||
- repeated commands in the same sandbox
|
||||
- service lifecycle when the workflow needs it
|
||||
- reset as a first-class workflow primitive
|
||||
|
||||
Representative primitives:
|
||||
|
||||
|
|
@ -114,95 +143,57 @@ Representative primitives:
|
|||
- `workspace.sync_push`
|
||||
- `workspace.export`
|
||||
- `workspace.diff`
|
||||
- `workspace.snapshot`
|
||||
- `workspace.reset`
|
||||
- `workspace.exec`
|
||||
- `shell.open`
|
||||
- `shell.read`
|
||||
- `shell.write`
|
||||
- `shell.signal`
|
||||
- `shell.close`
|
||||
- `workspace.exec`
|
||||
- `service.start`
|
||||
- `service.status`
|
||||
- `service.logs`
|
||||
- `service.stop`
|
||||
|
||||
These names are illustrative, not a committed public API.
|
||||
|
||||
The important point is the interaction model:
|
||||
|
||||
- a shell session is interactive state inside the sandbox
|
||||
- a workspace is durable for the life of the task
|
||||
- services are first-class, not accidental background jobs
|
||||
- reset is a core workflow primitive
|
||||
These names are illustrative, not a promise that every lower-level repo surface
|
||||
should be treated as equally stable or equally important.
|
||||
|
||||
## Interactive Shells And Disk Operations
|
||||
|
||||
Interactive shells are aligned with the vision because they make the agent feel
|
||||
present inside the sandbox rather than reduced to one-shot job submission.
|
||||
|
||||
That does not mean `pyro-mcp` should become a raw SSH replacement. The shell
|
||||
should sit inside a higher-level workspace model with structured file, service,
|
||||
diff, and reset operations around it.
|
||||
They should remain subordinate to the workspace model, not replace it with a
|
||||
raw SSH story.
|
||||
|
||||
Disk-level operations are also useful, but they should remain supporting tools.
|
||||
They are good for:
|
||||
Disk-level operations are useful for:
|
||||
|
||||
- fast workspace seeding
|
||||
- snapshotting
|
||||
- offline inspection
|
||||
- diffing
|
||||
- export/import without a full boot
|
||||
|
||||
They should not become the primary product identity. If the center of the
|
||||
product becomes "operate on VM disks", it will read as image tooling rather
|
||||
than an agent workspace.
|
||||
They should remain supporting tools rather than the product identity.
|
||||
|
||||
## What To Build Next
|
||||
|
||||
Features should be prioritized in this order:
|
||||
Features should keep reinforcing the chat-host path in this order:
|
||||
|
||||
1. Repeated commands in one persistent workspace
|
||||
2. Interactive shell sessions with PTY semantics
|
||||
3. Structured workspace sync and export
|
||||
4. Service lifecycle and readiness checks
|
||||
5. Snapshot and reset workflows
|
||||
6. Explicit secrets and network policy
|
||||
7. Secondary disk-level import/export and inspection tools
|
||||
1. make the first chat-host setup painfully obvious
|
||||
2. make the recipe-backed workflows feel trivial from chat
|
||||
3. keep the smoke pack trustworthy enough to gate the advertised stories
|
||||
4. keep the terminal companion path good enough to debug what the chat sees
|
||||
5. let lower-level repo surfaces move freely when the chat-host product needs it
|
||||
|
||||
The completed workspace GA roadmap lives in
|
||||
[roadmap/task-workspace-ga.md](roadmap/task-workspace-ga.md).
|
||||
|
||||
The next implementation milestones that make those workflows feel natural from
|
||||
chat-driven LLM interfaces live in
|
||||
The follow-on milestones that make the chat-host path clearer live in
|
||||
[roadmap/llm-chat-ergonomics.md](roadmap/llm-chat-ergonomics.md).
|
||||
|
||||
## Naming Guidance
|
||||
|
||||
Prefer language that reinforces the workspace model:
|
||||
|
||||
- `workspace`
|
||||
- `sandbox`
|
||||
- `shell`
|
||||
- `service`
|
||||
- `snapshot`
|
||||
- `reset`
|
||||
|
||||
Avoid centering language that makes the product feel like CI infrastructure:
|
||||
|
||||
- `job`
|
||||
- `runner`
|
||||
- `pipeline`
|
||||
- `worker`
|
||||
- `queue`
|
||||
- `build matrix`
|
||||
|
||||
## Litmus Test
|
||||
|
||||
When evaluating a new feature, ask:
|
||||
|
||||
"Does this help an agent inhabit a safe disposable workspace and do real
|
||||
software work inside it?"
|
||||
"Does this make Claude Code, Codex, or OpenCode feel more natural and powerful
|
||||
when they work inside a disposable sandbox?"
|
||||
|
||||
If the better description is "it helps submit, schedule, and report jobs", the
|
||||
feature is probably pushing the product in the wrong direction.
|
||||
If the better description is "it helps build a broader VM toolkit or SDK", it
|
||||
is probably pushing the product in the wrong direction.
|
||||
|
|
|
|||
|
|
@ -1,21 +1,49 @@
|
|||
# Claude Code MCP Setup
|
||||
|
||||
Recommended profile: `workspace-core`.
|
||||
Recommended modes:
|
||||
|
||||
- `cold-start`
|
||||
- `review-eval`
|
||||
|
||||
Preferred helper flow:
|
||||
|
||||
```bash
|
||||
pyro host connect claude-code --mode cold-start
|
||||
pyro host connect claude-code --mode review-eval
|
||||
pyro host doctor --mode cold-start
|
||||
```
|
||||
|
||||
Package without install:
|
||||
|
||||
```bash
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
||||
claude mcp list
|
||||
```
|
||||
|
||||
Run that from the repo root when you want the first `workspace_create` to start
|
||||
from the current checkout automatically.
|
||||
|
||||
Already installed:
|
||||
|
||||
```bash
|
||||
claude mcp add pyro -- pyro mcp serve
|
||||
claude mcp add pyro -- pyro mcp serve --mode cold-start
|
||||
claude mcp list
|
||||
```
|
||||
|
||||
If Claude Code launches the server from an unexpected cwd, pin the project
|
||||
explicitly:
|
||||
|
||||
```bash
|
||||
pyro host connect claude-code --mode cold-start --project-path /abs/path/to/repo
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start --project-path /abs/path/to/repo
|
||||
```
|
||||
|
||||
If the local config drifts later:
|
||||
|
||||
```bash
|
||||
pyro host repair claude-code --mode cold-start
|
||||
```
|
||||
|
||||
Move to `workspace-full` only when the chat truly needs shells, services,
|
||||
snapshots, secrets, network policy, or disk tools:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,49 @@
|
|||
# Codex MCP Setup
|
||||
|
||||
Recommended profile: `workspace-core`.
|
||||
Recommended modes:
|
||||
|
||||
- `repro-fix`
|
||||
- `inspect`
|
||||
|
||||
Preferred helper flow:
|
||||
|
||||
```bash
|
||||
pyro host connect codex --mode repro-fix
|
||||
pyro host connect codex --mode inspect
|
||||
pyro host doctor --mode repro-fix
|
||||
```
|
||||
|
||||
Package without install:
|
||||
|
||||
```bash
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
||||
codex mcp list
|
||||
```
|
||||
|
||||
Run that from the repo root when you want the first `workspace_create` to start
|
||||
from the current checkout automatically.
|
||||
|
||||
Already installed:
|
||||
|
||||
```bash
|
||||
codex mcp add pyro -- pyro mcp serve
|
||||
codex mcp add pyro -- pyro mcp serve --mode repro-fix
|
||||
codex mcp list
|
||||
```
|
||||
|
||||
If Codex launches the server from an unexpected cwd, pin the project
|
||||
explicitly:
|
||||
|
||||
```bash
|
||||
pyro host connect codex --mode repro-fix --project-path /abs/path/to/repo
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix --project-path /abs/path/to/repo
|
||||
```
|
||||
|
||||
If the local config drifts later:
|
||||
|
||||
```bash
|
||||
pyro host repair codex --mode repro-fix
|
||||
```
|
||||
|
||||
Move to `workspace-full` only when the chat truly needs shells, services,
|
||||
snapshots, secrets, network policy, or disk tools:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
# MCP Client Config Example
|
||||
|
||||
Default for most chat hosts in `4.x`: `workspace-core`.
|
||||
Recommended named modes for most chat hosts in `4.x`:
|
||||
|
||||
- `repro-fix`
|
||||
- `inspect`
|
||||
- `cold-start`
|
||||
- `review-eval`
|
||||
|
||||
Use the host-specific examples first when they apply:
|
||||
|
||||
|
|
@ -8,8 +13,18 @@ Use the host-specific examples first when they apply:
|
|||
- Codex: [examples/codex_mcp.md](codex_mcp.md)
|
||||
- OpenCode: [examples/opencode_mcp_config.json](opencode_mcp_config.json)
|
||||
|
||||
Preferred repair/bootstrap helpers:
|
||||
|
||||
- `pyro host connect codex --mode repro-fix`
|
||||
- `pyro host connect codex --mode inspect`
|
||||
- `pyro host connect claude-code --mode cold-start`
|
||||
- `pyro host connect claude-code --mode review-eval`
|
||||
- `pyro host print-config opencode --mode repro-fix`
|
||||
- `pyro host doctor --mode repro-fix`
|
||||
- `pyro host repair opencode --mode repro-fix`
|
||||
|
||||
Use this generic config only when the host expects a plain `mcpServers` JSON
|
||||
shape.
|
||||
shape or when the named modes are too narrow for the workflow.
|
||||
|
||||
`pyro-mcp` is intended to be exposed to LLM clients through the public `pyro` CLI.
|
||||
|
||||
|
|
@ -20,7 +35,7 @@ Generic stdio MCP configuration using `uvx`:
|
|||
"mcpServers": {
|
||||
"pyro": {
|
||||
"command": "uvx",
|
||||
"args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"]
|
||||
"args": ["--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -33,21 +48,28 @@ If `pyro-mcp` is already installed locally, the same server can be configured wi
|
|||
"mcpServers": {
|
||||
"pyro": {
|
||||
"command": "pyro",
|
||||
"args": ["mcp", "serve"]
|
||||
"args": ["mcp", "serve", "--mode", "repro-fix"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Profile progression:
|
||||
If the host does not preserve the server working directory and you want the
|
||||
first `workspace_create` to start from a specific checkout, add
|
||||
`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same args list.
|
||||
|
||||
- `workspace-core`: the default and recommended first persistent chat profile
|
||||
- `vm-run`: expose only `vm_run`
|
||||
Mode progression:
|
||||
|
||||
- `repro-fix`: patch, rerun, diff, export, reset
|
||||
- `inspect`: narrow offline-by-default inspection
|
||||
- `cold-start`: validation plus service readiness
|
||||
- `review-eval`: shell and snapshot-driven review
|
||||
- generic no-mode path: the fallback when the named mode is too narrow
|
||||
- `workspace-full`: explicit advanced opt-in for shells, services, snapshots, secrets, network policy, and disk tools
|
||||
|
||||
Primary profile for most agents:
|
||||
Primary mode for most agents:
|
||||
|
||||
- `workspace-core`
|
||||
- `repro-fix`
|
||||
|
||||
Use lifecycle tools only when the agent needs persistent VM state across multiple tool calls.
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"pyro": {
|
||||
"type": "local",
|
||||
"enabled": true,
|
||||
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]
|
||||
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
[project]
|
||||
name = "pyro-mcp"
|
||||
version = "4.0.0"
|
||||
description = "Stable Firecracker workspaces, one-shot sandboxes, and MCP tools for coding agents."
|
||||
version = "4.5.0"
|
||||
description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM."
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [
|
||||
|
|
@ -9,7 +9,7 @@ authors = [
|
|||
]
|
||||
requires-python = ">=3.12"
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
|
|
|
|||
8
scripts/daily_loop_smoke.py
Normal file
8
scripts/daily_loop_smoke.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
"""Run the real guest-backed daily-loop smoke."""
|
||||
|
||||
from pyro_mcp.daily_loop_smoke import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -8,11 +8,22 @@ from typing import Any, Literal, cast
|
|||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from pyro_mcp.contract import (
|
||||
PUBLIC_MCP_COLD_START_MODE_TOOLS,
|
||||
PUBLIC_MCP_INSPECT_MODE_TOOLS,
|
||||
PUBLIC_MCP_MODES,
|
||||
PUBLIC_MCP_PROFILES,
|
||||
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
|
||||
PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS,
|
||||
PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
|
||||
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
|
||||
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS,
|
||||
)
|
||||
from pyro_mcp.project_startup import (
|
||||
ProjectStartupSource,
|
||||
describe_project_startup_source,
|
||||
materialize_project_startup_source,
|
||||
resolve_project_startup_source,
|
||||
)
|
||||
from pyro_mcp.vm_manager import (
|
||||
DEFAULT_ALLOW_HOST_COMPAT,
|
||||
DEFAULT_MEM_MIB,
|
||||
|
|
@ -24,12 +35,77 @@ from pyro_mcp.vm_manager import (
|
|||
)
|
||||
|
||||
McpToolProfile = Literal["vm-run", "workspace-core", "workspace-full"]
|
||||
WorkspaceUseCaseMode = Literal["repro-fix", "inspect", "cold-start", "review-eval"]
|
||||
|
||||
_PROFILE_TOOLS: dict[str, tuple[str, ...]] = {
|
||||
"vm-run": PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
|
||||
"workspace-core": PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
|
||||
"workspace-full": PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS,
|
||||
}
|
||||
_MODE_TOOLS: dict[str, tuple[str, ...]] = {
|
||||
"repro-fix": PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
|
||||
"inspect": PUBLIC_MCP_INSPECT_MODE_TOOLS,
|
||||
"cold-start": PUBLIC_MCP_COLD_START_MODE_TOOLS,
|
||||
"review-eval": PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS,
|
||||
}
|
||||
_MODE_CREATE_INTENT: dict[str, str] = {
|
||||
"repro-fix": "to reproduce a failure, patch files, rerun, diff, export, and reset",
|
||||
"inspect": "to inspect suspicious or unfamiliar code with the smallest persistent surface",
|
||||
"cold-start": (
|
||||
"to validate a fresh repo, keep one service alive, and export a "
|
||||
"validation report"
|
||||
),
|
||||
"review-eval": "to review interactively, checkpoint work, and export the final report",
|
||||
}
|
||||
_MODE_TOOL_DESCRIPTIONS: dict[str, dict[str, str]] = {
|
||||
"repro-fix": {
|
||||
"workspace_file_read": "Read one workspace file while investigating the broken state.",
|
||||
"workspace_file_write": "Write one workspace file directly as part of the fix loop.",
|
||||
"workspace_patch_apply": "Apply a structured text patch inside the workspace fix loop.",
|
||||
"workspace_export": "Export the fixed result or patch-ready file back to the host.",
|
||||
"workspace_summary": (
|
||||
"Summarize the current repro/fix session for review before export "
|
||||
"or reset."
|
||||
),
|
||||
},
|
||||
"inspect": {
|
||||
"workspace_file_list": "List suspicious files under the current workspace path.",
|
||||
"workspace_file_read": "Read one suspicious or unfamiliar workspace file.",
|
||||
"workspace_export": (
|
||||
"Export only the inspection report or artifact you chose to "
|
||||
"materialize."
|
||||
),
|
||||
"workspace_summary": "Summarize the current inspection session and its exported results.",
|
||||
},
|
||||
"cold-start": {
|
||||
"workspace_create": "Create and start a persistent workspace for cold-start validation.",
|
||||
"workspace_export": "Export the validation report or other final host-visible result.",
|
||||
"workspace_summary": (
|
||||
"Summarize the current validation session, service state, and "
|
||||
"exports."
|
||||
),
|
||||
"service_start": "Start a named validation or app service and wait for typed readiness.",
|
||||
"service_list": "List running and exited validation services in the current workspace.",
|
||||
"service_status": "Inspect one validation service and its readiness outcome.",
|
||||
"service_logs": "Read stdout and stderr from one validation service.",
|
||||
"service_stop": "Stop one validation service in the workspace.",
|
||||
},
|
||||
"review-eval": {
|
||||
"workspace_create": (
|
||||
"Create and start a persistent workspace for interactive review "
|
||||
"or evaluation."
|
||||
),
|
||||
"workspace_summary": "Summarize the current review session before exporting or resetting.",
|
||||
"shell_open": "Open an interactive review shell inside the workspace.",
|
||||
"shell_read": "Read chat-friendly PTY output from the current review shell.",
|
||||
"shell_write": "Send one line of input to the current review shell.",
|
||||
"shell_signal": "Interrupt or terminate the current review shell process group.",
|
||||
"shell_close": "Close the current review shell.",
|
||||
"snapshot_create": "Create a checkpoint before the review branch diverges.",
|
||||
"snapshot_list": "List the baseline plus named review checkpoints.",
|
||||
"snapshot_delete": "Delete one named review checkpoint.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _validate_mcp_profile(profile: str) -> McpToolProfile:
|
||||
|
|
@ -39,6 +115,44 @@ def _validate_mcp_profile(profile: str) -> McpToolProfile:
|
|||
return cast(McpToolProfile, profile)
|
||||
|
||||
|
||||
def _validate_workspace_mode(mode: str) -> WorkspaceUseCaseMode:
|
||||
if mode not in PUBLIC_MCP_MODES:
|
||||
expected = ", ".join(PUBLIC_MCP_MODES)
|
||||
raise ValueError(f"unknown workspace mode {mode!r}; expected one of: {expected}")
|
||||
return cast(WorkspaceUseCaseMode, mode)
|
||||
|
||||
|
||||
def _workspace_create_description(
|
||||
startup_source: ProjectStartupSource | None,
|
||||
*,
|
||||
mode: WorkspaceUseCaseMode | None = None,
|
||||
) -> str:
|
||||
if mode is not None:
|
||||
prefix = (
|
||||
"Create and start a persistent workspace "
|
||||
f"{_MODE_CREATE_INTENT[mode]}."
|
||||
)
|
||||
else:
|
||||
prefix = "Create and start a persistent workspace."
|
||||
if startup_source is None:
|
||||
return prefix
|
||||
described_source = describe_project_startup_source(startup_source)
|
||||
if described_source is None:
|
||||
return prefix
|
||||
return f"{prefix} If `seed_path` is omitted, the server seeds from {described_source}."
|
||||
|
||||
|
||||
def _tool_description(
|
||||
tool_name: str,
|
||||
*,
|
||||
mode: WorkspaceUseCaseMode | None,
|
||||
fallback: str,
|
||||
) -> str:
|
||||
if mode is None:
|
||||
return fallback
|
||||
return _MODE_TOOL_DESCRIPTIONS.get(mode, {}).get(tool_name, fallback)
|
||||
|
||||
|
||||
class Pyro:
|
||||
"""High-level facade over the ephemeral VM runtime."""
|
||||
|
||||
|
|
@ -186,6 +300,9 @@ class Pyro:
|
|||
def logs_workspace(self, workspace_id: str) -> dict[str, Any]:
|
||||
return self._manager.logs_workspace(workspace_id)
|
||||
|
||||
def summarize_workspace(self, workspace_id: str) -> dict[str, Any]:
|
||||
return self._manager.summarize_workspace(workspace_id)
|
||||
|
||||
def export_workspace(
|
||||
self,
|
||||
workspace_id: str,
|
||||
|
|
@ -462,15 +579,40 @@ class Pyro:
|
|||
allow_host_compat=allow_host_compat,
|
||||
)
|
||||
|
||||
def create_server(self, *, profile: McpToolProfile = "workspace-core") -> FastMCP:
|
||||
def create_server(
|
||||
self,
|
||||
*,
|
||||
profile: McpToolProfile = "workspace-core",
|
||||
mode: WorkspaceUseCaseMode | None = None,
|
||||
project_path: str | Path | None = None,
|
||||
repo_url: str | None = None,
|
||||
repo_ref: str | None = None,
|
||||
no_project_source: bool = False,
|
||||
) -> FastMCP:
|
||||
"""Create an MCP server for one of the stable public tool profiles.
|
||||
|
||||
`workspace-core` is the default stable chat-host profile in 4.x. Use
|
||||
`profile="workspace-full"` only when the host truly needs the full
|
||||
advanced workspace surface.
|
||||
advanced workspace surface. By default, the server auto-detects the
|
||||
nearest Git worktree root from its current working directory and uses
|
||||
that source when `workspace_create` omits `seed_path`. `project_path`,
|
||||
`repo_url`, and `no_project_source` override that behavior explicitly.
|
||||
"""
|
||||
normalized_profile = _validate_mcp_profile(profile)
|
||||
enabled_tools = set(_PROFILE_TOOLS[normalized_profile])
|
||||
normalized_mode = _validate_workspace_mode(mode) if mode is not None else None
|
||||
if normalized_mode is not None and normalized_profile != "workspace-core":
|
||||
raise ValueError("mode and profile are mutually exclusive")
|
||||
startup_source = resolve_project_startup_source(
|
||||
project_path=project_path,
|
||||
repo_url=repo_url,
|
||||
repo_ref=repo_ref,
|
||||
no_project_source=no_project_source,
|
||||
)
|
||||
enabled_tools = set(
|
||||
_MODE_TOOLS[normalized_mode]
|
||||
if normalized_mode is not None
|
||||
else _PROFILE_TOOLS[normalized_profile]
|
||||
)
|
||||
server = FastMCP(name="pyro_mcp")
|
||||
|
||||
def _enabled(tool_name: str) -> bool:
|
||||
|
|
@ -583,9 +725,59 @@ class Pyro:
|
|||
return self.reap_expired()
|
||||
|
||||
if _enabled("workspace_create"):
|
||||
if normalized_profile == "workspace-core":
|
||||
workspace_create_description = _workspace_create_description(
|
||||
startup_source,
|
||||
mode=normalized_mode,
|
||||
)
|
||||
|
||||
@server.tool(name="workspace_create")
|
||||
def _create_workspace_from_server_defaults(
|
||||
*,
|
||||
environment: str,
|
||||
vcpu_count: int,
|
||||
mem_mib: int,
|
||||
ttl_seconds: int,
|
||||
network_policy: str,
|
||||
allow_host_compat: bool,
|
||||
seed_path: str | None,
|
||||
secrets: list[dict[str, str]] | None,
|
||||
name: str | None,
|
||||
labels: dict[str, str] | None,
|
||||
) -> dict[str, Any]:
|
||||
if seed_path is not None or startup_source is None:
|
||||
return self.create_workspace(
|
||||
environment=environment,
|
||||
vcpu_count=vcpu_count,
|
||||
mem_mib=mem_mib,
|
||||
ttl_seconds=ttl_seconds,
|
||||
network_policy=network_policy,
|
||||
allow_host_compat=allow_host_compat,
|
||||
seed_path=seed_path,
|
||||
secrets=secrets,
|
||||
name=name,
|
||||
labels=labels,
|
||||
)
|
||||
with materialize_project_startup_source(startup_source) as resolved_seed_path:
|
||||
prepared_seed = self._manager._prepare_workspace_seed( # noqa: SLF001
|
||||
resolved_seed_path,
|
||||
origin_kind=startup_source.kind,
|
||||
origin_ref=startup_source.origin_ref,
|
||||
)
|
||||
return self._manager.create_workspace(
|
||||
environment=environment,
|
||||
vcpu_count=vcpu_count,
|
||||
mem_mib=mem_mib,
|
||||
ttl_seconds=ttl_seconds,
|
||||
network_policy=network_policy,
|
||||
allow_host_compat=allow_host_compat,
|
||||
secrets=secrets,
|
||||
name=name,
|
||||
labels=labels,
|
||||
_prepared_seed=prepared_seed,
|
||||
)
|
||||
|
||||
if normalized_mode is not None or normalized_profile == "workspace-core":
|
||||
|
||||
@server.tool(name="workspace_create", description=workspace_create_description)
|
||||
async def workspace_create_core(
|
||||
environment: str,
|
||||
vcpu_count: int = DEFAULT_VCPU_COUNT,
|
||||
|
|
@ -596,8 +788,7 @@ class Pyro:
|
|||
name: str | None = None,
|
||||
labels: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create and start a persistent workspace."""
|
||||
return self.create_workspace(
|
||||
return _create_workspace_from_server_defaults(
|
||||
environment=environment,
|
||||
vcpu_count=vcpu_count,
|
||||
mem_mib=mem_mib,
|
||||
|
|
@ -612,7 +803,7 @@ class Pyro:
|
|||
|
||||
else:
|
||||
|
||||
@server.tool(name="workspace_create")
|
||||
@server.tool(name="workspace_create", description=workspace_create_description)
|
||||
async def workspace_create_full(
|
||||
environment: str,
|
||||
vcpu_count: int = DEFAULT_VCPU_COUNT,
|
||||
|
|
@ -625,8 +816,7 @@ class Pyro:
|
|||
name: str | None = None,
|
||||
labels: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create and start a persistent workspace."""
|
||||
return self.create_workspace(
|
||||
return _create_workspace_from_server_defaults(
|
||||
environment=environment,
|
||||
vcpu_count=vcpu_count,
|
||||
mem_mib=mem_mib,
|
||||
|
|
@ -666,15 +856,21 @@ class Pyro:
|
|||
)
|
||||
|
||||
if _enabled("workspace_exec"):
|
||||
if normalized_profile == "workspace-core":
|
||||
if normalized_mode is not None or normalized_profile == "workspace-core":
|
||||
|
||||
@server.tool(name="workspace_exec")
|
||||
@server.tool(
|
||||
name="workspace_exec",
|
||||
description=_tool_description(
|
||||
"workspace_exec",
|
||||
mode=normalized_mode,
|
||||
fallback="Run one command inside an existing persistent workspace.",
|
||||
),
|
||||
)
|
||||
async def workspace_exec_core(
|
||||
workspace_id: str,
|
||||
command: str,
|
||||
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> dict[str, Any]:
|
||||
"""Run one command inside an existing persistent workspace."""
|
||||
return self.exec_workspace(
|
||||
workspace_id,
|
||||
command=command,
|
||||
|
|
@ -684,14 +880,20 @@ class Pyro:
|
|||
|
||||
else:
|
||||
|
||||
@server.tool(name="workspace_exec")
|
||||
@server.tool(
|
||||
name="workspace_exec",
|
||||
description=_tool_description(
|
||||
"workspace_exec",
|
||||
mode=normalized_mode,
|
||||
fallback="Run one command inside an existing persistent workspace.",
|
||||
),
|
||||
)
|
||||
async def workspace_exec_full(
|
||||
workspace_id: str,
|
||||
command: str,
|
||||
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run one command inside an existing persistent workspace."""
|
||||
return self.exec_workspace(
|
||||
workspace_id,
|
||||
command=command,
|
||||
|
|
@ -738,15 +940,32 @@ class Pyro:
|
|||
"""Return persisted command history for one workspace."""
|
||||
return self.logs_workspace(workspace_id)
|
||||
|
||||
if _enabled("workspace_summary"):
|
||||
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"workspace_summary",
|
||||
mode=normalized_mode,
|
||||
fallback="Summarize the current workspace session for human review.",
|
||||
)
|
||||
)
|
||||
async def workspace_summary(workspace_id: str) -> dict[str, Any]:
|
||||
return self.summarize_workspace(workspace_id)
|
||||
|
||||
if _enabled("workspace_export"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"workspace_export",
|
||||
mode=normalized_mode,
|
||||
fallback="Export one file or directory from `/workspace` back to the host.",
|
||||
)
|
||||
)
|
||||
async def workspace_export(
|
||||
workspace_id: str,
|
||||
path: str,
|
||||
output_path: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Export one file or directory from `/workspace` back to the host."""
|
||||
return self.export_workspace(workspace_id, path, output_path=output_path)
|
||||
|
||||
if _enabled("workspace_diff"):
|
||||
|
|
@ -758,13 +977,21 @@ class Pyro:
|
|||
|
||||
if _enabled("workspace_file_list"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"workspace_file_list",
|
||||
mode=normalized_mode,
|
||||
fallback=(
|
||||
"List metadata for files and directories under one "
|
||||
"live workspace path."
|
||||
),
|
||||
)
|
||||
)
|
||||
async def workspace_file_list(
|
||||
workspace_id: str,
|
||||
path: str = "/workspace",
|
||||
recursive: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""List metadata for files and directories under one live workspace path."""
|
||||
return self.list_workspace_files(
|
||||
workspace_id,
|
||||
path=path,
|
||||
|
|
@ -773,13 +1000,18 @@ class Pyro:
|
|||
|
||||
if _enabled("workspace_file_read"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"workspace_file_read",
|
||||
mode=normalized_mode,
|
||||
fallback="Read one regular text file from a live workspace path.",
|
||||
)
|
||||
)
|
||||
async def workspace_file_read(
|
||||
workspace_id: str,
|
||||
path: str,
|
||||
max_bytes: int = 65536,
|
||||
) -> dict[str, Any]:
|
||||
"""Read one regular text file from a live workspace path."""
|
||||
return self.read_workspace_file(
|
||||
workspace_id,
|
||||
path,
|
||||
|
|
@ -788,13 +1020,18 @@ class Pyro:
|
|||
|
||||
if _enabled("workspace_file_write"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"workspace_file_write",
|
||||
mode=normalized_mode,
|
||||
fallback="Create or replace one regular text file under `/workspace`.",
|
||||
)
|
||||
)
|
||||
async def workspace_file_write(
|
||||
workspace_id: str,
|
||||
path: str,
|
||||
text: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Create or replace one regular text file under `/workspace`."""
|
||||
return self.write_workspace_file(
|
||||
workspace_id,
|
||||
path,
|
||||
|
|
@ -803,12 +1040,17 @@ class Pyro:
|
|||
|
||||
if _enabled("workspace_patch_apply"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"workspace_patch_apply",
|
||||
mode=normalized_mode,
|
||||
fallback="Apply a unified text patch inside one live workspace.",
|
||||
)
|
||||
)
|
||||
async def workspace_patch_apply(
|
||||
workspace_id: str,
|
||||
patch: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply a unified text patch inside one live workspace."""
|
||||
return self.apply_workspace_patch(
|
||||
workspace_id,
|
||||
patch=patch,
|
||||
|
|
@ -886,27 +1128,62 @@ class Pyro:
|
|||
return self.reset_workspace(workspace_id, snapshot=snapshot)
|
||||
|
||||
if _enabled("shell_open"):
|
||||
if normalized_mode == "review-eval":
|
||||
|
||||
@server.tool()
|
||||
async def shell_open(
|
||||
workspace_id: str,
|
||||
cwd: str = "/workspace",
|
||||
cols: int = 120,
|
||||
rows: int = 30,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Open a persistent interactive shell inside one workspace."""
|
||||
return self.open_shell(
|
||||
workspace_id,
|
||||
cwd=cwd,
|
||||
cols=cols,
|
||||
rows=rows,
|
||||
secret_env=secret_env,
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"shell_open",
|
||||
mode=normalized_mode,
|
||||
fallback="Open a persistent interactive shell inside one workspace.",
|
||||
)
|
||||
)
|
||||
async def shell_open(
|
||||
workspace_id: str,
|
||||
cwd: str = "/workspace",
|
||||
cols: int = 120,
|
||||
rows: int = 30,
|
||||
) -> dict[str, Any]:
|
||||
return self.open_shell(
|
||||
workspace_id,
|
||||
cwd=cwd,
|
||||
cols=cols,
|
||||
rows=rows,
|
||||
secret_env=None,
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"shell_open",
|
||||
mode=normalized_mode,
|
||||
fallback="Open a persistent interactive shell inside one workspace.",
|
||||
)
|
||||
)
|
||||
async def shell_open(
|
||||
workspace_id: str,
|
||||
cwd: str = "/workspace",
|
||||
cols: int = 120,
|
||||
rows: int = 30,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return self.open_shell(
|
||||
workspace_id,
|
||||
cwd=cwd,
|
||||
cols=cols,
|
||||
rows=rows,
|
||||
secret_env=secret_env,
|
||||
)
|
||||
|
||||
if _enabled("shell_read"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"shell_read",
|
||||
mode=normalized_mode,
|
||||
fallback="Read merged PTY output from a workspace shell.",
|
||||
)
|
||||
)
|
||||
async def shell_read(
|
||||
workspace_id: str,
|
||||
shell_id: str,
|
||||
|
|
@ -915,7 +1192,6 @@ class Pyro:
|
|||
plain: bool = False,
|
||||
wait_for_idle_ms: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Read merged PTY output from a workspace shell."""
|
||||
return self.read_shell(
|
||||
workspace_id,
|
||||
shell_id,
|
||||
|
|
@ -927,14 +1203,19 @@ class Pyro:
|
|||
|
||||
if _enabled("shell_write"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"shell_write",
|
||||
mode=normalized_mode,
|
||||
fallback="Write text input to a persistent workspace shell.",
|
||||
)
|
||||
)
|
||||
async def shell_write(
|
||||
workspace_id: str,
|
||||
shell_id: str,
|
||||
input: str,
|
||||
append_newline: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Write text input to a persistent workspace shell."""
|
||||
return self.write_shell(
|
||||
workspace_id,
|
||||
shell_id,
|
||||
|
|
@ -944,13 +1225,18 @@ class Pyro:
|
|||
|
||||
if _enabled("shell_signal"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"shell_signal",
|
||||
mode=normalized_mode,
|
||||
fallback="Send a signal to the shell process group.",
|
||||
)
|
||||
)
|
||||
async def shell_signal(
|
||||
workspace_id: str,
|
||||
shell_id: str,
|
||||
signal_name: str = "INT",
|
||||
) -> dict[str, Any]:
|
||||
"""Send a signal to the shell process group."""
|
||||
return self.signal_shell(
|
||||
workspace_id,
|
||||
shell_id,
|
||||
|
|
@ -959,74 +1245,142 @@ class Pyro:
|
|||
|
||||
if _enabled("shell_close"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"shell_close",
|
||||
mode=normalized_mode,
|
||||
fallback="Close a persistent workspace shell.",
|
||||
)
|
||||
)
|
||||
async def shell_close(workspace_id: str, shell_id: str) -> dict[str, Any]:
|
||||
"""Close a persistent workspace shell."""
|
||||
return self.close_shell(workspace_id, shell_id)
|
||||
|
||||
if _enabled("service_start"):
|
||||
if normalized_mode == "cold-start":
|
||||
|
||||
@server.tool()
|
||||
async def service_start(
|
||||
workspace_id: str,
|
||||
service_name: str,
|
||||
command: str,
|
||||
cwd: str = "/workspace",
|
||||
ready_file: str | None = None,
|
||||
ready_tcp: str | None = None,
|
||||
ready_http: str | None = None,
|
||||
ready_command: str | None = None,
|
||||
ready_timeout_seconds: int = 30,
|
||||
ready_interval_ms: int = 500,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
published_ports: list[dict[str, int | None]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Start a named long-running service inside a workspace."""
|
||||
readiness: dict[str, Any] | None = None
|
||||
if ready_file is not None:
|
||||
readiness = {"type": "file", "path": ready_file}
|
||||
elif ready_tcp is not None:
|
||||
readiness = {"type": "tcp", "address": ready_tcp}
|
||||
elif ready_http is not None:
|
||||
readiness = {"type": "http", "url": ready_http}
|
||||
elif ready_command is not None:
|
||||
readiness = {"type": "command", "command": ready_command}
|
||||
return self.start_service(
|
||||
workspace_id,
|
||||
service_name,
|
||||
command=command,
|
||||
cwd=cwd,
|
||||
readiness=readiness,
|
||||
ready_timeout_seconds=ready_timeout_seconds,
|
||||
ready_interval_ms=ready_interval_ms,
|
||||
secret_env=secret_env,
|
||||
published_ports=published_ports,
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"service_start",
|
||||
mode=normalized_mode,
|
||||
fallback="Start a named long-running service inside a workspace.",
|
||||
)
|
||||
)
|
||||
async def service_start(
|
||||
workspace_id: str,
|
||||
service_name: str,
|
||||
command: str,
|
||||
cwd: str = "/workspace",
|
||||
ready_file: str | None = None,
|
||||
ready_tcp: str | None = None,
|
||||
ready_http: str | None = None,
|
||||
ready_command: str | None = None,
|
||||
ready_timeout_seconds: int = 30,
|
||||
ready_interval_ms: int = 500,
|
||||
) -> dict[str, Any]:
|
||||
readiness: dict[str, Any] | None = None
|
||||
if ready_file is not None:
|
||||
readiness = {"type": "file", "path": ready_file}
|
||||
elif ready_tcp is not None:
|
||||
readiness = {"type": "tcp", "address": ready_tcp}
|
||||
elif ready_http is not None:
|
||||
readiness = {"type": "http", "url": ready_http}
|
||||
elif ready_command is not None:
|
||||
readiness = {"type": "command", "command": ready_command}
|
||||
return self.start_service(
|
||||
workspace_id,
|
||||
service_name,
|
||||
command=command,
|
||||
cwd=cwd,
|
||||
readiness=readiness,
|
||||
ready_timeout_seconds=ready_timeout_seconds,
|
||||
ready_interval_ms=ready_interval_ms,
|
||||
secret_env=None,
|
||||
published_ports=None,
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"service_start",
|
||||
mode=normalized_mode,
|
||||
fallback="Start a named long-running service inside a workspace.",
|
||||
)
|
||||
)
|
||||
async def service_start(
|
||||
workspace_id: str,
|
||||
service_name: str,
|
||||
command: str,
|
||||
cwd: str = "/workspace",
|
||||
ready_file: str | None = None,
|
||||
ready_tcp: str | None = None,
|
||||
ready_http: str | None = None,
|
||||
ready_command: str | None = None,
|
||||
ready_timeout_seconds: int = 30,
|
||||
ready_interval_ms: int = 500,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
published_ports: list[dict[str, int | None]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
readiness: dict[str, Any] | None = None
|
||||
if ready_file is not None:
|
||||
readiness = {"type": "file", "path": ready_file}
|
||||
elif ready_tcp is not None:
|
||||
readiness = {"type": "tcp", "address": ready_tcp}
|
||||
elif ready_http is not None:
|
||||
readiness = {"type": "http", "url": ready_http}
|
||||
elif ready_command is not None:
|
||||
readiness = {"type": "command", "command": ready_command}
|
||||
return self.start_service(
|
||||
workspace_id,
|
||||
service_name,
|
||||
command=command,
|
||||
cwd=cwd,
|
||||
readiness=readiness,
|
||||
ready_timeout_seconds=ready_timeout_seconds,
|
||||
ready_interval_ms=ready_interval_ms,
|
||||
secret_env=secret_env,
|
||||
published_ports=published_ports,
|
||||
)
|
||||
|
||||
if _enabled("service_list"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"service_list",
|
||||
mode=normalized_mode,
|
||||
fallback="List named services in one workspace.",
|
||||
)
|
||||
)
|
||||
async def service_list(workspace_id: str) -> dict[str, Any]:
|
||||
"""List named services in one workspace."""
|
||||
return self.list_services(workspace_id)
|
||||
|
||||
if _enabled("service_status"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"service_status",
|
||||
mode=normalized_mode,
|
||||
fallback="Inspect one named workspace service.",
|
||||
)
|
||||
)
|
||||
async def service_status(workspace_id: str, service_name: str) -> dict[str, Any]:
|
||||
"""Inspect one named workspace service."""
|
||||
return self.status_service(workspace_id, service_name)
|
||||
|
||||
if _enabled("service_logs"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"service_logs",
|
||||
mode=normalized_mode,
|
||||
fallback="Read persisted stdout/stderr for one workspace service.",
|
||||
)
|
||||
)
|
||||
async def service_logs(
|
||||
workspace_id: str,
|
||||
service_name: str,
|
||||
tail_lines: int = 200,
|
||||
all: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Read persisted stdout/stderr for one workspace service."""
|
||||
return self.logs_service(
|
||||
workspace_id,
|
||||
service_name,
|
||||
|
|
@ -1036,9 +1390,14 @@ class Pyro:
|
|||
|
||||
if _enabled("service_stop"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"service_stop",
|
||||
mode=normalized_mode,
|
||||
fallback="Stop one running service in a workspace.",
|
||||
)
|
||||
)
|
||||
async def service_stop(workspace_id: str, service_name: str) -> dict[str, Any]:
|
||||
"""Stop one running service in a workspace."""
|
||||
return self.stop_service(workspace_id, service_name)
|
||||
|
||||
if _enabled("workspace_delete"):
|
||||
|
|
|
|||
|
|
@ -8,12 +8,21 @@ import shlex
|
|||
import sys
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from pyro_mcp import __version__
|
||||
from pyro_mcp.api import Pyro
|
||||
from pyro_mcp.contract import PUBLIC_MCP_PROFILES
|
||||
from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode
|
||||
from pyro_mcp.contract import PUBLIC_MCP_MODES, PUBLIC_MCP_PROFILES
|
||||
from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT
|
||||
from pyro_mcp.demo import run_demo
|
||||
from pyro_mcp.host_helpers import (
|
||||
HostDoctorEntry,
|
||||
HostServerConfig,
|
||||
connect_cli_host,
|
||||
doctor_hosts,
|
||||
print_or_write_opencode_config,
|
||||
repair_host,
|
||||
)
|
||||
from pyro_mcp.ollama_demo import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL, run_ollama_tool_demo
|
||||
from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report
|
||||
from pyro_mcp.vm_environments import DEFAULT_CATALOG_VERSION
|
||||
|
|
@ -146,6 +155,7 @@ def _print_doctor_human(payload: dict[str, Any]) -> None:
|
|||
)
|
||||
runtime = payload.get("runtime")
|
||||
if isinstance(runtime, dict):
|
||||
print(f"Catalog version: {str(runtime.get('catalog_version', 'unknown'))}")
|
||||
print(f"Environment cache: {str(runtime.get('cache_dir', 'unknown'))}")
|
||||
capabilities = runtime.get("capabilities")
|
||||
if isinstance(capabilities, dict):
|
||||
|
|
@ -163,12 +173,108 @@ def _print_doctor_human(payload: dict[str, Any]) -> None:
|
|||
f"tun={'yes' if bool(networking.get('tun_available')) else 'no'} "
|
||||
f"ip_forward={'yes' if bool(networking.get('ip_forward_enabled')) else 'no'}"
|
||||
)
|
||||
daily_loop = payload.get("daily_loop")
|
||||
if isinstance(daily_loop, dict):
|
||||
status = str(daily_loop.get("status", "cold")).upper()
|
||||
environment = str(daily_loop.get("environment", DEFAULT_PREPARE_ENVIRONMENT))
|
||||
print(f"Daily loop: {status} ({environment})")
|
||||
print(
|
||||
" "
|
||||
f"installed={'yes' if bool(daily_loop.get('installed')) else 'no'} "
|
||||
f"network_prepared={'yes' if bool(daily_loop.get('network_prepared')) else 'no'}"
|
||||
)
|
||||
prepared_at = daily_loop.get("prepared_at")
|
||||
if prepared_at is not None:
|
||||
print(f" prepared_at={prepared_at}")
|
||||
reason = daily_loop.get("reason")
|
||||
if isinstance(reason, str) and reason != "":
|
||||
print(f" reason={reason}")
|
||||
if str(daily_loop.get("status", "cold")) != "warm":
|
||||
print(f" Run: pyro prepare {environment}")
|
||||
if isinstance(issues, list) and issues:
|
||||
print("Issues:")
|
||||
for issue in issues:
|
||||
print(f"- {issue}")
|
||||
|
||||
|
||||
def _print_prepare_human(payload: dict[str, Any]) -> None:
|
||||
environment = str(payload.get("environment", DEFAULT_PREPARE_ENVIRONMENT))
|
||||
status = str(payload.get("status", "cold")).upper()
|
||||
print(f"Prepare: {environment}")
|
||||
print(f"Daily loop: {status}")
|
||||
print(
|
||||
"Result: "
|
||||
f"{'reused' if bool(payload.get('reused')) else 'prepared'} "
|
||||
f"network_prepared={'yes' if bool(payload.get('network_prepared')) else 'no'}"
|
||||
)
|
||||
print(f"Cache dir: {str(payload.get('cache_dir', 'unknown'))}")
|
||||
print(f"Manifest: {str(payload.get('manifest_path', 'unknown'))}")
|
||||
prepared_at = payload.get("prepared_at")
|
||||
if prepared_at is not None:
|
||||
print(f"Prepared at: {prepared_at}")
|
||||
print(f"Duration: {int(payload.get('last_prepare_duration_ms', 0))} ms")
|
||||
reason = payload.get("reason")
|
||||
if isinstance(reason, str) and reason != "":
|
||||
print(f"Reason: {reason}")
|
||||
|
||||
|
||||
def _build_host_server_config(args: argparse.Namespace) -> HostServerConfig:
|
||||
return HostServerConfig(
|
||||
installed_package=bool(getattr(args, "installed_package", False)),
|
||||
profile=cast(McpToolProfile, str(getattr(args, "profile", "workspace-core"))),
|
||||
mode=cast(WorkspaceUseCaseMode | None, getattr(args, "mode", None)),
|
||||
project_path=getattr(args, "project_path", None),
|
||||
repo_url=getattr(args, "repo_url", None),
|
||||
repo_ref=getattr(args, "repo_ref", None),
|
||||
no_project_source=bool(getattr(args, "no_project_source", False)),
|
||||
)
|
||||
|
||||
|
||||
def _print_host_connect_human(payload: dict[str, Any]) -> None:
|
||||
host = str(payload.get("host", "unknown"))
|
||||
server_command = payload.get("server_command")
|
||||
verification_command = payload.get("verification_command")
|
||||
print(f"Connected pyro to {host}.")
|
||||
if isinstance(server_command, list):
|
||||
print("Server command: " + shlex.join(str(item) for item in server_command))
|
||||
if isinstance(verification_command, list):
|
||||
print("Verify with: " + shlex.join(str(item) for item in verification_command))
|
||||
|
||||
|
||||
def _print_host_print_config_human(payload: dict[str, Any]) -> None:
|
||||
rendered_config = payload.get("rendered_config")
|
||||
if isinstance(rendered_config, str):
|
||||
_write_stream(rendered_config, stream=sys.stdout)
|
||||
return
|
||||
output_path = payload.get("output_path")
|
||||
if isinstance(output_path, str):
|
||||
print(f"Wrote OpenCode config to {output_path}")
|
||||
|
||||
|
||||
def _print_host_repair_human(payload: dict[str, Any]) -> None:
|
||||
host = str(payload.get("host", "unknown"))
|
||||
if host == "opencode":
|
||||
print(f"Repaired OpenCode config at {str(payload.get('config_path', 'unknown'))}.")
|
||||
backup_path = payload.get("backup_path")
|
||||
if isinstance(backup_path, str):
|
||||
print(f"Backed up the previous config to {backup_path}.")
|
||||
return
|
||||
_print_host_connect_human(payload)
|
||||
|
||||
|
||||
def _print_host_doctor_human(entries: list[HostDoctorEntry]) -> None:
|
||||
for index, entry in enumerate(entries):
|
||||
print(
|
||||
f"{entry.host}: {entry.status} "
|
||||
f"installed={'yes' if entry.installed else 'no'} "
|
||||
f"configured={'yes' if entry.configured else 'no'}"
|
||||
)
|
||||
print(f" details: {entry.details}")
|
||||
print(f" repair: {entry.repair_command}")
|
||||
if index != len(entries) - 1:
|
||||
print()
|
||||
|
||||
|
||||
def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> None:
|
||||
print(f"{action} ID: {str(payload.get('workspace_id', 'unknown'))}")
|
||||
name = payload.get("name")
|
||||
|
|
@ -191,7 +297,16 @@ def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> N
|
|||
if isinstance(workspace_seed, dict):
|
||||
mode = str(workspace_seed.get("mode", "empty"))
|
||||
seed_path = workspace_seed.get("seed_path")
|
||||
if isinstance(seed_path, str) and seed_path != "":
|
||||
origin_kind = workspace_seed.get("origin_kind")
|
||||
origin_ref = workspace_seed.get("origin_ref")
|
||||
if isinstance(origin_kind, str) and isinstance(origin_ref, str) and origin_ref != "":
|
||||
if origin_kind == "project_path":
|
||||
print(f"Workspace seed: {mode} from project {origin_ref}")
|
||||
elif origin_kind == "repo_url":
|
||||
print(f"Workspace seed: {mode} from clean clone {origin_ref}")
|
||||
else:
|
||||
print(f"Workspace seed: {mode} from {origin_ref}")
|
||||
elif isinstance(seed_path, str) and seed_path != "":
|
||||
print(f"Workspace seed: {mode} from {seed_path}")
|
||||
else:
|
||||
print(f"Workspace seed: {mode}")
|
||||
|
|
@ -477,6 +592,147 @@ def _print_workspace_logs_human(payload: dict[str, Any]) -> None:
|
|||
print(stderr, end="" if stderr.endswith("\n") else "\n", file=sys.stderr)
|
||||
|
||||
|
||||
def _print_workspace_review_summary_human(payload: dict[str, Any]) -> None:
|
||||
print(f"Workspace review: {str(payload.get('workspace_id', 'unknown'))}")
|
||||
name = payload.get("name")
|
||||
if isinstance(name, str) and name != "":
|
||||
print(f"Name: {name}")
|
||||
labels = payload.get("labels")
|
||||
if isinstance(labels, dict) and labels:
|
||||
rendered_labels = ", ".join(
|
||||
f"{str(key)}={str(value)}" for key, value in sorted(labels.items())
|
||||
)
|
||||
print(f"Labels: {rendered_labels}")
|
||||
print(f"Environment: {str(payload.get('environment', 'unknown'))}")
|
||||
print(f"State: {str(payload.get('state', 'unknown'))}")
|
||||
print(f"Last activity at: {payload.get('last_activity_at')}")
|
||||
print(f"Session started at: {payload.get('session_started_at')}")
|
||||
|
||||
outcome = payload.get("outcome")
|
||||
if isinstance(outcome, dict):
|
||||
print(
|
||||
"Outcome: "
|
||||
f"commands={int(outcome.get('command_count', 0))} "
|
||||
f"services={int(outcome.get('running_service_count', 0))}/"
|
||||
f"{int(outcome.get('service_count', 0))} "
|
||||
f"exports={int(outcome.get('export_count', 0))} "
|
||||
f"snapshots={int(outcome.get('snapshot_count', 0))} "
|
||||
f"resets={int(outcome.get('reset_count', 0))}"
|
||||
)
|
||||
last_command = outcome.get("last_command")
|
||||
if isinstance(last_command, dict):
|
||||
print(
|
||||
"Last command: "
|
||||
f"{str(last_command.get('command', 'unknown'))} "
|
||||
f"(exit_code={int(last_command.get('exit_code', -1))})"
|
||||
)
|
||||
|
||||
def _print_events(title: str, events: object, *, formatter: Any) -> None:
|
||||
if not isinstance(events, list) or not events:
|
||||
return
|
||||
print(f"{title}:")
|
||||
for event in events:
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
print(f"- {formatter(event)}")
|
||||
|
||||
commands = payload.get("commands")
|
||||
if isinstance(commands, dict):
|
||||
_print_events(
|
||||
"Recent commands",
|
||||
commands.get("recent"),
|
||||
formatter=lambda event: (
|
||||
f"#{int(event.get('sequence', 0))} "
|
||||
f"exit={int(event.get('exit_code', -1))} "
|
||||
f"cwd={str(event.get('cwd', WORKSPACE_GUEST_PATH))} "
|
||||
f"cmd={str(event.get('command', ''))}"
|
||||
),
|
||||
)
|
||||
|
||||
edits = payload.get("edits")
|
||||
if isinstance(edits, dict):
|
||||
_print_events(
|
||||
"Recent edits",
|
||||
edits.get("recent"),
|
||||
formatter=lambda event: (
|
||||
f"{str(event.get('event_kind', 'edit'))} "
|
||||
f"at={event.get('recorded_at')} "
|
||||
f"path={event.get('path') or event.get('destination') or 'n/a'}"
|
||||
),
|
||||
)
|
||||
|
||||
changes = payload.get("changes")
|
||||
if isinstance(changes, dict):
|
||||
if not bool(changes.get("available")):
|
||||
print(f"Changes: unavailable ({str(changes.get('reason', 'unknown reason'))})")
|
||||
elif not bool(changes.get("changed")):
|
||||
print("Changes: no current workspace changes.")
|
||||
else:
|
||||
summary = changes.get("summary")
|
||||
if isinstance(summary, dict):
|
||||
print(
|
||||
"Changes: "
|
||||
f"total={int(summary.get('total', 0))} "
|
||||
f"added={int(summary.get('added', 0))} "
|
||||
f"modified={int(summary.get('modified', 0))} "
|
||||
f"deleted={int(summary.get('deleted', 0))} "
|
||||
f"type_changed={int(summary.get('type_changed', 0))} "
|
||||
f"non_text={int(summary.get('non_text', 0))}"
|
||||
)
|
||||
_print_events(
|
||||
"Top changed paths",
|
||||
changes.get("entries"),
|
||||
formatter=lambda event: (
|
||||
f"{str(event.get('status', 'changed'))} "
|
||||
f"{str(event.get('path', 'unknown'))} "
|
||||
f"[{str(event.get('artifact_type', 'unknown'))}]"
|
||||
),
|
||||
)
|
||||
|
||||
services = payload.get("services")
|
||||
if isinstance(services, dict):
|
||||
_print_events(
|
||||
"Current services",
|
||||
services.get("current"),
|
||||
formatter=lambda event: (
|
||||
f"{str(event.get('service_name', 'unknown'))} "
|
||||
f"state={str(event.get('state', 'unknown'))}"
|
||||
),
|
||||
)
|
||||
_print_events(
|
||||
"Recent service events",
|
||||
services.get("recent"),
|
||||
formatter=lambda event: (
|
||||
f"{str(event.get('event_kind', 'service'))} "
|
||||
f"{str(event.get('service_name', 'unknown'))} "
|
||||
f"state={str(event.get('state', 'unknown'))}"
|
||||
),
|
||||
)
|
||||
|
||||
artifacts = payload.get("artifacts")
|
||||
if isinstance(artifacts, dict):
|
||||
_print_events(
|
||||
"Recent exports",
|
||||
artifacts.get("exports"),
|
||||
formatter=lambda event: (
|
||||
f"{str(event.get('workspace_path', 'unknown'))} -> "
|
||||
f"{str(event.get('output_path', 'unknown'))}"
|
||||
),
|
||||
)
|
||||
|
||||
snapshots = payload.get("snapshots")
|
||||
if isinstance(snapshots, dict):
|
||||
print(f"Named snapshots: {int(snapshots.get('named_count', 0))}")
|
||||
_print_events(
|
||||
"Recent snapshot events",
|
||||
snapshots.get("recent"),
|
||||
formatter=lambda event: (
|
||||
f"{str(event.get('event_kind', 'snapshot'))} "
|
||||
f"{str(event.get('snapshot_name', 'unknown'))}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _print_workspace_snapshot_human(payload: dict[str, Any], *, prefix: str) -> None:
|
||||
snapshot = payload.get("snapshot")
|
||||
if not isinstance(snapshot, dict):
|
||||
|
|
@ -636,24 +892,73 @@ class _HelpFormatter(
|
|||
return help_string
|
||||
|
||||
|
||||
def _add_host_server_source_args(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"--installed-package",
|
||||
action="store_true",
|
||||
help="Use `pyro mcp serve` instead of the default `uvx --from pyro-mcp pyro mcp serve`.",
|
||||
)
|
||||
profile_group = parser.add_mutually_exclusive_group()
|
||||
profile_group.add_argument(
|
||||
"--profile",
|
||||
choices=PUBLIC_MCP_PROFILES,
|
||||
default="workspace-core",
|
||||
help="Explicit profile for the host helper flow when not using a named mode.",
|
||||
)
|
||||
profile_group.add_argument(
|
||||
"--mode",
|
||||
choices=PUBLIC_MCP_MODES,
|
||||
help="Opinionated use-case mode for the host helper flow.",
|
||||
)
|
||||
source_group = parser.add_mutually_exclusive_group()
|
||||
source_group.add_argument(
|
||||
"--project-path",
|
||||
help="Pin the server to this local project path instead of relying on host cwd.",
|
||||
)
|
||||
source_group.add_argument(
|
||||
"--repo-url",
|
||||
help="Seed default workspaces from a clean clone of this repository URL.",
|
||||
)
|
||||
source_group.add_argument(
|
||||
"--no-project-source",
|
||||
action="store_true",
|
||||
help="Disable automatic Git checkout detection from the current working directory.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repo-ref",
|
||||
help="Optional branch, tag, or commit to checkout after cloning --repo-url.",
|
||||
)
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Run stable one-shot and persistent workspace workflows on supported "
|
||||
"Linux x86_64 KVM hosts."
|
||||
"Validate the host and serve disposable MCP workspaces for chat-based "
|
||||
"coding agents on supported Linux x86_64 KVM hosts."
|
||||
),
|
||||
epilog=dedent(
|
||||
"""
|
||||
Suggested first run:
|
||||
Suggested zero-to-hero path:
|
||||
pyro doctor
|
||||
pyro env list
|
||||
pyro env pull debian:12
|
||||
pyro prepare debian:12
|
||||
pyro run debian:12 -- git --version
|
||||
pyro host connect claude-code
|
||||
|
||||
Continue into the stable workspace path after that:
|
||||
Connect a chat host after that:
|
||||
pyro host connect claude-code
|
||||
pyro host connect codex
|
||||
pyro host print-config opencode
|
||||
|
||||
Daily local loop after the first warmup:
|
||||
pyro doctor --environment debian:12
|
||||
pyro prepare debian:12
|
||||
pyro workspace reset WORKSPACE_ID
|
||||
|
||||
If you want terminal-level visibility into the workspace model:
|
||||
pyro workspace create debian:12 --seed-path ./repo --id-only
|
||||
pyro workspace sync push WORKSPACE_ID ./changes
|
||||
pyro workspace exec WORKSPACE_ID -- cat note.txt
|
||||
pyro workspace summary WORKSPACE_ID
|
||||
pyro workspace diff WORKSPACE_ID
|
||||
pyro workspace snapshot create WORKSPACE_ID checkpoint
|
||||
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
|
||||
|
|
@ -661,8 +966,6 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
|
||||
sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||
pyro workspace export WORKSPACE_ID note.txt --output ./note.txt
|
||||
|
||||
Use `pyro mcp serve` only after the CLI validation path works.
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
|
|
@ -670,6 +973,51 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
|
||||
|
||||
prepare_parser = subparsers.add_parser(
|
||||
"prepare",
|
||||
help="Warm the local machine for the daily workspace loop.",
|
||||
description=(
|
||||
"Warm the recommended guest-backed daily loop by ensuring the "
|
||||
"environment is installed and proving create, exec, reset, and "
|
||||
"delete on one throwaway workspace."
|
||||
),
|
||||
epilog=dedent(
|
||||
f"""
|
||||
Examples:
|
||||
pyro prepare
|
||||
pyro prepare {DEFAULT_PREPARE_ENVIRONMENT}
|
||||
pyro prepare {DEFAULT_PREPARE_ENVIRONMENT} --network
|
||||
pyro prepare {DEFAULT_PREPARE_ENVIRONMENT} --force
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
prepare_parser.add_argument(
|
||||
"environment",
|
||||
nargs="?",
|
||||
default=DEFAULT_PREPARE_ENVIRONMENT,
|
||||
metavar="ENVIRONMENT",
|
||||
help=(
|
||||
"Curated environment to warm for the daily loop. Defaults to "
|
||||
f"`{DEFAULT_PREPARE_ENVIRONMENT}`."
|
||||
),
|
||||
)
|
||||
prepare_parser.add_argument(
|
||||
"--network",
|
||||
action="store_true",
|
||||
help="Also warm guest networking by proving one egress-enabled workspace cycle.",
|
||||
)
|
||||
prepare_parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Rerun warmup even when a compatible warm manifest already exists.",
|
||||
)
|
||||
prepare_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print structured JSON instead of human-readable output.",
|
||||
)
|
||||
|
||||
env_parser = subparsers.add_parser(
|
||||
"env",
|
||||
help="Inspect and manage curated environments.",
|
||||
|
|
@ -755,18 +1103,151 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
help="Print structured JSON instead of human-readable output.",
|
||||
)
|
||||
|
||||
host_parser = subparsers.add_parser(
|
||||
"host",
|
||||
help="Bootstrap and repair supported chat-host configs.",
|
||||
description=(
|
||||
"Connect or repair the supported Claude Code, Codex, and OpenCode "
|
||||
"host setups without hand-writing MCP commands or config."
|
||||
),
|
||||
epilog=dedent(
|
||||
"""
|
||||
Examples:
|
||||
pyro host connect claude-code
|
||||
pyro host connect claude-code --mode cold-start
|
||||
pyro host connect codex --project-path /abs/path/to/repo
|
||||
pyro host print-config opencode
|
||||
pyro host repair opencode
|
||||
pyro host doctor
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
host_subparsers = host_parser.add_subparsers(dest="host_command", required=True, metavar="HOST")
|
||||
host_connect_parser = host_subparsers.add_parser(
|
||||
"connect",
|
||||
help="Connect Claude Code or Codex in one step.",
|
||||
description=(
|
||||
"Ensure the supported host has a `pyro` MCP server entry that wraps "
|
||||
"the canonical `pyro mcp serve` command."
|
||||
),
|
||||
epilog=dedent(
|
||||
"""
|
||||
Examples:
|
||||
pyro host connect claude-code
|
||||
pyro host connect claude-code --mode cold-start
|
||||
pyro host connect codex --installed-package
|
||||
pyro host connect codex --mode repro-fix
|
||||
pyro host connect codex --project-path /abs/path/to/repo
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
host_connect_parser.add_argument(
|
||||
"host",
|
||||
choices=("claude-code", "codex"),
|
||||
help="Chat host to connect and update in place.",
|
||||
)
|
||||
_add_host_server_source_args(host_connect_parser)
|
||||
|
||||
host_print_config_parser = host_subparsers.add_parser(
|
||||
"print-config",
|
||||
help="Print or write the canonical OpenCode config snippet.",
|
||||
description=(
|
||||
"Render the canonical OpenCode `mcp.pyro` config entry so it can be "
|
||||
"copied into or written to `opencode.json`."
|
||||
),
|
||||
epilog=dedent(
|
||||
"""
|
||||
Examples:
|
||||
pyro host print-config opencode
|
||||
pyro host print-config opencode --mode repro-fix
|
||||
pyro host print-config opencode --output ./opencode.json
|
||||
pyro host print-config opencode --project-path /abs/path/to/repo
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
host_print_config_parser.add_argument(
|
||||
"host",
|
||||
choices=("opencode",),
|
||||
help="Host config shape to render.",
|
||||
)
|
||||
_add_host_server_source_args(host_print_config_parser)
|
||||
host_print_config_parser.add_argument(
|
||||
"--output",
|
||||
help="Write the rendered JSON to this path instead of printing it to stdout.",
|
||||
)
|
||||
|
||||
host_doctor_parser = host_subparsers.add_parser(
|
||||
"doctor",
|
||||
help="Inspect supported host setup status.",
|
||||
description=(
|
||||
"Report whether Claude Code, Codex, and OpenCode are installed, "
|
||||
"configured, missing, or drifted relative to the canonical `pyro` MCP setup."
|
||||
),
|
||||
epilog=dedent(
|
||||
"""
|
||||
Examples:
|
||||
pyro host doctor
|
||||
pyro host doctor --mode inspect
|
||||
pyro host doctor --project-path /abs/path/to/repo
|
||||
pyro host doctor --installed-package
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
_add_host_server_source_args(host_doctor_parser)
|
||||
host_doctor_parser.add_argument(
|
||||
"--config-path",
|
||||
help="Override the OpenCode config path when inspecting or repairing that host.",
|
||||
)
|
||||
|
||||
host_repair_parser = host_subparsers.add_parser(
|
||||
"repair",
|
||||
help="Repair one supported host to the canonical `pyro` setup.",
|
||||
description=(
|
||||
"Repair a stale or broken host config by reapplying the canonical "
|
||||
"`pyro mcp serve` setup for that host."
|
||||
),
|
||||
epilog=dedent(
|
||||
"""
|
||||
Examples:
|
||||
pyro host repair claude-code
|
||||
pyro host repair claude-code --mode review-eval
|
||||
pyro host repair codex --project-path /abs/path/to/repo
|
||||
pyro host repair opencode
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
host_repair_parser.add_argument(
|
||||
"host",
|
||||
choices=("claude-code", "codex", "opencode"),
|
||||
help="Host config to repair.",
|
||||
)
|
||||
_add_host_server_source_args(host_repair_parser)
|
||||
host_repair_parser.add_argument(
|
||||
"--config-path",
|
||||
help="Override the OpenCode config path when repairing that host.",
|
||||
)
|
||||
|
||||
mcp_parser = subparsers.add_parser(
|
||||
"mcp",
|
||||
help="Run the MCP server.",
|
||||
description=(
|
||||
"Run the MCP server after you have already validated the host and "
|
||||
"guest execution with `pyro doctor` and `pyro run`. Bare `pyro "
|
||||
"mcp serve` now starts the recommended `workspace-core` profile."
|
||||
"guest execution with `pyro doctor` and `pyro run`. This is the "
|
||||
"main product path for Claude Code, Codex, and OpenCode."
|
||||
),
|
||||
epilog=dedent(
|
||||
"""
|
||||
Examples:
|
||||
pyro mcp serve
|
||||
pyro mcp serve --mode repro-fix
|
||||
pyro mcp serve --mode inspect
|
||||
pyro mcp serve --project-path .
|
||||
pyro mcp serve --repo-url https://github.com/example/project.git
|
||||
pyro mcp serve --profile vm-run
|
||||
pyro mcp serve --profile workspace-full
|
||||
"""
|
||||
|
|
@ -779,36 +1260,83 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
help="Run the MCP server over stdio.",
|
||||
description=(
|
||||
"Expose pyro tools over stdio for an MCP client. Bare `pyro mcp "
|
||||
"serve` now starts `workspace-core`, the recommended first profile "
|
||||
"for most chat hosts."
|
||||
"serve` starts the generic `workspace-core` path. Use `--mode` to "
|
||||
"start from an opinionated use-case flow, or `--profile` to choose "
|
||||
"a generic profile directly. When launched from inside a Git "
|
||||
"checkout, it also seeds the first workspace from that repo by default."
|
||||
),
|
||||
epilog=dedent(
|
||||
"""
|
||||
Default and recommended first start:
|
||||
Generic default path:
|
||||
pyro mcp serve
|
||||
pyro mcp serve --project-path .
|
||||
pyro mcp serve --repo-url https://github.com/example/project.git
|
||||
|
||||
Named modes:
|
||||
repro-fix: structured edit / diff / export / reset loop
|
||||
inspect: smallest persistent inspection surface
|
||||
cold-start: validation plus service readiness
|
||||
review-eval: shell plus snapshots for review workflows
|
||||
|
||||
Profiles:
|
||||
workspace-core: default for normal persistent chat editing
|
||||
workspace-core: default for normal persistent chat editing and the
|
||||
recommended first profile for most chat hosts
|
||||
vm-run: smallest one-shot-only surface
|
||||
workspace-full: advanced 4.x opt-in surface for shells, services,
|
||||
workspace-full: larger opt-in surface for shells, services,
|
||||
snapshots, secrets, network policy, and disk tools
|
||||
|
||||
Use --profile workspace-full only when the host truly needs the full
|
||||
advanced workspace surface.
|
||||
Project-aware startup:
|
||||
- bare `pyro mcp serve` auto-detects the nearest Git checkout
|
||||
from the current working directory
|
||||
- use --project-path when the host does not preserve cwd
|
||||
- use --repo-url for a clean-clone source outside a local checkout
|
||||
|
||||
Use --mode when one named use case already matches the job. Fall
|
||||
back to the generic no-mode path when the mode feels too narrow.
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
mcp_serve_parser.add_argument(
|
||||
mcp_profile_group = mcp_serve_parser.add_mutually_exclusive_group()
|
||||
mcp_profile_group.add_argument(
|
||||
"--profile",
|
||||
choices=PUBLIC_MCP_PROFILES,
|
||||
default="workspace-core",
|
||||
help=(
|
||||
"Expose only one model-facing tool profile. `workspace-core` is "
|
||||
"the default and recommended first profile for most chat hosts; "
|
||||
"`workspace-full` is the explicit advanced opt-in surface."
|
||||
"Expose one generic model-facing tool profile instead of a named mode. "
|
||||
"`workspace-core` is the generic default and `workspace-full` is the "
|
||||
"larger opt-in profile."
|
||||
),
|
||||
)
|
||||
mcp_profile_group.add_argument(
|
||||
"--mode",
|
||||
choices=PUBLIC_MCP_MODES,
|
||||
help="Expose one opinionated use-case mode instead of the generic profile path.",
|
||||
)
|
||||
mcp_source_group = mcp_serve_parser.add_mutually_exclusive_group()
|
||||
mcp_source_group.add_argument(
|
||||
"--project-path",
|
||||
help=(
|
||||
"Seed default workspaces from this local project path. If the path "
|
||||
"is inside a Git checkout, pyro uses that repo root."
|
||||
),
|
||||
)
|
||||
mcp_source_group.add_argument(
|
||||
"--repo-url",
|
||||
help=(
|
||||
"Seed default workspaces from a clean host-side clone of this repo URL "
|
||||
"when `workspace_create` omits `seed_path`."
|
||||
),
|
||||
)
|
||||
mcp_serve_parser.add_argument(
|
||||
"--repo-ref",
|
||||
help="Optional branch, tag, or commit to checkout after cloning --repo-url.",
|
||||
)
|
||||
mcp_serve_parser.add_argument(
|
||||
"--no-project-source",
|
||||
action="store_true",
|
||||
help=("Disable automatic Git checkout detection from the current working directory."),
|
||||
)
|
||||
|
||||
run_parser = subparsers.add_parser(
|
||||
"run",
|
||||
|
|
@ -865,8 +1393,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
"--allow-host-compat",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Opt into host-side compatibility execution if guest boot or guest exec "
|
||||
"is unavailable."
|
||||
"Opt into host-side compatibility execution if guest boot or guest exec is unavailable."
|
||||
),
|
||||
)
|
||||
run_parser.add_argument(
|
||||
|
|
@ -888,7 +1415,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
"workspace",
|
||||
help="Manage persistent workspaces.",
|
||||
description=(
|
||||
"Use the stable workspace contract when you need one sandbox to stay alive "
|
||||
"Use the workspace model when you need one sandbox to stay alive "
|
||||
"across repeated exec, shell, service, diff, export, snapshot, and reset calls."
|
||||
),
|
||||
epilog=dedent(
|
||||
|
|
@ -908,6 +1435,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
pyro workspace start WORKSPACE_ID
|
||||
pyro workspace snapshot create WORKSPACE_ID checkpoint
|
||||
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
|
||||
pyro workspace summary WORKSPACE_ID
|
||||
pyro workspace diff WORKSPACE_ID
|
||||
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
|
||||
pyro workspace shell open WORKSPACE_ID --id-only
|
||||
|
|
@ -986,8 +1514,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
"--allow-host-compat",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Opt into host-side compatibility execution if guest boot or guest exec "
|
||||
"is unavailable."
|
||||
"Opt into host-side compatibility execution if guest boot or guest exec is unavailable."
|
||||
),
|
||||
)
|
||||
workspace_create_parser.add_argument(
|
||||
|
|
@ -1037,8 +1564,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
"exec",
|
||||
help="Run one command inside an existing workspace.",
|
||||
description=(
|
||||
"Run one non-interactive command in the persistent `/workspace` "
|
||||
"for a workspace."
|
||||
"Run one non-interactive command in the persistent `/workspace` for a workspace."
|
||||
),
|
||||
epilog=dedent(
|
||||
"""
|
||||
|
|
@ -1274,8 +1800,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
"created automatically."
|
||||
),
|
||||
epilog=(
|
||||
"Example:\n"
|
||||
" pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py"
|
||||
"Example:\n pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py"
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
|
|
@ -1467,8 +1992,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
"start",
|
||||
help="Start one stopped workspace without resetting it.",
|
||||
description=(
|
||||
"Start a previously stopped workspace from its preserved rootfs and "
|
||||
"workspace state."
|
||||
"Start a previously stopped workspace from its preserved rootfs and workspace state."
|
||||
),
|
||||
epilog="Example:\n pyro workspace start WORKSPACE_ID",
|
||||
formatter_class=_HelpFormatter,
|
||||
|
|
@ -1594,8 +2118,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
"shell",
|
||||
help="Open and manage persistent interactive shells.",
|
||||
description=(
|
||||
"Open one or more persistent interactive PTY shell sessions inside a started "
|
||||
"workspace."
|
||||
"Open one or more persistent interactive PTY shell sessions inside a started workspace."
|
||||
),
|
||||
epilog=dedent(
|
||||
"""
|
||||
|
|
@ -1818,7 +2341,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
pyro workspace service stop WORKSPACE_ID app
|
||||
|
||||
Use `--ready-file` by default in the curated Debian environments. `--ready-command`
|
||||
remains available as an escape hatch.
|
||||
remains available when the workflow needs a custom readiness check.
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
|
|
@ -2047,12 +2570,38 @@ while true; do sleep 60; done'
|
|||
action="store_true",
|
||||
help="Print structured JSON instead of human-readable output.",
|
||||
)
|
||||
workspace_summary_parser = workspace_subparsers.add_parser(
|
||||
"summary",
|
||||
help="Summarize the current workspace session for review.",
|
||||
description=(
|
||||
"Summarize the current workspace session since the last reset, including recent "
|
||||
"commands, edits, services, exports, snapshots, and current change status."
|
||||
),
|
||||
epilog=dedent(
|
||||
"""
|
||||
Example:
|
||||
pyro workspace summary WORKSPACE_ID
|
||||
|
||||
Use `workspace logs`, `workspace diff`, and `workspace export` for drill-down.
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
workspace_summary_parser.add_argument(
|
||||
"workspace_id",
|
||||
metavar="WORKSPACE_ID",
|
||||
help="Persistent workspace identifier.",
|
||||
)
|
||||
workspace_summary_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print structured JSON instead of human-readable output.",
|
||||
)
|
||||
workspace_logs_parser = workspace_subparsers.add_parser(
|
||||
"logs",
|
||||
help="Show command history for one workspace.",
|
||||
description=(
|
||||
"Show persisted command history, including stdout and stderr, "
|
||||
"for one workspace."
|
||||
"Show persisted command history, including stdout and stderr, for one workspace."
|
||||
),
|
||||
epilog="Example:\n pyro workspace logs WORKSPACE_ID",
|
||||
formatter_class=_HelpFormatter,
|
||||
|
|
@ -2088,11 +2637,16 @@ while true; do sleep 60; done'
|
|||
doctor_parser = subparsers.add_parser(
|
||||
"doctor",
|
||||
help="Inspect runtime and host diagnostics.",
|
||||
description="Check host prerequisites and embedded runtime health before your first run.",
|
||||
description=(
|
||||
"Check host prerequisites and embedded runtime health, plus "
|
||||
"daily-loop warmth before your first run or before reconnecting a "
|
||||
"chat host."
|
||||
),
|
||||
epilog=dedent(
|
||||
"""
|
||||
Examples:
|
||||
pyro doctor
|
||||
pyro doctor --environment debian:12
|
||||
pyro doctor --json
|
||||
"""
|
||||
),
|
||||
|
|
@ -2103,6 +2657,14 @@ while true; do sleep 60; done'
|
|||
default=DEFAULT_PLATFORM,
|
||||
help="Runtime platform to inspect.",
|
||||
)
|
||||
doctor_parser.add_argument(
|
||||
"--environment",
|
||||
default=DEFAULT_PREPARE_ENVIRONMENT,
|
||||
help=(
|
||||
"Environment to inspect for the daily-loop warm manifest. "
|
||||
f"Defaults to `{DEFAULT_PREPARE_ENVIRONMENT}`."
|
||||
),
|
||||
)
|
||||
doctor_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
|
|
@ -2265,6 +2827,24 @@ def _parse_workspace_publish_options(values: list[str]) -> list[dict[str, int |
|
|||
def main() -> None:
|
||||
args = _build_parser().parse_args()
|
||||
pyro = Pyro()
|
||||
if args.command == "prepare":
|
||||
try:
|
||||
payload = pyro.manager.prepare_daily_loop(
|
||||
args.environment,
|
||||
network=bool(args.network),
|
||||
force=bool(args.force),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if bool(args.json):
|
||||
_print_json({"ok": False, "error": str(exc)})
|
||||
else:
|
||||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1) from exc
|
||||
if bool(args.json):
|
||||
_print_json(payload)
|
||||
else:
|
||||
_print_prepare_human(payload)
|
||||
return
|
||||
if args.command == "env":
|
||||
if args.env_command == "list":
|
||||
list_payload: dict[str, Any] = {
|
||||
|
|
@ -2300,8 +2880,66 @@ def main() -> None:
|
|||
else:
|
||||
_print_prune_human(prune_payload)
|
||||
return
|
||||
if args.command == "host":
|
||||
config = _build_host_server_config(args)
|
||||
if args.host_command == "connect":
|
||||
try:
|
||||
payload = connect_cli_host(args.host, config=config)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1) from exc
|
||||
_print_host_connect_human(payload)
|
||||
return
|
||||
if args.host_command == "print-config":
|
||||
try:
|
||||
output_path = (
|
||||
None if args.output is None else Path(args.output).expanduser().resolve()
|
||||
)
|
||||
payload = print_or_write_opencode_config(config=config, output_path=output_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1) from exc
|
||||
_print_host_print_config_human(payload)
|
||||
return
|
||||
if args.host_command == "doctor":
|
||||
try:
|
||||
config_path = (
|
||||
None
|
||||
if args.config_path is None
|
||||
else Path(args.config_path).expanduser().resolve()
|
||||
)
|
||||
entries = doctor_hosts(config=config, config_path=config_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1) from exc
|
||||
_print_host_doctor_human(entries)
|
||||
return
|
||||
if args.host_command == "repair":
|
||||
try:
|
||||
if args.host != "opencode" and args.config_path is not None:
|
||||
raise ValueError(
|
||||
"--config-path is only supported for `pyro host repair opencode`"
|
||||
)
|
||||
config_path = (
|
||||
None
|
||||
if args.config_path is None
|
||||
else Path(args.config_path).expanduser().resolve()
|
||||
)
|
||||
payload = repair_host(args.host, config=config, config_path=config_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1) from exc
|
||||
_print_host_repair_human(payload)
|
||||
return
|
||||
if args.command == "mcp":
|
||||
pyro.create_server(profile=args.profile).run(transport="stdio")
|
||||
pyro.create_server(
|
||||
profile=args.profile,
|
||||
mode=getattr(args, "mode", None),
|
||||
project_path=args.project_path,
|
||||
repo_url=args.repo_url,
|
||||
repo_ref=args.repo_ref,
|
||||
no_project_source=bool(args.no_project_source),
|
||||
).run(transport="stdio")
|
||||
return
|
||||
if args.command == "run":
|
||||
command = _require_command(args.command_args)
|
||||
|
|
@ -2354,10 +2992,7 @@ def main() -> None:
|
|||
if args.command == "workspace":
|
||||
if args.workspace_command == "create":
|
||||
secrets = [
|
||||
*(
|
||||
_parse_workspace_secret_option(value)
|
||||
for value in getattr(args, "secret", [])
|
||||
),
|
||||
*(_parse_workspace_secret_option(value) for value in getattr(args, "secret", [])),
|
||||
*(
|
||||
_parse_workspace_secret_file_option(value)
|
||||
for value in getattr(args, "secret_file", [])
|
||||
|
|
@ -2392,9 +3027,7 @@ def main() -> None:
|
|||
return
|
||||
if args.workspace_command == "update":
|
||||
labels = _parse_workspace_label_options(getattr(args, "label", []))
|
||||
clear_labels = _parse_workspace_clear_label_options(
|
||||
getattr(args, "clear_label", [])
|
||||
)
|
||||
clear_labels = _parse_workspace_clear_label_options(getattr(args, "clear_label", []))
|
||||
try:
|
||||
payload = pyro.update_workspace(
|
||||
args.workspace_id,
|
||||
|
|
@ -2441,7 +3074,8 @@ def main() -> None:
|
|||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1) from exc
|
||||
_print_workspace_exec_human(payload)
|
||||
exit_code = int(payload.get("exit_code", 1))
|
||||
exit_code_raw = payload.get("exit_code", 1)
|
||||
exit_code = exit_code_raw if isinstance(exit_code_raw, int) else 1
|
||||
if exit_code != 0:
|
||||
raise SystemExit(exit_code)
|
||||
return
|
||||
|
|
@ -2977,6 +3611,13 @@ def main() -> None:
|
|||
else:
|
||||
_print_workspace_summary_human(payload, action="Workspace")
|
||||
return
|
||||
if args.workspace_command == "summary":
|
||||
payload = pyro.summarize_workspace(args.workspace_id)
|
||||
if bool(args.json):
|
||||
_print_json(payload)
|
||||
else:
|
||||
_print_workspace_review_summary_human(payload)
|
||||
return
|
||||
if args.workspace_command == "logs":
|
||||
payload = pyro.logs_workspace(args.workspace_id)
|
||||
if bool(args.json):
|
||||
|
|
@ -2992,7 +3633,17 @@ def main() -> None:
|
|||
print(f"Deleted workspace: {str(payload.get('workspace_id', 'unknown'))}")
|
||||
return
|
||||
if args.command == "doctor":
|
||||
payload = doctor_report(platform=args.platform)
|
||||
try:
|
||||
payload = doctor_report(
|
||||
platform=args.platform,
|
||||
environment=args.environment,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if bool(args.json):
|
||||
_print_json({"ok": False, "error": str(exc)})
|
||||
else:
|
||||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1) from exc
|
||||
if bool(args.json):
|
||||
_print_json(payload)
|
||||
else:
|
||||
|
|
@ -3023,3 +3674,7 @@ def main() -> None:
|
|||
return
|
||||
result = run_demo(network=bool(args.network))
|
||||
_print_json(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -2,11 +2,34 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "mcp", "run", "workspace")
|
||||
PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "host", "mcp", "prepare", "run", "workspace")
|
||||
PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",)
|
||||
PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune")
|
||||
PUBLIC_CLI_DOCTOR_FLAGS = ("--platform", "--environment", "--json")
|
||||
PUBLIC_CLI_HOST_SUBCOMMANDS = ("connect", "doctor", "print-config", "repair")
|
||||
PUBLIC_CLI_HOST_COMMON_FLAGS = (
|
||||
"--installed-package",
|
||||
"--mode",
|
||||
"--profile",
|
||||
"--project-path",
|
||||
"--repo-url",
|
||||
"--repo-ref",
|
||||
"--no-project-source",
|
||||
)
|
||||
PUBLIC_CLI_HOST_CONNECT_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS
|
||||
PUBLIC_CLI_HOST_DOCTOR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",)
|
||||
PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--output",)
|
||||
PUBLIC_CLI_HOST_REPAIR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",)
|
||||
PUBLIC_CLI_MCP_SUBCOMMANDS = ("serve",)
|
||||
PUBLIC_CLI_MCP_SERVE_FLAGS = ("--profile",)
|
||||
PUBLIC_CLI_MCP_SERVE_FLAGS = (
|
||||
"--mode",
|
||||
"--profile",
|
||||
"--project-path",
|
||||
"--repo-url",
|
||||
"--repo-ref",
|
||||
"--no-project-source",
|
||||
)
|
||||
PUBLIC_CLI_PREPARE_FLAGS = ("--network", "--force", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = (
|
||||
"create",
|
||||
"delete",
|
||||
|
|
@ -25,6 +48,7 @@ PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = (
|
|||
"start",
|
||||
"status",
|
||||
"stop",
|
||||
"summary",
|
||||
"sync",
|
||||
"update",
|
||||
)
|
||||
|
|
@ -101,6 +125,7 @@ PUBLIC_CLI_WORKSPACE_SNAPSHOT_LIST_FLAGS = ("--json",)
|
|||
PUBLIC_CLI_WORKSPACE_START_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_STATUS_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_STOP_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_SUMMARY_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS = ("--dest", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS = (
|
||||
"--name",
|
||||
|
|
@ -119,6 +144,7 @@ PUBLIC_CLI_RUN_FLAGS = (
|
|||
"--json",
|
||||
)
|
||||
PUBLIC_MCP_PROFILES = ("vm-run", "workspace-core", "workspace-full")
|
||||
PUBLIC_MCP_MODES = ("repro-fix", "inspect", "cold-start", "review-eval")
|
||||
|
||||
PUBLIC_SDK_METHODS = (
|
||||
"apply_workspace_patch",
|
||||
|
|
@ -165,6 +191,7 @@ PUBLIC_SDK_METHODS = (
|
|||
"stop_service",
|
||||
"stop_vm",
|
||||
"stop_workspace",
|
||||
"summarize_workspace",
|
||||
"update_workspace",
|
||||
"write_shell",
|
||||
"write_workspace_file",
|
||||
|
|
@ -209,6 +236,7 @@ PUBLIC_MCP_TOOLS = (
|
|||
"workspace_logs",
|
||||
"workspace_patch_apply",
|
||||
"workspace_reset",
|
||||
"workspace_summary",
|
||||
"workspace_start",
|
||||
"workspace_status",
|
||||
"workspace_stop",
|
||||
|
|
@ -230,8 +258,82 @@ PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS = (
|
|||
"workspace_logs",
|
||||
"workspace_patch_apply",
|
||||
"workspace_reset",
|
||||
"workspace_summary",
|
||||
"workspace_status",
|
||||
"workspace_sync_push",
|
||||
"workspace_update",
|
||||
)
|
||||
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS = (
|
||||
"workspace_create",
|
||||
"workspace_delete",
|
||||
"workspace_diff",
|
||||
"workspace_exec",
|
||||
"workspace_export",
|
||||
"workspace_file_list",
|
||||
"workspace_file_read",
|
||||
"workspace_file_write",
|
||||
"workspace_list",
|
||||
"workspace_logs",
|
||||
"workspace_patch_apply",
|
||||
"workspace_reset",
|
||||
"workspace_summary",
|
||||
"workspace_status",
|
||||
"workspace_sync_push",
|
||||
"workspace_update",
|
||||
)
|
||||
PUBLIC_MCP_INSPECT_MODE_TOOLS = (
|
||||
"workspace_create",
|
||||
"workspace_delete",
|
||||
"workspace_exec",
|
||||
"workspace_export",
|
||||
"workspace_file_list",
|
||||
"workspace_file_read",
|
||||
"workspace_list",
|
||||
"workspace_logs",
|
||||
"workspace_summary",
|
||||
"workspace_status",
|
||||
"workspace_update",
|
||||
)
|
||||
PUBLIC_MCP_COLD_START_MODE_TOOLS = (
|
||||
"service_list",
|
||||
"service_logs",
|
||||
"service_start",
|
||||
"service_status",
|
||||
"service_stop",
|
||||
"workspace_create",
|
||||
"workspace_delete",
|
||||
"workspace_exec",
|
||||
"workspace_export",
|
||||
"workspace_file_list",
|
||||
"workspace_file_read",
|
||||
"workspace_list",
|
||||
"workspace_logs",
|
||||
"workspace_reset",
|
||||
"workspace_summary",
|
||||
"workspace_status",
|
||||
"workspace_update",
|
||||
)
|
||||
PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS = (
|
||||
"shell_close",
|
||||
"shell_open",
|
||||
"shell_read",
|
||||
"shell_signal",
|
||||
"shell_write",
|
||||
"snapshot_create",
|
||||
"snapshot_delete",
|
||||
"snapshot_list",
|
||||
"workspace_create",
|
||||
"workspace_delete",
|
||||
"workspace_diff",
|
||||
"workspace_exec",
|
||||
"workspace_export",
|
||||
"workspace_file_list",
|
||||
"workspace_file_read",
|
||||
"workspace_list",
|
||||
"workspace_logs",
|
||||
"workspace_reset",
|
||||
"workspace_summary",
|
||||
"workspace_status",
|
||||
"workspace_update",
|
||||
)
|
||||
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS = PUBLIC_MCP_TOOLS
|
||||
|
|
|
|||
152
src/pyro_mcp/daily_loop.py
Normal file
152
src/pyro_mcp/daily_loop.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"""Machine-level daily-loop warmup state for the CLI prepare flow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
DEFAULT_PREPARE_ENVIRONMENT = "debian:12"
|
||||
PREPARE_MANIFEST_LAYOUT_VERSION = 1
|
||||
DailyLoopStatus = Literal["cold", "warm", "stale"]
|
||||
|
||||
|
||||
def _environment_key(environment: str) -> str:
|
||||
return environment.replace("/", "_").replace(":", "_")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DailyLoopManifest:
|
||||
"""Persisted machine-readiness proof for one environment on one platform."""
|
||||
|
||||
environment: str
|
||||
environment_version: str
|
||||
platform: str
|
||||
catalog_version: str
|
||||
bundle_version: str | None
|
||||
prepared_at: float
|
||||
network_prepared: bool
|
||||
last_prepare_duration_ms: int
|
||||
|
||||
def to_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"layout_version": PREPARE_MANIFEST_LAYOUT_VERSION,
|
||||
"environment": self.environment,
|
||||
"environment_version": self.environment_version,
|
||||
"platform": self.platform,
|
||||
"catalog_version": self.catalog_version,
|
||||
"bundle_version": self.bundle_version,
|
||||
"prepared_at": self.prepared_at,
|
||||
"network_prepared": self.network_prepared,
|
||||
"last_prepare_duration_ms": self.last_prepare_duration_ms,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: dict[str, Any]) -> "DailyLoopManifest":
|
||||
return cls(
|
||||
environment=str(payload["environment"]),
|
||||
environment_version=str(payload["environment_version"]),
|
||||
platform=str(payload["platform"]),
|
||||
catalog_version=str(payload["catalog_version"]),
|
||||
bundle_version=(
|
||||
None if payload.get("bundle_version") is None else str(payload["bundle_version"])
|
||||
),
|
||||
prepared_at=float(payload["prepared_at"]),
|
||||
network_prepared=bool(payload.get("network_prepared", False)),
|
||||
last_prepare_duration_ms=int(payload.get("last_prepare_duration_ms", 0)),
|
||||
)
|
||||
|
||||
|
||||
def prepare_manifest_path(cache_dir: Path, *, platform: str, environment: str) -> Path:
|
||||
return cache_dir / ".prepare" / platform / f"{_environment_key(environment)}.json"
|
||||
|
||||
|
||||
def load_prepare_manifest(path: Path) -> tuple[DailyLoopManifest | None, str | None]:
|
||||
if not path.exists():
|
||||
return None, None
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
return None, f"prepare manifest is unreadable: {exc}"
|
||||
if not isinstance(payload, dict):
|
||||
return None, "prepare manifest is not a JSON object"
|
||||
try:
|
||||
manifest = DailyLoopManifest.from_payload(payload)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
return None, f"prepare manifest is invalid: {exc}"
|
||||
return manifest, None
|
||||
|
||||
|
||||
def write_prepare_manifest(path: Path, manifest: DailyLoopManifest) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(manifest.to_payload(), indent=2, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def evaluate_daily_loop_status(
|
||||
*,
|
||||
environment: str,
|
||||
environment_version: str,
|
||||
platform: str,
|
||||
catalog_version: str,
|
||||
bundle_version: str | None,
|
||||
installed: bool,
|
||||
manifest: DailyLoopManifest | None,
|
||||
manifest_error: str | None = None,
|
||||
) -> tuple[DailyLoopStatus, str | None]:
|
||||
if manifest_error is not None:
|
||||
return "stale", manifest_error
|
||||
if manifest is None:
|
||||
if not installed:
|
||||
return "cold", "environment is not installed"
|
||||
return "cold", "daily loop has not been prepared yet"
|
||||
if not installed:
|
||||
return "stale", "environment install is missing"
|
||||
if manifest.environment != environment:
|
||||
return "stale", "prepare manifest environment does not match the selected environment"
|
||||
if manifest.environment_version != environment_version:
|
||||
return "stale", "environment version changed since the last prepare run"
|
||||
if manifest.platform != platform:
|
||||
return "stale", "platform changed since the last prepare run"
|
||||
if manifest.catalog_version != catalog_version:
|
||||
return "stale", "catalog version changed since the last prepare run"
|
||||
if manifest.bundle_version != bundle_version:
|
||||
return "stale", "runtime bundle version changed since the last prepare run"
|
||||
return "warm", None
|
||||
|
||||
|
||||
def prepare_request_is_satisfied(
|
||||
manifest: DailyLoopManifest | None,
|
||||
*,
|
||||
require_network: bool,
|
||||
) -> bool:
|
||||
if manifest is None:
|
||||
return False
|
||||
if require_network and not manifest.network_prepared:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def serialize_daily_loop_report(
|
||||
*,
|
||||
environment: str,
|
||||
status: DailyLoopStatus,
|
||||
installed: bool,
|
||||
cache_dir: Path,
|
||||
manifest_path: Path,
|
||||
reason: str | None,
|
||||
manifest: DailyLoopManifest | None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"environment": environment,
|
||||
"status": status,
|
||||
"installed": installed,
|
||||
"network_prepared": bool(manifest.network_prepared) if manifest is not None else False,
|
||||
"prepared_at": None if manifest is None else manifest.prepared_at,
|
||||
"manifest_path": str(manifest_path),
|
||||
"reason": reason,
|
||||
"cache_dir": str(cache_dir),
|
||||
}
|
||||
131
src/pyro_mcp/daily_loop_smoke.py
Normal file
131
src/pyro_mcp/daily_loop_smoke.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""Real guest-backed smoke for the daily local prepare and reset loop."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from pyro_mcp.api import Pyro
|
||||
from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT
|
||||
|
||||
|
||||
def _log(message: str) -> None:
|
||||
print(f"[daily-loop] {message}", flush=True)
|
||||
|
||||
|
||||
def _write_text(path: Path, text: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def _run_prepare(environment: str) -> dict[str, object]:
|
||||
proc = subprocess.run( # noqa: S603
|
||||
[sys.executable, "-m", "pyro_mcp.cli", "prepare", environment, "--json"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "pyro prepare failed")
|
||||
payload = json.loads(proc.stdout)
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("pyro prepare did not return a JSON object")
|
||||
return payload
|
||||
|
||||
|
||||
def run_daily_loop_smoke(*, environment: str = DEFAULT_PREPARE_ENVIRONMENT) -> None:
|
||||
_log(f"prepare environment={environment}")
|
||||
first_prepare = _run_prepare(environment)
|
||||
assert bool(first_prepare["prepared"]) is True, first_prepare
|
||||
second_prepare = _run_prepare(environment)
|
||||
assert bool(second_prepare["reused"]) is True, second_prepare
|
||||
|
||||
pyro = Pyro()
|
||||
with tempfile.TemporaryDirectory(prefix="pyro-daily-loop-") as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
seed_dir = root / "seed"
|
||||
export_dir = root / "export"
|
||||
_write_text(seed_dir / "message.txt", "broken\n")
|
||||
_write_text(
|
||||
seed_dir / "check.sh",
|
||||
"#!/bin/sh\n"
|
||||
"set -eu\n"
|
||||
"value=$(cat message.txt)\n"
|
||||
'[ "$value" = "fixed" ] || {\n'
|
||||
" printf 'expected fixed got %s\\n' \"$value\" >&2\n"
|
||||
" exit 1\n"
|
||||
"}\n"
|
||||
"printf '%s\\n' \"$value\"\n",
|
||||
)
|
||||
|
||||
workspace_id: str | None = None
|
||||
try:
|
||||
created = pyro.create_workspace(
|
||||
environment=environment,
|
||||
seed_path=seed_dir,
|
||||
name="daily-loop",
|
||||
labels={"suite": "daily-loop-smoke"},
|
||||
)
|
||||
workspace_id = str(created["workspace_id"])
|
||||
_log(f"workspace_id={workspace_id}")
|
||||
|
||||
failing = pyro.exec_workspace(workspace_id, command="sh check.sh")
|
||||
assert int(failing["exit_code"]) != 0, failing
|
||||
|
||||
patched = pyro.apply_workspace_patch(
|
||||
workspace_id,
|
||||
patch=("--- a/message.txt\n+++ b/message.txt\n@@ -1 +1 @@\n-broken\n+fixed\n"),
|
||||
)
|
||||
assert bool(patched["changed"]) is True, patched
|
||||
|
||||
passing = pyro.exec_workspace(workspace_id, command="sh check.sh")
|
||||
assert int(passing["exit_code"]) == 0, passing
|
||||
assert str(passing["stdout"]) == "fixed\n", passing
|
||||
|
||||
export_path = export_dir / "message.txt"
|
||||
exported = pyro.export_workspace(
|
||||
workspace_id,
|
||||
"message.txt",
|
||||
output_path=export_path,
|
||||
)
|
||||
assert export_path.read_text(encoding="utf-8") == "fixed\n"
|
||||
assert str(exported["artifact_type"]) == "file", exported
|
||||
|
||||
reset = pyro.reset_workspace(workspace_id)
|
||||
assert int(reset["reset_count"]) == 1, reset
|
||||
|
||||
rerun = pyro.exec_workspace(workspace_id, command="sh check.sh")
|
||||
assert int(rerun["exit_code"]) != 0, rerun
|
||||
reset_read = pyro.read_workspace_file(workspace_id, "message.txt")
|
||||
assert str(reset_read["content"]) == "broken\n", reset_read
|
||||
finally:
|
||||
if workspace_id is not None:
|
||||
try:
|
||||
pyro.delete_workspace(workspace_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def build_arg_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run the real guest-backed daily-loop prepare and reset smoke.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--environment",
|
||||
default=DEFAULT_PREPARE_ENVIRONMENT,
|
||||
help=f"Environment to warm and test. Defaults to `{DEFAULT_PREPARE_ENVIRONMENT}`.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = build_arg_parser().parse_args()
|
||||
run_daily_loop_smoke(environment=args.environment)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -5,16 +5,18 @@ from __future__ import annotations
|
|||
import argparse
|
||||
import json
|
||||
|
||||
from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT
|
||||
from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Inspect bundled runtime health for pyro-mcp.")
|
||||
parser.add_argument("--platform", default=DEFAULT_PLATFORM)
|
||||
parser.add_argument("--environment", default=DEFAULT_PREPARE_ENVIRONMENT)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = _build_parser().parse_args()
|
||||
report = doctor_report(platform=args.platform)
|
||||
report = doctor_report(platform=args.platform, environment=args.environment)
|
||||
print(json.dumps(report, indent=2, sort_keys=True))
|
||||
|
|
|
|||
370
src/pyro_mcp/host_helpers.py
Normal file
370
src/pyro_mcp/host_helpers.py
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
"""Helpers for bootstrapping and repairing supported MCP chat hosts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pyro_mcp.api import McpToolProfile, WorkspaceUseCaseMode
|
||||
|
||||
SUPPORTED_HOST_CONNECT_TARGETS = ("claude-code", "codex")
|
||||
SUPPORTED_HOST_REPAIR_TARGETS = ("claude-code", "codex", "opencode")
|
||||
SUPPORTED_HOST_PRINT_CONFIG_TARGETS = ("opencode",)
|
||||
DEFAULT_HOST_SERVER_NAME = "pyro"
|
||||
DEFAULT_OPENCODE_CONFIG_PATH = Path.home() / ".config" / "opencode" / "opencode.json"
|
||||
|
||||
HostStatus = Literal["drifted", "missing", "ok", "unavailable"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostServerConfig:
|
||||
installed_package: bool = False
|
||||
profile: McpToolProfile = "workspace-core"
|
||||
mode: WorkspaceUseCaseMode | None = None
|
||||
project_path: str | None = None
|
||||
repo_url: str | None = None
|
||||
repo_ref: str | None = None
|
||||
no_project_source: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostDoctorEntry:
|
||||
host: str
|
||||
installed: bool
|
||||
configured: bool
|
||||
status: HostStatus
|
||||
details: str
|
||||
repair_command: str
|
||||
|
||||
|
||||
def _run_command(command: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run( # noqa: S603
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _host_binary(host: str) -> str:
|
||||
if host == "claude-code":
|
||||
return "claude"
|
||||
if host == "codex":
|
||||
return "codex"
|
||||
raise ValueError(f"unsupported CLI host {host!r}")
|
||||
|
||||
|
||||
def _canonical_server_command(config: HostServerConfig) -> list[str]:
|
||||
if config.mode is not None and config.profile != "workspace-core":
|
||||
raise ValueError("--mode and --profile are mutually exclusive")
|
||||
if config.project_path is not None and config.repo_url is not None:
|
||||
raise ValueError("--project-path and --repo-url are mutually exclusive")
|
||||
if config.no_project_source and (
|
||||
config.project_path is not None
|
||||
or config.repo_url is not None
|
||||
or config.repo_ref is not None
|
||||
):
|
||||
raise ValueError(
|
||||
"--no-project-source cannot be combined with --project-path, --repo-url, or --repo-ref"
|
||||
)
|
||||
if config.repo_ref is not None and config.repo_url is None:
|
||||
raise ValueError("--repo-ref requires --repo-url")
|
||||
|
||||
command = ["pyro", "mcp", "serve"]
|
||||
if not config.installed_package:
|
||||
command = ["uvx", "--from", "pyro-mcp", *command]
|
||||
if config.mode is not None:
|
||||
command.extend(["--mode", config.mode])
|
||||
elif config.profile != "workspace-core":
|
||||
command.extend(["--profile", config.profile])
|
||||
if config.project_path is not None:
|
||||
command.extend(["--project-path", config.project_path])
|
||||
elif config.repo_url is not None:
|
||||
command.extend(["--repo-url", config.repo_url])
|
||||
if config.repo_ref is not None:
|
||||
command.extend(["--repo-ref", config.repo_ref])
|
||||
elif config.no_project_source:
|
||||
command.append("--no-project-source")
|
||||
return command
|
||||
|
||||
|
||||
def _render_cli_command(command: list[str]) -> str:
|
||||
return shlex.join(command)
|
||||
|
||||
|
||||
def _repair_command(host: str, config: HostServerConfig, *, config_path: Path | None = None) -> str:
|
||||
command = ["pyro", "host", "repair", host]
|
||||
if config.installed_package:
|
||||
command.append("--installed-package")
|
||||
if config.mode is not None:
|
||||
command.extend(["--mode", config.mode])
|
||||
elif config.profile != "workspace-core":
|
||||
command.extend(["--profile", config.profile])
|
||||
if config.project_path is not None:
|
||||
command.extend(["--project-path", config.project_path])
|
||||
elif config.repo_url is not None:
|
||||
command.extend(["--repo-url", config.repo_url])
|
||||
if config.repo_ref is not None:
|
||||
command.extend(["--repo-ref", config.repo_ref])
|
||||
elif config.no_project_source:
|
||||
command.append("--no-project-source")
|
||||
if config_path is not None:
|
||||
command.extend(["--config-path", str(config_path)])
|
||||
return _render_cli_command(command)
|
||||
|
||||
|
||||
def _command_matches(output: str, expected: list[str]) -> bool:
|
||||
normalized_output = output.strip()
|
||||
if ":" in normalized_output:
|
||||
normalized_output = normalized_output.split(":", 1)[1].strip()
|
||||
try:
|
||||
parsed = shlex.split(normalized_output)
|
||||
except ValueError:
|
||||
parsed = normalized_output.split()
|
||||
return parsed == expected
|
||||
|
||||
|
||||
def _upsert_opencode_config(
|
||||
*,
|
||||
config_path: Path,
|
||||
config: HostServerConfig,
|
||||
) -> tuple[dict[str, object], Path | None]:
|
||||
existing_payload: dict[str, object] = {}
|
||||
backup_path: Path | None = None
|
||||
if config_path.exists():
|
||||
raw_text = config_path.read_text(encoding="utf-8")
|
||||
try:
|
||||
parsed = json.loads(raw_text)
|
||||
except json.JSONDecodeError:
|
||||
timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S")
|
||||
backup_path = config_path.with_name(f"{config_path.name}.bak-{timestamp}")
|
||||
shutil.move(str(config_path), str(backup_path))
|
||||
parsed = {}
|
||||
if isinstance(parsed, dict):
|
||||
existing_payload = parsed
|
||||
else:
|
||||
timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S")
|
||||
backup_path = config_path.with_name(f"{config_path.name}.bak-{timestamp}")
|
||||
shutil.move(str(config_path), str(backup_path))
|
||||
payload = dict(existing_payload)
|
||||
mcp_payload = payload.get("mcp")
|
||||
if not isinstance(mcp_payload, dict):
|
||||
mcp_payload = {}
|
||||
else:
|
||||
mcp_payload = dict(mcp_payload)
|
||||
mcp_payload[DEFAULT_HOST_SERVER_NAME] = canonical_opencode_entry(config)
|
||||
payload["mcp"] = mcp_payload
|
||||
return payload, backup_path
|
||||
|
||||
|
||||
def canonical_opencode_entry(config: HostServerConfig) -> dict[str, object]:
|
||||
return {
|
||||
"type": "local",
|
||||
"enabled": True,
|
||||
"command": _canonical_server_command(config),
|
||||
}
|
||||
|
||||
|
||||
def render_opencode_config(config: HostServerConfig) -> str:
|
||||
return (
|
||||
json.dumps(
|
||||
{"mcp": {DEFAULT_HOST_SERVER_NAME: canonical_opencode_entry(config)}},
|
||||
indent=2,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
|
||||
def print_or_write_opencode_config(
|
||||
*,
|
||||
config: HostServerConfig,
|
||||
output_path: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
rendered = render_opencode_config(config)
|
||||
if output_path is None:
|
||||
return {
|
||||
"host": "opencode",
|
||||
"rendered_config": rendered,
|
||||
"server_command": _canonical_server_command(config),
|
||||
}
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(rendered, encoding="utf-8")
|
||||
return {
|
||||
"host": "opencode",
|
||||
"output_path": str(output_path),
|
||||
"server_command": _canonical_server_command(config),
|
||||
}
|
||||
|
||||
|
||||
def connect_cli_host(host: str, *, config: HostServerConfig) -> dict[str, object]:
|
||||
binary = _host_binary(host)
|
||||
if shutil.which(binary) is None:
|
||||
raise RuntimeError(f"{binary} CLI is not installed or not on PATH")
|
||||
server_command = _canonical_server_command(config)
|
||||
_run_command([binary, "mcp", "remove", DEFAULT_HOST_SERVER_NAME])
|
||||
result = _run_command([binary, "mcp", "add", DEFAULT_HOST_SERVER_NAME, "--", *server_command])
|
||||
if result.returncode != 0:
|
||||
details = (result.stderr or result.stdout).strip() or f"{binary} mcp add failed"
|
||||
raise RuntimeError(details)
|
||||
return {
|
||||
"host": host,
|
||||
"server_command": server_command,
|
||||
"verification_command": [binary, "mcp", "list"],
|
||||
}
|
||||
|
||||
|
||||
def repair_opencode_host(
|
||||
*,
|
||||
config: HostServerConfig,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
resolved_path = (
|
||||
DEFAULT_OPENCODE_CONFIG_PATH
|
||||
if config_path is None
|
||||
else config_path.expanduser().resolve()
|
||||
)
|
||||
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload, backup_path = _upsert_opencode_config(config_path=resolved_path, config=config)
|
||||
resolved_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||
result: dict[str, object] = {
|
||||
"host": "opencode",
|
||||
"config_path": str(resolved_path),
|
||||
"server_command": _canonical_server_command(config),
|
||||
}
|
||||
if backup_path is not None:
|
||||
result["backup_path"] = str(backup_path)
|
||||
return result
|
||||
|
||||
|
||||
def repair_host(
|
||||
host: str,
|
||||
*,
|
||||
config: HostServerConfig,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
if host == "opencode":
|
||||
return repair_opencode_host(config=config, config_path=config_path)
|
||||
return connect_cli_host(host, config=config)
|
||||
|
||||
|
||||
def _doctor_cli_host(host: str, *, config: HostServerConfig) -> HostDoctorEntry:
|
||||
binary = _host_binary(host)
|
||||
repair_command = _repair_command(host, config)
|
||||
if shutil.which(binary) is None:
|
||||
return HostDoctorEntry(
|
||||
host=host,
|
||||
installed=False,
|
||||
configured=False,
|
||||
status="unavailable",
|
||||
details=f"{binary} CLI was not found on PATH",
|
||||
repair_command=repair_command,
|
||||
)
|
||||
expected_command = _canonical_server_command(config)
|
||||
get_result = _run_command([binary, "mcp", "get", DEFAULT_HOST_SERVER_NAME])
|
||||
combined_get_output = (get_result.stdout + get_result.stderr).strip()
|
||||
if get_result.returncode == 0:
|
||||
status: HostStatus = (
|
||||
"ok" if _command_matches(combined_get_output, expected_command) else "drifted"
|
||||
)
|
||||
return HostDoctorEntry(
|
||||
host=host,
|
||||
installed=True,
|
||||
configured=True,
|
||||
status=status,
|
||||
details=combined_get_output or f"{binary} MCP entry exists",
|
||||
repair_command=repair_command,
|
||||
)
|
||||
|
||||
list_result = _run_command([binary, "mcp", "list"])
|
||||
combined_list_output = (list_result.stdout + list_result.stderr).strip()
|
||||
configured = DEFAULT_HOST_SERVER_NAME in combined_list_output.split()
|
||||
return HostDoctorEntry(
|
||||
host=host,
|
||||
installed=True,
|
||||
configured=configured,
|
||||
status="drifted" if configured else "missing",
|
||||
details=combined_get_output or combined_list_output or f"{binary} MCP entry missing",
|
||||
repair_command=repair_command,
|
||||
)
|
||||
|
||||
|
||||
def _doctor_opencode_host(
|
||||
*,
|
||||
config: HostServerConfig,
|
||||
config_path: Path | None = None,
|
||||
) -> HostDoctorEntry:
|
||||
resolved_path = (
|
||||
DEFAULT_OPENCODE_CONFIG_PATH
|
||||
if config_path is None
|
||||
else config_path.expanduser().resolve()
|
||||
)
|
||||
repair_command = _repair_command("opencode", config, config_path=config_path)
|
||||
installed = shutil.which("opencode") is not None
|
||||
if not resolved_path.exists():
|
||||
return HostDoctorEntry(
|
||||
host="opencode",
|
||||
installed=installed,
|
||||
configured=False,
|
||||
status="missing" if installed else "unavailable",
|
||||
details=f"OpenCode config missing at {resolved_path}",
|
||||
repair_command=repair_command,
|
||||
)
|
||||
try:
|
||||
payload = json.loads(resolved_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
return HostDoctorEntry(
|
||||
host="opencode",
|
||||
installed=installed,
|
||||
configured=False,
|
||||
status="drifted" if installed else "unavailable",
|
||||
details=f"OpenCode config is invalid JSON: {exc}",
|
||||
repair_command=repair_command,
|
||||
)
|
||||
if not isinstance(payload, dict):
|
||||
return HostDoctorEntry(
|
||||
host="opencode",
|
||||
installed=installed,
|
||||
configured=False,
|
||||
status="drifted" if installed else "unavailable",
|
||||
details="OpenCode config must be a JSON object",
|
||||
repair_command=repair_command,
|
||||
)
|
||||
mcp_payload = payload.get("mcp")
|
||||
if not isinstance(mcp_payload, dict) or DEFAULT_HOST_SERVER_NAME not in mcp_payload:
|
||||
return HostDoctorEntry(
|
||||
host="opencode",
|
||||
installed=installed,
|
||||
configured=False,
|
||||
status="missing" if installed else "unavailable",
|
||||
details=f"OpenCode config at {resolved_path} is missing mcp.pyro",
|
||||
repair_command=repair_command,
|
||||
)
|
||||
configured_entry = mcp_payload[DEFAULT_HOST_SERVER_NAME]
|
||||
expected_entry = canonical_opencode_entry(config)
|
||||
status: HostStatus = "ok" if configured_entry == expected_entry else "drifted"
|
||||
return HostDoctorEntry(
|
||||
host="opencode",
|
||||
installed=installed,
|
||||
configured=True,
|
||||
status=status,
|
||||
details=f"OpenCode config path: {resolved_path}",
|
||||
repair_command=repair_command,
|
||||
)
|
||||
|
||||
|
||||
def doctor_hosts(
|
||||
*,
|
||||
config: HostServerConfig,
|
||||
config_path: Path | None = None,
|
||||
) -> list[HostDoctorEntry]:
|
||||
return [
|
||||
_doctor_cli_host("claude-code", config=config),
|
||||
_doctor_cli_host("codex", config=config),
|
||||
_doctor_opencode_host(config=config, config_path=config_path),
|
||||
]
|
||||
149
src/pyro_mcp/project_startup.py
Normal file
149
src/pyro_mcp/project_startup.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"""Server-scoped project startup source helpers for MCP chat flows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Literal
|
||||
|
||||
ProjectStartupSourceKind = Literal["project_path", "repo_url"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectStartupSource:
|
||||
"""Server-scoped default source for workspace creation."""
|
||||
|
||||
kind: ProjectStartupSourceKind
|
||||
origin_ref: str
|
||||
resolved_path: Path | None = None
|
||||
repo_ref: str | None = None
|
||||
|
||||
|
||||
def _run_git(command: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run( # noqa: S603
|
||||
command,
|
||||
cwd=str(cwd) if cwd is not None else None,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _detect_git_root(start_dir: Path) -> Path | None:
|
||||
result = _run_git(["git", "rev-parse", "--show-toplevel"], cwd=start_dir)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
stdout = result.stdout.strip()
|
||||
if stdout == "":
|
||||
return None
|
||||
return Path(stdout).expanduser().resolve()
|
||||
|
||||
|
||||
def _resolve_project_path(project_path: str | Path, *, cwd: Path) -> Path:
|
||||
resolved = Path(project_path).expanduser()
|
||||
if not resolved.is_absolute():
|
||||
resolved = (cwd / resolved).resolve()
|
||||
else:
|
||||
resolved = resolved.resolve()
|
||||
if not resolved.exists():
|
||||
raise ValueError(f"project_path {resolved} does not exist")
|
||||
if not resolved.is_dir():
|
||||
raise ValueError(f"project_path {resolved} must be a directory")
|
||||
git_root = _detect_git_root(resolved)
|
||||
if git_root is not None:
|
||||
return git_root
|
||||
return resolved
|
||||
|
||||
|
||||
def resolve_project_startup_source(
|
||||
*,
|
||||
project_path: str | Path | None = None,
|
||||
repo_url: str | None = None,
|
||||
repo_ref: str | None = None,
|
||||
no_project_source: bool = False,
|
||||
cwd: Path | None = None,
|
||||
) -> ProjectStartupSource | None:
|
||||
working_dir = Path.cwd() if cwd is None else cwd.resolve()
|
||||
if no_project_source:
|
||||
if project_path is not None or repo_url is not None or repo_ref is not None:
|
||||
raise ValueError(
|
||||
"--no-project-source cannot be combined with --project-path, "
|
||||
"--repo-url, or --repo-ref"
|
||||
)
|
||||
return None
|
||||
if project_path is not None and repo_url is not None:
|
||||
raise ValueError("--project-path and --repo-url are mutually exclusive")
|
||||
if repo_ref is not None and repo_url is None:
|
||||
raise ValueError("--repo-ref requires --repo-url")
|
||||
if project_path is not None:
|
||||
resolved_path = _resolve_project_path(project_path, cwd=working_dir)
|
||||
return ProjectStartupSource(
|
||||
kind="project_path",
|
||||
origin_ref=str(resolved_path),
|
||||
resolved_path=resolved_path,
|
||||
)
|
||||
if repo_url is not None:
|
||||
normalized_repo_url = repo_url.strip()
|
||||
if normalized_repo_url == "":
|
||||
raise ValueError("--repo-url must not be empty")
|
||||
normalized_repo_ref = None if repo_ref is None else repo_ref.strip()
|
||||
if normalized_repo_ref == "":
|
||||
raise ValueError("--repo-ref must not be empty")
|
||||
return ProjectStartupSource(
|
||||
kind="repo_url",
|
||||
origin_ref=normalized_repo_url,
|
||||
repo_ref=normalized_repo_ref,
|
||||
)
|
||||
detected_root = _detect_git_root(working_dir)
|
||||
if detected_root is None:
|
||||
return None
|
||||
return ProjectStartupSource(
|
||||
kind="project_path",
|
||||
origin_ref=str(detected_root),
|
||||
resolved_path=detected_root,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def materialize_project_startup_source(source: ProjectStartupSource) -> Iterator[Path]:
|
||||
if source.kind == "project_path":
|
||||
if source.resolved_path is None:
|
||||
raise RuntimeError("project_path source is missing a resolved path")
|
||||
yield source.resolved_path
|
||||
return
|
||||
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="pyro-project-source-"))
|
||||
clone_dir = temp_dir / "clone"
|
||||
try:
|
||||
clone_result = _run_git(["git", "clone", "--quiet", source.origin_ref, str(clone_dir)])
|
||||
if clone_result.returncode != 0:
|
||||
stderr = clone_result.stderr.strip() or "git clone failed"
|
||||
raise RuntimeError(f"failed to clone repo_url {source.origin_ref!r}: {stderr}")
|
||||
if source.repo_ref is not None:
|
||||
checkout_result = _run_git(
|
||||
["git", "checkout", "--quiet", source.repo_ref],
|
||||
cwd=clone_dir,
|
||||
)
|
||||
if checkout_result.returncode != 0:
|
||||
stderr = checkout_result.stderr.strip() or "git checkout failed"
|
||||
raise RuntimeError(
|
||||
f"failed to checkout repo_ref {source.repo_ref!r} for "
|
||||
f"repo_url {source.origin_ref!r}: {stderr}"
|
||||
)
|
||||
yield clone_dir
|
||||
finally:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def describe_project_startup_source(source: ProjectStartupSource | None) -> str | None:
|
||||
if source is None:
|
||||
return None
|
||||
if source.kind == "project_path":
|
||||
return f"the current project at {source.origin_ref}"
|
||||
if source.repo_ref is None:
|
||||
return f"the clean clone source {source.origin_ref}"
|
||||
return f"the clean clone source {source.origin_ref} at ref {source.repo_ref}"
|
||||
|
|
@ -11,6 +11,13 @@ from dataclasses import dataclass
|
|||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pyro_mcp.daily_loop import (
|
||||
DEFAULT_PREPARE_ENVIRONMENT,
|
||||
evaluate_daily_loop_status,
|
||||
load_prepare_manifest,
|
||||
prepare_manifest_path,
|
||||
serialize_daily_loop_report,
|
||||
)
|
||||
from pyro_mcp.vm_network import TapNetworkManager
|
||||
|
||||
DEFAULT_PLATFORM = "linux-x86_64"
|
||||
|
|
@ -200,7 +207,11 @@ def runtime_capabilities(paths: RuntimePaths) -> RuntimeCapabilities:
|
|||
)
|
||||
|
||||
|
||||
def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]:
|
||||
def doctor_report(
|
||||
*,
|
||||
platform: str = DEFAULT_PLATFORM,
|
||||
environment: str = DEFAULT_PREPARE_ENVIRONMENT,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a runtime diagnostics report."""
|
||||
report: dict[str, Any] = {
|
||||
"platform": platform,
|
||||
|
|
@ -258,6 +269,36 @@ def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]:
|
|||
"cache_dir": str(environment_store.cache_dir),
|
||||
"environments": environment_store.list_environments(),
|
||||
}
|
||||
environment_details = environment_store.inspect_environment(environment)
|
||||
manifest_path = prepare_manifest_path(
|
||||
environment_store.cache_dir,
|
||||
platform=platform,
|
||||
environment=environment,
|
||||
)
|
||||
manifest, manifest_error = load_prepare_manifest(manifest_path)
|
||||
status, reason = evaluate_daily_loop_status(
|
||||
environment=environment,
|
||||
environment_version=str(environment_details["version"]),
|
||||
platform=platform,
|
||||
catalog_version=environment_store.catalog_version,
|
||||
bundle_version=(
|
||||
None
|
||||
if paths.manifest.get("bundle_version") is None
|
||||
else str(paths.manifest["bundle_version"])
|
||||
),
|
||||
installed=bool(environment_details["installed"]),
|
||||
manifest=manifest,
|
||||
manifest_error=manifest_error,
|
||||
)
|
||||
report["daily_loop"] = serialize_daily_loop_report(
|
||||
environment=environment,
|
||||
status=status,
|
||||
installed=bool(environment_details["installed"]),
|
||||
cache_dir=environment_store.cache_dir,
|
||||
manifest_path=manifest_path,
|
||||
reason=reason,
|
||||
manifest=manifest,
|
||||
)
|
||||
if not report["kvm"]["exists"]:
|
||||
report["issues"] = ["/dev/kvm is not available on this host"]
|
||||
return report
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from pyro_mcp.api import McpToolProfile, Pyro
|
||||
from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode
|
||||
from pyro_mcp.vm_manager import VmManager
|
||||
|
||||
|
||||
|
|
@ -12,14 +14,29 @@ def create_server(
|
|||
manager: VmManager | None = None,
|
||||
*,
|
||||
profile: McpToolProfile = "workspace-core",
|
||||
mode: WorkspaceUseCaseMode | None = None,
|
||||
project_path: str | Path | None = None,
|
||||
repo_url: str | None = None,
|
||||
repo_ref: str | None = None,
|
||||
no_project_source: bool = False,
|
||||
) -> FastMCP:
|
||||
"""Create and return a configured MCP server instance.
|
||||
|
||||
`workspace-core` is the default stable chat-host profile in 4.x. Use
|
||||
Bare server creation uses the generic `workspace-core` path in 4.x. Use
|
||||
`mode=...` for one of the named use-case surfaces, or
|
||||
`profile="workspace-full"` only when the host truly needs the full
|
||||
advanced workspace surface.
|
||||
advanced workspace surface. By default, the server auto-detects the
|
||||
nearest Git worktree root from its current working directory for
|
||||
project-aware `workspace_create` calls.
|
||||
"""
|
||||
return Pyro(manager=manager).create_server(profile=profile)
|
||||
return Pyro(manager=manager).create_server(
|
||||
profile=profile,
|
||||
mode=mode,
|
||||
project_path=project_path,
|
||||
repo_url=repo_url,
|
||||
repo_ref=repo_ref,
|
||||
no_project_source=no_project_source,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from typing import Any
|
|||
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
|
||||
|
||||
DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
|
||||
DEFAULT_CATALOG_VERSION = "4.0.0"
|
||||
DEFAULT_CATALOG_VERSION = "4.5.0"
|
||||
OCI_MANIFEST_ACCEPT = ", ".join(
|
||||
(
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
|
|
@ -48,7 +48,7 @@ class VmEnvironment:
|
|||
oci_repository: str | None = None
|
||||
oci_reference: str | None = None
|
||||
source_digest: str | None = None
|
||||
compatibility: str = ">=4.0.0,<5.0.0"
|
||||
compatibility: str = ">=4.5.0,<5.0.0"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,15 @@ from dataclasses import dataclass, field
|
|||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from pyro_mcp.daily_loop import (
|
||||
DailyLoopManifest,
|
||||
evaluate_daily_loop_status,
|
||||
load_prepare_manifest,
|
||||
prepare_manifest_path,
|
||||
prepare_request_is_satisfied,
|
||||
serialize_daily_loop_report,
|
||||
write_prepare_manifest,
|
||||
)
|
||||
from pyro_mcp.runtime import (
|
||||
RuntimeCapabilities,
|
||||
RuntimePaths,
|
||||
|
|
@ -79,12 +88,13 @@ DEFAULT_TIMEOUT_SECONDS = 30
|
|||
DEFAULT_TTL_SECONDS = 600
|
||||
DEFAULT_ALLOW_HOST_COMPAT = False
|
||||
|
||||
WORKSPACE_LAYOUT_VERSION = 8
|
||||
WORKSPACE_LAYOUT_VERSION = 9
|
||||
WORKSPACE_BASELINE_DIRNAME = "baseline"
|
||||
WORKSPACE_BASELINE_ARCHIVE_NAME = "workspace.tar"
|
||||
WORKSPACE_SNAPSHOTS_DIRNAME = "snapshots"
|
||||
WORKSPACE_DIRNAME = "workspace"
|
||||
WORKSPACE_COMMANDS_DIRNAME = "commands"
|
||||
WORKSPACE_REVIEW_DIRNAME = "review"
|
||||
WORKSPACE_SHELLS_DIRNAME = "shells"
|
||||
WORKSPACE_SERVICES_DIRNAME = "services"
|
||||
WORKSPACE_SECRETS_DIRNAME = "secrets"
|
||||
|
|
@ -116,7 +126,18 @@ WORKSPACE_SECRET_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
|
|||
WORKSPACE_LABEL_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
|
||||
WorkspaceSeedMode = Literal["empty", "directory", "tar_archive"]
|
||||
WorkspaceSeedOriginKind = Literal["empty", "manual_seed_path", "project_path", "repo_url"]
|
||||
WorkspaceArtifactType = Literal["file", "directory", "symlink"]
|
||||
WorkspaceReviewEventKind = Literal[
|
||||
"file_write",
|
||||
"patch_apply",
|
||||
"service_start",
|
||||
"service_stop",
|
||||
"snapshot_create",
|
||||
"snapshot_delete",
|
||||
"sync_push",
|
||||
"workspace_export",
|
||||
]
|
||||
WorkspaceServiceReadinessType = Literal["file", "tcp", "http", "command"]
|
||||
WorkspaceSnapshotKind = Literal["baseline", "named"]
|
||||
WorkspaceSecretSourceKind = Literal["literal", "file"]
|
||||
|
|
@ -276,9 +297,7 @@ class WorkspaceRecord:
|
|||
network=_deserialize_network(payload.get("network")),
|
||||
name=_normalize_workspace_name(_optional_str(payload.get("name")), allow_none=True),
|
||||
labels=_normalize_workspace_labels(payload.get("labels")),
|
||||
last_activity_at=float(
|
||||
payload.get("last_activity_at", float(payload["created_at"]))
|
||||
),
|
||||
last_activity_at=float(payload.get("last_activity_at", float(payload["created_at"]))),
|
||||
command_count=int(payload.get("command_count", 0)),
|
||||
last_command=_optional_dict(payload.get("last_command")),
|
||||
workspace_seed=_workspace_seed_dict(payload.get("workspace_seed")),
|
||||
|
|
@ -316,6 +335,35 @@ class WorkspaceSecretRecord:
|
|||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkspaceReviewEventRecord:
|
||||
"""Persistent concise review event metadata stored on disk per workspace."""
|
||||
|
||||
workspace_id: str
|
||||
event_kind: WorkspaceReviewEventKind
|
||||
recorded_at: float
|
||||
payload: dict[str, Any]
|
||||
|
||||
def to_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"layout_version": WORKSPACE_LAYOUT_VERSION,
|
||||
"workspace_id": self.workspace_id,
|
||||
"event_kind": self.event_kind,
|
||||
"recorded_at": self.recorded_at,
|
||||
"payload": self.payload,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: dict[str, Any]) -> WorkspaceReviewEventRecord:
|
||||
raw_payload = payload.get("payload")
|
||||
return cls(
|
||||
workspace_id=str(payload["workspace_id"]),
|
||||
event_kind=cast(WorkspaceReviewEventKind, str(payload["event_kind"])),
|
||||
recorded_at=float(payload["recorded_at"]),
|
||||
payload=dict(raw_payload) if isinstance(raw_payload, dict) else {},
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkspaceSnapshotRecord:
|
||||
"""Persistent snapshot metadata stored on disk per workspace."""
|
||||
|
|
@ -503,9 +551,7 @@ class WorkspacePublishedPortRecord:
|
|||
host=str(payload.get("host", DEFAULT_PUBLISHED_PORT_HOST)),
|
||||
protocol=str(payload.get("protocol", "tcp")),
|
||||
proxy_pid=(
|
||||
None
|
||||
if payload.get("proxy_pid") is None
|
||||
else int(payload.get("proxy_pid", 0))
|
||||
None if payload.get("proxy_pid") is None else int(payload.get("proxy_pid", 0))
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -524,6 +570,8 @@ class PreparedWorkspaceSeed:
|
|||
|
||||
mode: WorkspaceSeedMode
|
||||
source_path: str | None
|
||||
origin_kind: WorkspaceSeedOriginKind = "empty"
|
||||
origin_ref: str | None = None
|
||||
archive_path: Path | None = None
|
||||
entry_count: int = 0
|
||||
bytes_written: int = 0
|
||||
|
|
@ -534,14 +582,19 @@ class PreparedWorkspaceSeed:
|
|||
*,
|
||||
destination: str = WORKSPACE_GUEST_PATH,
|
||||
path_key: str = "seed_path",
|
||||
include_origin: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
payload = {
|
||||
"mode": self.mode,
|
||||
path_key: self.source_path,
|
||||
"destination": destination,
|
||||
"entry_count": self.entry_count,
|
||||
"bytes_written": self.bytes_written,
|
||||
}
|
||||
if include_origin:
|
||||
payload["origin_kind"] = self.origin_kind
|
||||
payload["origin_ref"] = self.origin_ref
|
||||
return payload
|
||||
|
||||
def cleanup(self) -> None:
|
||||
if self.cleanup_dir is not None:
|
||||
|
|
@ -614,6 +667,8 @@ def _empty_workspace_seed_payload() -> dict[str, Any]:
|
|||
return {
|
||||
"mode": "empty",
|
||||
"seed_path": None,
|
||||
"origin_kind": "empty",
|
||||
"origin_ref": None,
|
||||
"destination": WORKSPACE_GUEST_PATH,
|
||||
"entry_count": 0,
|
||||
"bytes_written": 0,
|
||||
|
|
@ -628,6 +683,8 @@ def _workspace_seed_dict(value: object) -> dict[str, Any]:
|
|||
{
|
||||
"mode": str(value.get("mode", payload["mode"])),
|
||||
"seed_path": _optional_str(value.get("seed_path")),
|
||||
"origin_kind": str(value.get("origin_kind", payload["origin_kind"])),
|
||||
"origin_ref": _optional_str(value.get("origin_ref")),
|
||||
"destination": str(value.get("destination", payload["destination"])),
|
||||
"entry_count": int(value.get("entry_count", payload["entry_count"])),
|
||||
"bytes_written": int(value.get("bytes_written", payload["bytes_written"])),
|
||||
|
|
@ -869,9 +926,7 @@ def _validate_workspace_file_read_max_bytes(max_bytes: int) -> int:
|
|||
if max_bytes <= 0:
|
||||
raise ValueError("max_bytes must be positive")
|
||||
if max_bytes > WORKSPACE_FILE_MAX_BYTES:
|
||||
raise ValueError(
|
||||
f"max_bytes must be at most {WORKSPACE_FILE_MAX_BYTES} bytes"
|
||||
)
|
||||
raise ValueError(f"max_bytes must be at most {WORKSPACE_FILE_MAX_BYTES} bytes")
|
||||
return max_bytes
|
||||
|
||||
|
||||
|
|
@ -899,9 +954,7 @@ def _decode_workspace_patch_text(path: str, content_bytes: bytes) -> str:
|
|||
try:
|
||||
return content_bytes.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise RuntimeError(
|
||||
f"workspace patch only supports UTF-8 text files: {path}"
|
||||
) from exc
|
||||
raise RuntimeError(f"workspace patch only supports UTF-8 text files: {path}") from exc
|
||||
|
||||
|
||||
def _normalize_archive_member_name(name: str) -> PurePosixPath:
|
||||
|
|
@ -991,9 +1044,7 @@ def _prepare_workspace_secrets(
|
|||
has_value = "value" in item
|
||||
has_file_path = "file_path" in item
|
||||
if has_value == has_file_path:
|
||||
raise ValueError(
|
||||
f"secret {name!r} must provide exactly one of 'value' or 'file_path'"
|
||||
)
|
||||
raise ValueError(f"secret {name!r} must provide exactly one of 'value' or 'file_path'")
|
||||
source_kind: WorkspaceSecretSourceKind
|
||||
if has_value:
|
||||
value = _validate_workspace_secret_value(name, str(item["value"]))
|
||||
|
|
@ -1473,9 +1524,7 @@ def _normalize_workspace_published_port_specs(
|
|||
)
|
||||
dedupe_key = (spec.host_port, spec.guest_port)
|
||||
if dedupe_key in seen_guest_ports:
|
||||
raise ValueError(
|
||||
"published ports must not repeat the same host/guest port mapping"
|
||||
)
|
||||
raise ValueError("published ports must not repeat the same host/guest port mapping")
|
||||
seen_guest_ports.add(dedupe_key)
|
||||
normalized.append(spec)
|
||||
return normalized
|
||||
|
|
@ -1738,7 +1787,7 @@ def _start_local_service(
|
|||
),
|
||||
"status=$?",
|
||||
f"printf '%s' \"$status\" > {shlex.quote(str(status_path))}",
|
||||
"exit \"$status\"",
|
||||
'exit "$status"',
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
|
|
@ -1921,9 +1970,7 @@ def _patch_rootfs_runtime_file(
|
|||
) -> None:
|
||||
debugfs_path = shutil.which("debugfs")
|
||||
if debugfs_path is None:
|
||||
raise RuntimeError(
|
||||
"debugfs is required to seed workspaces on guest-backed runtimes"
|
||||
)
|
||||
raise RuntimeError("debugfs is required to seed workspaces on guest-backed runtimes")
|
||||
with tempfile.TemporaryDirectory(prefix=f"pyro-{asset_label}-") as temp_dir:
|
||||
staged_path = Path(temp_dir) / Path(destination_path).name
|
||||
shutil.copy2(source_path, staged_path)
|
||||
|
|
@ -3582,6 +3629,152 @@ class VmManager:
|
|||
def prune_environments(self) -> dict[str, object]:
|
||||
return self._environment_store.prune_environments()
|
||||
|
||||
def prepare_daily_loop(
|
||||
self,
|
||||
environment: str,
|
||||
*,
|
||||
network: bool = False,
|
||||
force: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
spec = get_environment(environment, runtime_paths=self._runtime_paths)
|
||||
if self._backend_name != "firecracker":
|
||||
raise RuntimeError("pyro prepare requires a guest-backed runtime and is unavailable")
|
||||
if not self._runtime_capabilities.supports_vm_boot:
|
||||
reason = self._runtime_capabilities.reason or "runtime does not support guest boot"
|
||||
raise RuntimeError(
|
||||
f"pyro prepare requires guest-backed workspace boot and is unavailable: {reason}"
|
||||
)
|
||||
if not self._runtime_capabilities.supports_guest_exec:
|
||||
reason = self._runtime_capabilities.reason or (
|
||||
"runtime does not support guest command execution"
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"pyro prepare requires guest command execution and is unavailable: {reason}"
|
||||
)
|
||||
if network and not self._runtime_capabilities.supports_guest_network:
|
||||
reason = self._runtime_capabilities.reason or (
|
||||
"runtime does not support guest networking"
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"pyro prepare --network requires guest networking and is unavailable: {reason}"
|
||||
)
|
||||
|
||||
runtime_paths = self._runtime_paths
|
||||
if runtime_paths is None:
|
||||
raise RuntimeError("runtime paths are unavailable for pyro prepare")
|
||||
platform = str(runtime_paths.manifest.get("platform", "linux-x86_64"))
|
||||
bundle_version = cast(str | None, runtime_paths.manifest.get("bundle_version"))
|
||||
manifest_path = prepare_manifest_path(
|
||||
self._environment_store.cache_dir,
|
||||
platform=platform,
|
||||
environment=environment,
|
||||
)
|
||||
manifest, manifest_error = load_prepare_manifest(manifest_path)
|
||||
status, status_reason = evaluate_daily_loop_status(
|
||||
environment=environment,
|
||||
environment_version=spec.version,
|
||||
platform=platform,
|
||||
catalog_version=self._environment_store.catalog_version,
|
||||
bundle_version=bundle_version,
|
||||
installed=bool(self.inspect_environment(environment)["installed"]),
|
||||
manifest=manifest,
|
||||
manifest_error=manifest_error,
|
||||
)
|
||||
if (
|
||||
not force
|
||||
and status == "warm"
|
||||
and prepare_request_is_satisfied(manifest, require_network=network)
|
||||
):
|
||||
if manifest is None:
|
||||
raise AssertionError("warm prepare state requires a manifest")
|
||||
payload = serialize_daily_loop_report(
|
||||
environment=environment,
|
||||
status="warm",
|
||||
installed=True,
|
||||
cache_dir=self._environment_store.cache_dir,
|
||||
manifest_path=manifest_path,
|
||||
reason="reused existing warm manifest",
|
||||
manifest=manifest,
|
||||
)
|
||||
payload.update(
|
||||
{
|
||||
"prepared": True,
|
||||
"reused": True,
|
||||
"executed": False,
|
||||
"forced": force,
|
||||
"network_requested": network,
|
||||
"last_prepare_duration_ms": manifest.last_prepare_duration_ms,
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
self._environment_store.ensure_installed(environment)
|
||||
started = time.monotonic()
|
||||
workspace_id: str | None = None
|
||||
execution_mode = "pending"
|
||||
try:
|
||||
created = self.create_workspace(
|
||||
environment=environment,
|
||||
network_policy="egress" if network else "off",
|
||||
allow_host_compat=False,
|
||||
)
|
||||
workspace_id = str(created["workspace_id"])
|
||||
exec_result = self.exec_workspace(
|
||||
workspace_id,
|
||||
command="pwd",
|
||||
timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
|
||||
)
|
||||
execution_mode = str(exec_result.get("execution_mode", "unknown"))
|
||||
if int(exec_result.get("exit_code", 1)) != 0:
|
||||
raise RuntimeError("prepare guest exec failed")
|
||||
if str(exec_result.get("stdout", "")) != f"{WORKSPACE_GUEST_PATH}\n":
|
||||
raise RuntimeError("prepare guest exec returned an unexpected working directory")
|
||||
self.reset_workspace(workspace_id)
|
||||
finally:
|
||||
if workspace_id is not None:
|
||||
try:
|
||||
self.delete_workspace(workspace_id, reason="prepare_cleanup")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
duration_ms = int((time.monotonic() - started) * 1000)
|
||||
prepared_at = time.time()
|
||||
preserved_network_prepared = bool(
|
||||
manifest is not None and status == "warm" and manifest.network_prepared
|
||||
)
|
||||
prepared_manifest = DailyLoopManifest(
|
||||
environment=environment,
|
||||
environment_version=spec.version,
|
||||
platform=platform,
|
||||
catalog_version=self._environment_store.catalog_version,
|
||||
bundle_version=bundle_version,
|
||||
prepared_at=prepared_at,
|
||||
network_prepared=network or preserved_network_prepared,
|
||||
last_prepare_duration_ms=duration_ms,
|
||||
)
|
||||
write_prepare_manifest(manifest_path, prepared_manifest)
|
||||
payload = serialize_daily_loop_report(
|
||||
environment=environment,
|
||||
status="warm",
|
||||
installed=True,
|
||||
cache_dir=self._environment_store.cache_dir,
|
||||
manifest_path=manifest_path,
|
||||
reason=status_reason,
|
||||
manifest=prepared_manifest,
|
||||
)
|
||||
payload.update(
|
||||
{
|
||||
"prepared": True,
|
||||
"reused": False,
|
||||
"executed": True,
|
||||
"forced": force,
|
||||
"network_requested": network,
|
||||
"last_prepare_duration_ms": duration_ms,
|
||||
"execution_mode": execution_mode,
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
def create_vm(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -3747,19 +3940,23 @@ class VmManager:
|
|||
secrets: list[dict[str, str]] | None = None,
|
||||
name: str | None = None,
|
||||
labels: dict[str, str] | None = None,
|
||||
_prepared_seed: PreparedWorkspaceSeed | None = None,
|
||||
) -> dict[str, Any]:
|
||||
self._validate_limits(vcpu_count=vcpu_count, mem_mib=mem_mib, ttl_seconds=ttl_seconds)
|
||||
get_environment(environment, runtime_paths=self._runtime_paths)
|
||||
normalized_network_policy = _normalize_workspace_network_policy(str(network_policy))
|
||||
normalized_name = None if name is None else _normalize_workspace_name(name)
|
||||
normalized_labels = _normalize_workspace_labels(labels)
|
||||
prepared_seed = self._prepare_workspace_seed(seed_path)
|
||||
if _prepared_seed is not None and seed_path is not None:
|
||||
raise ValueError("_prepared_seed and seed_path are mutually exclusive")
|
||||
prepared_seed = _prepared_seed or self._prepare_workspace_seed(seed_path)
|
||||
now = time.time()
|
||||
workspace_id = uuid.uuid4().hex[:12]
|
||||
workspace_dir = self._workspace_dir(workspace_id)
|
||||
runtime_dir = self._workspace_runtime_dir(workspace_id)
|
||||
host_workspace_dir = self._workspace_host_dir(workspace_id)
|
||||
commands_dir = self._workspace_commands_dir(workspace_id)
|
||||
review_dir = self._workspace_review_dir(workspace_id)
|
||||
shells_dir = self._workspace_shells_dir(workspace_id)
|
||||
services_dir = self._workspace_services_dir(workspace_id)
|
||||
secrets_dir = self._workspace_secrets_dir(workspace_id)
|
||||
|
|
@ -3768,6 +3965,7 @@ class VmManager:
|
|||
workspace_dir.mkdir(parents=True, exist_ok=False)
|
||||
host_workspace_dir.mkdir(parents=True, exist_ok=True)
|
||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||
review_dir.mkdir(parents=True, exist_ok=True)
|
||||
shells_dir.mkdir(parents=True, exist_ok=True)
|
||||
services_dir.mkdir(parents=True, exist_ok=True)
|
||||
secrets_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -3802,9 +4000,7 @@ class VmManager:
|
|||
raise RuntimeError(
|
||||
f"max active VMs reached ({self._max_active_vms}); delete old VMs first"
|
||||
)
|
||||
self._require_workspace_network_policy_support(
|
||||
network_policy=normalized_network_policy
|
||||
)
|
||||
self._require_workspace_network_policy_support(network_policy=normalized_network_policy)
|
||||
self._backend.create(instance)
|
||||
if self._runtime_capabilities.supports_guest_exec:
|
||||
self._ensure_workspace_guest_bootstrap_support(instance)
|
||||
|
|
@ -3885,6 +4081,7 @@ class VmManager:
|
|||
workspace_sync = prepared_seed.to_payload(
|
||||
destination=normalized_destination,
|
||||
path_key="source_path",
|
||||
include_origin=False,
|
||||
)
|
||||
workspace_sync["entry_count"] = int(import_summary["entry_count"])
|
||||
workspace_sync["bytes_written"] = int(import_summary["bytes_written"])
|
||||
|
|
@ -3896,6 +4093,18 @@ class VmManager:
|
|||
workspace.last_error = instance.last_error
|
||||
workspace.metadata = dict(instance.metadata)
|
||||
self._touch_workspace_activity_locked(workspace)
|
||||
self._record_workspace_review_event_locked(
|
||||
workspace_id,
|
||||
event_kind="sync_push",
|
||||
payload={
|
||||
"mode": str(workspace_sync["mode"]),
|
||||
"source_path": str(workspace_sync["source_path"]),
|
||||
"destination": str(workspace_sync["destination"]),
|
||||
"entry_count": int(workspace_sync["entry_count"]),
|
||||
"bytes_written": int(workspace_sync["bytes_written"]),
|
||||
"execution_mode": str(instance.metadata.get("execution_mode", "pending")),
|
||||
},
|
||||
)
|
||||
self._save_workspace_locked(workspace)
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
|
|
@ -3977,8 +4186,8 @@ class VmManager:
|
|||
def export_workspace(
|
||||
self,
|
||||
workspace_id: str,
|
||||
*,
|
||||
path: str,
|
||||
*,
|
||||
output_path: str | Path,
|
||||
) -> dict[str, Any]:
|
||||
normalized_path, _ = _normalize_workspace_destination(path)
|
||||
|
|
@ -4010,6 +4219,23 @@ class VmManager:
|
|||
workspace.firecracker_pid = instance.firecracker_pid
|
||||
workspace.last_error = instance.last_error
|
||||
workspace.metadata = dict(instance.metadata)
|
||||
self._record_workspace_review_event_locked(
|
||||
workspace_id,
|
||||
event_kind="workspace_export",
|
||||
payload={
|
||||
"workspace_path": normalized_path,
|
||||
"output_path": str(Path(str(extracted["output_path"]))),
|
||||
"artifact_type": str(extracted["artifact_type"]),
|
||||
"entry_count": int(extracted["entry_count"]),
|
||||
"bytes_written": int(extracted["bytes_written"]),
|
||||
"execution_mode": str(
|
||||
exported.get(
|
||||
"execution_mode",
|
||||
instance.metadata.get("execution_mode", "pending"),
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
self._save_workspace_locked(workspace)
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
|
|
@ -4169,6 +4395,22 @@ class VmManager:
|
|||
workspace.firecracker_pid = instance.firecracker_pid
|
||||
workspace.last_error = instance.last_error
|
||||
workspace.metadata = dict(instance.metadata)
|
||||
self._touch_workspace_activity_locked(workspace)
|
||||
self._record_workspace_review_event_locked(
|
||||
workspace_id,
|
||||
event_kind="file_write",
|
||||
payload={
|
||||
"path": str(payload["path"]),
|
||||
"size_bytes": int(payload["size_bytes"]),
|
||||
"bytes_written": int(payload["bytes_written"]),
|
||||
"execution_mode": str(
|
||||
payload.get(
|
||||
"execution_mode",
|
||||
instance.metadata.get("execution_mode", "pending"),
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
self._save_workspace_locked(workspace)
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
|
|
@ -4286,6 +4528,15 @@ class VmManager:
|
|||
workspace.last_error = instance.last_error
|
||||
workspace.metadata = dict(instance.metadata)
|
||||
self._touch_workspace_activity_locked(workspace)
|
||||
self._record_workspace_review_event_locked(
|
||||
workspace_id,
|
||||
event_kind="patch_apply",
|
||||
payload={
|
||||
"summary": dict(summary),
|
||||
"entries": [dict(entry) for entry in entries[:10]],
|
||||
"execution_mode": str(instance.metadata.get("execution_mode", "pending")),
|
||||
},
|
||||
)
|
||||
self._save_workspace_locked(workspace)
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
|
|
@ -4363,6 +4614,17 @@ class VmManager:
|
|||
self._touch_workspace_activity_locked(workspace)
|
||||
self._save_workspace_locked(workspace)
|
||||
self._save_workspace_snapshot_locked(snapshot)
|
||||
self._record_workspace_review_event_locked(
|
||||
workspace_id,
|
||||
event_kind="snapshot_create",
|
||||
payload={
|
||||
"snapshot_name": snapshot.snapshot_name,
|
||||
"kind": snapshot.kind,
|
||||
"entry_count": snapshot.entry_count,
|
||||
"bytes_written": snapshot.bytes_written,
|
||||
"created_at": snapshot.created_at,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"snapshot": self._serialize_workspace_snapshot(snapshot),
|
||||
|
|
@ -4396,6 +4658,11 @@ class VmManager:
|
|||
self._load_workspace_snapshot_locked(workspace_id, normalized_snapshot_name)
|
||||
self._delete_workspace_snapshot_locked(workspace_id, normalized_snapshot_name)
|
||||
self._touch_workspace_activity_locked(workspace)
|
||||
self._record_workspace_review_event_locked(
|
||||
workspace_id,
|
||||
event_kind="snapshot_delete",
|
||||
payload={"snapshot_name": normalized_snapshot_name},
|
||||
)
|
||||
self._save_workspace_locked(workspace)
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
|
|
@ -4436,9 +4703,7 @@ class VmManager:
|
|||
recreated = workspace.to_instance(
|
||||
workdir=self._workspace_runtime_dir(workspace.workspace_id)
|
||||
)
|
||||
self._require_workspace_network_policy_support(
|
||||
network_policy=workspace.network_policy
|
||||
)
|
||||
self._require_workspace_network_policy_support(network_policy=workspace.network_policy)
|
||||
self._backend.create(recreated)
|
||||
if self._runtime_capabilities.supports_guest_exec:
|
||||
self._ensure_workspace_guest_bootstrap_support(recreated)
|
||||
|
|
@ -4632,9 +4897,7 @@ class VmManager:
|
|||
if wait_for_idle_ms is not None and (
|
||||
wait_for_idle_ms <= 0 or wait_for_idle_ms > MAX_SHELL_WAIT_FOR_IDLE_MS
|
||||
):
|
||||
raise ValueError(
|
||||
f"wait_for_idle_ms must be between 1 and {MAX_SHELL_WAIT_FOR_IDLE_MS}"
|
||||
)
|
||||
raise ValueError(f"wait_for_idle_ms must be between 1 and {MAX_SHELL_WAIT_FOR_IDLE_MS}")
|
||||
with self._lock:
|
||||
workspace = self._load_workspace_locked(workspace_id)
|
||||
instance = self._workspace_instance_for_live_shell_locked(workspace)
|
||||
|
|
@ -4909,8 +5172,7 @@ class VmManager:
|
|||
if normalized_published_ports:
|
||||
if workspace.network_policy != "egress+published-ports":
|
||||
raise RuntimeError(
|
||||
"published ports require workspace network_policy "
|
||||
"'egress+published-ports'"
|
||||
"published ports require workspace network_policy 'egress+published-ports'"
|
||||
)
|
||||
if instance.network is None:
|
||||
raise RuntimeError(
|
||||
|
|
@ -4997,6 +5259,24 @@ class VmManager:
|
|||
self._touch_workspace_activity_locked(workspace)
|
||||
self._save_workspace_locked(workspace)
|
||||
self._save_workspace_service_locked(service)
|
||||
self._record_workspace_review_event_locked(
|
||||
workspace_id,
|
||||
event_kind="service_start",
|
||||
payload={
|
||||
"service_name": service.service_name,
|
||||
"state": service.state,
|
||||
"command": service.command,
|
||||
"cwd": service.cwd,
|
||||
"readiness": (
|
||||
dict(service.readiness) if service.readiness is not None else None
|
||||
),
|
||||
"ready_at": service.ready_at,
|
||||
"published_ports": [
|
||||
_serialize_workspace_published_port_public(published_port)
|
||||
for published_port in service.published_ports
|
||||
],
|
||||
},
|
||||
)
|
||||
return self._serialize_workspace_service(service)
|
||||
|
||||
def list_services(self, workspace_id: str) -> dict[str, Any]:
|
||||
|
|
@ -5116,6 +5396,18 @@ class VmManager:
|
|||
workspace.firecracker_pid = instance.firecracker_pid
|
||||
workspace.last_error = instance.last_error
|
||||
workspace.metadata = dict(instance.metadata)
|
||||
self._touch_workspace_activity_locked(workspace)
|
||||
self._record_workspace_review_event_locked(
|
||||
workspace_id,
|
||||
event_kind="service_stop",
|
||||
payload={
|
||||
"service_name": service.service_name,
|
||||
"state": service.state,
|
||||
"exit_code": service.exit_code,
|
||||
"stop_reason": service.stop_reason,
|
||||
"ended_at": service.ended_at,
|
||||
},
|
||||
)
|
||||
self._save_workspace_locked(workspace)
|
||||
self._save_workspace_service_locked(service)
|
||||
return self._serialize_workspace_service(service)
|
||||
|
|
@ -5149,6 +5441,153 @@ class VmManager:
|
|||
"entries": redacted_entries,
|
||||
}
|
||||
|
||||
def summarize_workspace(self, workspace_id: str) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
workspace = self._load_workspace_locked(workspace_id)
|
||||
self._ensure_workspace_not_expired_locked(workspace, time.time())
|
||||
self._refresh_workspace_liveness_locked(workspace)
|
||||
self._refresh_workspace_service_counts_locked(workspace)
|
||||
self._save_workspace_locked(workspace)
|
||||
|
||||
command_entries = self._read_workspace_logs_locked(workspace.workspace_id)
|
||||
recent_commands = [
|
||||
{
|
||||
"sequence": int(entry["sequence"]),
|
||||
"command": str(entry["command"]),
|
||||
"cwd": str(entry["cwd"]),
|
||||
"exit_code": int(entry["exit_code"]),
|
||||
"duration_ms": int(entry["duration_ms"]),
|
||||
"execution_mode": str(entry["execution_mode"]),
|
||||
"recorded_at": float(entry["recorded_at"]),
|
||||
}
|
||||
for entry in command_entries[-5:]
|
||||
]
|
||||
recent_commands.reverse()
|
||||
|
||||
review_events = self._list_workspace_review_events_locked(workspace.workspace_id)
|
||||
|
||||
def _recent_events(
|
||||
kinds: set[WorkspaceReviewEventKind],
|
||||
*,
|
||||
limit: int = 5,
|
||||
) -> list[dict[str, Any]]:
|
||||
matched = [
|
||||
{
|
||||
"event_kind": event.event_kind,
|
||||
"recorded_at": event.recorded_at,
|
||||
**event.payload,
|
||||
}
|
||||
for event in review_events
|
||||
if event.event_kind in kinds
|
||||
]
|
||||
matched = matched[-limit:]
|
||||
matched.reverse()
|
||||
return matched
|
||||
|
||||
current_services = [
|
||||
self._serialize_workspace_service(service)
|
||||
for service in self._list_workspace_services_locked(workspace.workspace_id)
|
||||
]
|
||||
current_services.sort(key=lambda item: str(item["service_name"]))
|
||||
try:
|
||||
snapshots = self._list_workspace_snapshots_locked(workspace)
|
||||
named_snapshot_count = max(len(snapshots) - 1, 0)
|
||||
except RuntimeError:
|
||||
named_snapshot_count = 0
|
||||
|
||||
service_count = len(current_services)
|
||||
running_service_count = sum(
|
||||
1 for service in current_services if service["state"] == "running"
|
||||
)
|
||||
execution_mode = str(workspace.metadata.get("execution_mode", "pending"))
|
||||
payload: dict[str, Any] = {
|
||||
"workspace_id": workspace.workspace_id,
|
||||
"name": workspace.name,
|
||||
"labels": dict(workspace.labels),
|
||||
"environment": workspace.environment,
|
||||
"state": workspace.state,
|
||||
"workspace_path": WORKSPACE_GUEST_PATH,
|
||||
"execution_mode": execution_mode,
|
||||
"last_activity_at": workspace.last_activity_at,
|
||||
"session_started_at": (
|
||||
workspace.last_reset_at
|
||||
if workspace.last_reset_at is not None
|
||||
else workspace.created_at
|
||||
),
|
||||
"outcome": {
|
||||
"command_count": workspace.command_count,
|
||||
"last_command": workspace.last_command,
|
||||
"service_count": service_count,
|
||||
"running_service_count": running_service_count,
|
||||
"export_count": sum(
|
||||
1 for event in review_events if event.event_kind == "workspace_export"
|
||||
),
|
||||
"snapshot_count": named_snapshot_count,
|
||||
"reset_count": workspace.reset_count,
|
||||
},
|
||||
"commands": {
|
||||
"total": workspace.command_count,
|
||||
"recent": recent_commands,
|
||||
},
|
||||
"edits": {
|
||||
"recent": _recent_events({"sync_push", "file_write", "patch_apply"}),
|
||||
},
|
||||
"services": {
|
||||
"current": current_services,
|
||||
"recent": _recent_events({"service_start", "service_stop"}),
|
||||
},
|
||||
"artifacts": {
|
||||
"exports": _recent_events({"workspace_export"}),
|
||||
},
|
||||
"snapshots": {
|
||||
"named_count": named_snapshot_count,
|
||||
"recent": _recent_events({"snapshot_create", "snapshot_delete"}),
|
||||
},
|
||||
}
|
||||
|
||||
if payload["state"] != "started":
|
||||
payload["changes"] = {
|
||||
"available": False,
|
||||
"reason": (
|
||||
f"workspace {workspace_id!r} must be in 'started' state before "
|
||||
"workspace_summary can compute current changes"
|
||||
),
|
||||
"changed": False,
|
||||
"summary": None,
|
||||
"entries": [],
|
||||
}
|
||||
return payload
|
||||
|
||||
try:
|
||||
diff_payload = self.diff_workspace(workspace_id)
|
||||
except Exception as exc:
|
||||
payload["changes"] = {
|
||||
"available": False,
|
||||
"reason": str(exc),
|
||||
"changed": False,
|
||||
"summary": None,
|
||||
"entries": [],
|
||||
}
|
||||
return payload
|
||||
|
||||
diff_entries: list[dict[str, Any]] = []
|
||||
raw_entries = diff_payload.get("entries")
|
||||
if isinstance(raw_entries, list):
|
||||
for entry in raw_entries[:10]:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
diff_entries.append(
|
||||
{key: value for key, value in entry.items() if key != "text_patch"}
|
||||
)
|
||||
payload["changes"] = {
|
||||
"available": True,
|
||||
"reason": None,
|
||||
"changed": bool(diff_payload.get("changed", False)),
|
||||
"summary": diff_payload.get("summary"),
|
||||
"entries": diff_entries,
|
||||
}
|
||||
return payload
|
||||
|
||||
def stop_workspace(self, workspace_id: str) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
workspace = self._load_workspace_locked(workspace_id)
|
||||
|
|
@ -5196,9 +5635,7 @@ class VmManager:
|
|||
self._stop_workspace_services_locked(workspace, instance)
|
||||
self._close_workspace_shells_locked(workspace, instance)
|
||||
try:
|
||||
self._require_workspace_network_policy_support(
|
||||
network_policy=workspace.network_policy
|
||||
)
|
||||
self._require_workspace_network_policy_support(network_policy=workspace.network_policy)
|
||||
if self._runtime_capabilities.supports_guest_exec:
|
||||
self._ensure_workspace_guest_bootstrap_support(instance)
|
||||
with self._lock:
|
||||
|
|
@ -5381,9 +5818,7 @@ class VmManager:
|
|||
"execution_mode": workspace.metadata.get("execution_mode", "pending"),
|
||||
"workspace_path": WORKSPACE_GUEST_PATH,
|
||||
"workspace_seed": _workspace_seed_dict(workspace.workspace_seed),
|
||||
"secrets": [
|
||||
_serialize_workspace_secret_public(secret) for secret in workspace.secrets
|
||||
],
|
||||
"secrets": [_serialize_workspace_secret_public(secret) for secret in workspace.secrets],
|
||||
"command_count": workspace.command_count,
|
||||
"last_command": workspace.last_command,
|
||||
"reset_count": workspace.reset_count,
|
||||
|
|
@ -5554,9 +5989,7 @@ class VmManager:
|
|||
env_values: dict[str, str] = {}
|
||||
for secret_name, env_name in secret_env.items():
|
||||
if secret_name not in secret_values:
|
||||
raise ValueError(
|
||||
f"secret_env references unknown workspace secret {secret_name!r}"
|
||||
)
|
||||
raise ValueError(f"secret_env references unknown workspace secret {secret_name!r}")
|
||||
env_values[env_name] = secret_values[secret_name]
|
||||
return env_values
|
||||
|
||||
|
|
@ -5663,12 +6096,30 @@ class VmManager:
|
|||
execution_mode = instance.metadata.get("execution_mode", "unknown")
|
||||
return exec_result, execution_mode
|
||||
|
||||
def _prepare_workspace_seed(self, seed_path: str | Path | None) -> PreparedWorkspaceSeed:
|
||||
def _prepare_workspace_seed(
|
||||
self,
|
||||
seed_path: str | Path | None,
|
||||
*,
|
||||
origin_kind: WorkspaceSeedOriginKind | None = None,
|
||||
origin_ref: str | None = None,
|
||||
) -> PreparedWorkspaceSeed:
|
||||
if seed_path is None:
|
||||
return PreparedWorkspaceSeed(mode="empty", source_path=None)
|
||||
return PreparedWorkspaceSeed(
|
||||
mode="empty",
|
||||
source_path=None,
|
||||
origin_kind="empty" if origin_kind is None else origin_kind,
|
||||
origin_ref=origin_ref,
|
||||
)
|
||||
resolved_source_path = Path(seed_path).expanduser().resolve()
|
||||
if not resolved_source_path.exists():
|
||||
raise ValueError(f"seed_path {resolved_source_path} does not exist")
|
||||
effective_origin_kind: WorkspaceSeedOriginKind = (
|
||||
"manual_seed_path" if origin_kind is None else origin_kind
|
||||
)
|
||||
effective_origin_ref = str(resolved_source_path) if origin_ref is None else origin_ref
|
||||
public_source_path = (
|
||||
None if effective_origin_kind == "repo_url" else str(resolved_source_path)
|
||||
)
|
||||
if resolved_source_path.is_dir():
|
||||
cleanup_dir = Path(tempfile.mkdtemp(prefix="pyro-workspace-seed-"))
|
||||
archive_path = cleanup_dir / "workspace-seed.tar"
|
||||
|
|
@ -5680,23 +6131,24 @@ class VmManager:
|
|||
raise
|
||||
return PreparedWorkspaceSeed(
|
||||
mode="directory",
|
||||
source_path=str(resolved_source_path),
|
||||
source_path=public_source_path,
|
||||
origin_kind=effective_origin_kind,
|
||||
origin_ref=effective_origin_ref,
|
||||
archive_path=archive_path,
|
||||
entry_count=entry_count,
|
||||
bytes_written=bytes_written,
|
||||
cleanup_dir=cleanup_dir,
|
||||
)
|
||||
if (
|
||||
not resolved_source_path.is_file()
|
||||
or not _is_supported_seed_archive(resolved_source_path)
|
||||
if not resolved_source_path.is_file() or not _is_supported_seed_archive(
|
||||
resolved_source_path
|
||||
):
|
||||
raise ValueError(
|
||||
"seed_path must be a directory or a .tar/.tar.gz/.tgz archive"
|
||||
)
|
||||
raise ValueError("seed_path must be a directory or a .tar/.tar.gz/.tgz archive")
|
||||
entry_count, bytes_written = _inspect_seed_archive(resolved_source_path)
|
||||
return PreparedWorkspaceSeed(
|
||||
mode="tar_archive",
|
||||
source_path=str(resolved_source_path),
|
||||
source_path=public_source_path,
|
||||
origin_kind=effective_origin_kind,
|
||||
origin_ref=effective_origin_ref,
|
||||
archive_path=resolved_source_path,
|
||||
entry_count=entry_count,
|
||||
bytes_written=bytes_written,
|
||||
|
|
@ -5757,6 +6209,9 @@ class VmManager:
|
|||
def _workspace_commands_dir(self, workspace_id: str) -> Path:
|
||||
return self._workspace_dir(workspace_id) / WORKSPACE_COMMANDS_DIRNAME
|
||||
|
||||
def _workspace_review_dir(self, workspace_id: str) -> Path:
|
||||
return self._workspace_dir(workspace_id) / WORKSPACE_REVIEW_DIRNAME
|
||||
|
||||
def _workspace_shells_dir(self, workspace_id: str) -> Path:
|
||||
return self._workspace_dir(workspace_id) / WORKSPACE_SHELLS_DIRNAME
|
||||
|
||||
|
|
@ -5772,6 +6227,9 @@ class VmManager:
|
|||
def _workspace_shell_record_path(self, workspace_id: str, shell_id: str) -> Path:
|
||||
return self._workspace_shells_dir(workspace_id) / f"{shell_id}.json"
|
||||
|
||||
def _workspace_review_record_path(self, workspace_id: str, event_id: str) -> Path:
|
||||
return self._workspace_review_dir(workspace_id) / f"{event_id}.json"
|
||||
|
||||
def _workspace_service_record_path(self, workspace_id: str, service_name: str) -> Path:
|
||||
return self._workspace_services_dir(workspace_id) / f"{service_name}.json"
|
||||
|
||||
|
|
@ -5787,8 +6245,7 @@ class VmManager:
|
|||
rootfs_path = Path(raw_rootfs_image)
|
||||
if not rootfs_path.exists():
|
||||
raise RuntimeError(
|
||||
f"workspace {workspace.workspace_id!r} rootfs image is unavailable at "
|
||||
f"{rootfs_path}"
|
||||
f"workspace {workspace.workspace_id!r} rootfs image is unavailable at {rootfs_path}"
|
||||
)
|
||||
return rootfs_path
|
||||
|
||||
|
|
@ -5805,9 +6262,7 @@ class VmManager:
|
|||
f"workspace {workspace.workspace_id!r} must be stopped before {operation_name}"
|
||||
)
|
||||
if workspace.metadata.get("execution_mode") == "host_compat":
|
||||
raise RuntimeError(
|
||||
f"{operation_name} is unavailable for host_compat workspaces"
|
||||
)
|
||||
raise RuntimeError(f"{operation_name} is unavailable for host_compat workspaces")
|
||||
return self._workspace_rootfs_image_path_locked(workspace)
|
||||
|
||||
def _scrub_workspace_runtime_state_locked(
|
||||
|
|
@ -5966,6 +6421,46 @@ class VmManager:
|
|||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
def _record_workspace_review_event_locked(
|
||||
self,
|
||||
workspace_id: str,
|
||||
*,
|
||||
event_kind: WorkspaceReviewEventKind,
|
||||
payload: dict[str, Any],
|
||||
when: float | None = None,
|
||||
) -> WorkspaceReviewEventRecord:
|
||||
recorded_at = time.time() if when is None else when
|
||||
event = WorkspaceReviewEventRecord(
|
||||
workspace_id=workspace_id,
|
||||
event_kind=event_kind,
|
||||
recorded_at=recorded_at,
|
||||
payload=dict(payload),
|
||||
)
|
||||
event_id = f"{int(recorded_at * 1_000_000_000):020d}-{uuid.uuid4().hex[:8]}"
|
||||
record_path = self._workspace_review_record_path(workspace_id, event_id)
|
||||
record_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
record_path.write_text(
|
||||
json.dumps(event.to_payload(), indent=2, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return event
|
||||
|
||||
def _list_workspace_review_events_locked(
|
||||
self,
|
||||
workspace_id: str,
|
||||
) -> list[WorkspaceReviewEventRecord]:
|
||||
review_dir = self._workspace_review_dir(workspace_id)
|
||||
if not review_dir.exists():
|
||||
return []
|
||||
events: list[WorkspaceReviewEventRecord] = []
|
||||
for record_path in sorted(review_dir.glob("*.json")):
|
||||
payload = json.loads(record_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
events.append(WorkspaceReviewEventRecord.from_payload(payload))
|
||||
events.sort(key=lambda item: (item.recorded_at, item.event_kind))
|
||||
return events
|
||||
|
||||
def _workspace_instance_for_live_shell_locked(self, workspace: WorkspaceRecord) -> VmInstance:
|
||||
instance = self._workspace_instance_for_live_operation_locked(
|
||||
workspace,
|
||||
|
|
@ -6322,10 +6817,12 @@ class VmManager:
|
|||
shutil.rmtree(self._workspace_runtime_dir(workspace_id), ignore_errors=True)
|
||||
shutil.rmtree(self._workspace_host_dir(workspace_id), ignore_errors=True)
|
||||
shutil.rmtree(self._workspace_commands_dir(workspace_id), ignore_errors=True)
|
||||
shutil.rmtree(self._workspace_review_dir(workspace_id), ignore_errors=True)
|
||||
shutil.rmtree(self._workspace_shells_dir(workspace_id), ignore_errors=True)
|
||||
shutil.rmtree(self._workspace_services_dir(workspace_id), ignore_errors=True)
|
||||
self._workspace_host_dir(workspace_id).mkdir(parents=True, exist_ok=True)
|
||||
self._workspace_commands_dir(workspace_id).mkdir(parents=True, exist_ok=True)
|
||||
self._workspace_review_dir(workspace_id).mkdir(parents=True, exist_ok=True)
|
||||
self._workspace_shells_dir(workspace_id).mkdir(parents=True, exist_ok=True)
|
||||
self._workspace_services_dir(workspace_id).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -28,7 +29,7 @@ USE_CASE_CHOICES: Final[tuple[str, ...]] = USE_CASE_SCENARIOS + (USE_CASE_ALL_SC
|
|||
class WorkspaceUseCaseRecipe:
|
||||
scenario: str
|
||||
title: str
|
||||
profile: Literal["workspace-core", "workspace-full"]
|
||||
mode: Literal["repro-fix", "inspect", "cold-start", "review-eval"]
|
||||
smoke_target: str
|
||||
doc_path: str
|
||||
summary: str
|
||||
|
|
@ -38,7 +39,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
|
|||
WorkspaceUseCaseRecipe(
|
||||
scenario="cold-start-validation",
|
||||
title="Cold-Start Repo Validation",
|
||||
profile="workspace-full",
|
||||
mode="cold-start",
|
||||
smoke_target="smoke-cold-start-validation",
|
||||
doc_path="docs/use-cases/cold-start-repo-validation.md",
|
||||
summary=(
|
||||
|
|
@ -49,7 +50,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
|
|||
WorkspaceUseCaseRecipe(
|
||||
scenario="repro-fix-loop",
|
||||
title="Repro Plus Fix Loop",
|
||||
profile="workspace-core",
|
||||
mode="repro-fix",
|
||||
smoke_target="smoke-repro-fix-loop",
|
||||
doc_path="docs/use-cases/repro-fix-loop.md",
|
||||
summary=(
|
||||
|
|
@ -60,7 +61,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
|
|||
WorkspaceUseCaseRecipe(
|
||||
scenario="parallel-workspaces",
|
||||
title="Parallel Isolated Workspaces",
|
||||
profile="workspace-core",
|
||||
mode="repro-fix",
|
||||
smoke_target="smoke-parallel-workspaces",
|
||||
doc_path="docs/use-cases/parallel-workspaces.md",
|
||||
summary=(
|
||||
|
|
@ -71,7 +72,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
|
|||
WorkspaceUseCaseRecipe(
|
||||
scenario="untrusted-inspection",
|
||||
title="Unsafe Or Untrusted Code Inspection",
|
||||
profile="workspace-core",
|
||||
mode="inspect",
|
||||
smoke_target="smoke-untrusted-inspection",
|
||||
doc_path="docs/use-cases/untrusted-inspection.md",
|
||||
summary=(
|
||||
|
|
@ -82,7 +83,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
|
|||
WorkspaceUseCaseRecipe(
|
||||
scenario="review-eval",
|
||||
title="Review And Evaluation Workflows",
|
||||
profile="workspace-full",
|
||||
mode="review-eval",
|
||||
smoke_target="smoke-review-eval",
|
||||
doc_path="docs/use-cases/review-eval-workflows.md",
|
||||
summary=(
|
||||
|
|
@ -107,6 +108,15 @@ def _log(message: str) -> None:
|
|||
print(f"[smoke] {message}", flush=True)
|
||||
|
||||
|
||||
def _extract_structured_tool_result(raw_result: object) -> dict[str, object]:
|
||||
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
|
||||
raise TypeError("unexpected MCP tool result shape")
|
||||
_, structured = raw_result
|
||||
if not isinstance(structured, dict):
|
||||
raise TypeError("expected structured dictionary result")
|
||||
return structured
|
||||
|
||||
|
||||
def _create_workspace(
|
||||
pyro: Pyro,
|
||||
*,
|
||||
|
|
@ -126,6 +136,31 @@ def _create_workspace(
|
|||
return str(created["workspace_id"])
|
||||
|
||||
|
||||
def _create_project_aware_workspace(
|
||||
pyro: Pyro,
|
||||
*,
|
||||
environment: str,
|
||||
project_path: Path,
|
||||
mode: Literal["repro-fix", "cold-start"],
|
||||
name: str,
|
||||
labels: dict[str, str],
|
||||
) -> dict[str, object]:
|
||||
async def _run() -> dict[str, object]:
|
||||
server = pyro.create_server(mode=mode, project_path=project_path)
|
||||
return _extract_structured_tool_result(
|
||||
await server.call_tool(
|
||||
"workspace_create",
|
||||
{
|
||||
"environment": environment,
|
||||
"name": name,
|
||||
"labels": labels,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
|
||||
def _safe_delete_workspace(pyro: Pyro, workspace_id: str | None) -> None:
|
||||
if workspace_id is None:
|
||||
return
|
||||
|
|
@ -160,14 +195,19 @@ def _scenario_cold_start_validation(pyro: Pyro, *, root: Path, environment: str)
|
|||
)
|
||||
workspace_id: str | None = None
|
||||
try:
|
||||
workspace_id = _create_workspace(
|
||||
created = _create_project_aware_workspace(
|
||||
pyro,
|
||||
environment=environment,
|
||||
seed_path=seed_dir,
|
||||
project_path=seed_dir,
|
||||
mode="cold-start",
|
||||
name="cold-start-validation",
|
||||
labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "cold-start-validation"},
|
||||
)
|
||||
workspace_id = str(created["workspace_id"])
|
||||
_log(f"cold-start-validation workspace_id={workspace_id}")
|
||||
workspace_seed = created["workspace_seed"]
|
||||
assert isinstance(workspace_seed, dict), created
|
||||
assert workspace_seed["origin_kind"] == "project_path", created
|
||||
validation = pyro.exec_workspace(workspace_id, command="sh validate.sh")
|
||||
assert int(validation["exit_code"]) == 0, validation
|
||||
assert str(validation["stdout"]) == "validated\n", validation
|
||||
|
|
@ -221,14 +261,20 @@ def _scenario_repro_fix_loop(pyro: Pyro, *, root: Path, environment: str) -> Non
|
|||
)
|
||||
workspace_id: str | None = None
|
||||
try:
|
||||
workspace_id = _create_workspace(
|
||||
created = _create_project_aware_workspace(
|
||||
pyro,
|
||||
environment=environment,
|
||||
seed_path=seed_dir,
|
||||
project_path=seed_dir,
|
||||
mode="repro-fix",
|
||||
name="repro-fix-loop",
|
||||
labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "repro-fix-loop"},
|
||||
)
|
||||
workspace_id = str(created["workspace_id"])
|
||||
_log(f"repro-fix-loop workspace_id={workspace_id}")
|
||||
workspace_seed = created["workspace_seed"]
|
||||
assert isinstance(workspace_seed, dict), created
|
||||
assert workspace_seed["origin_kind"] == "project_path", created
|
||||
assert workspace_seed["origin_ref"] == str(seed_dir.resolve()), created
|
||||
initial_read = pyro.read_workspace_file(workspace_id, "message.txt")
|
||||
assert str(initial_read["content"]) == "broken\n", initial_read
|
||||
failing = pyro.exec_workspace(workspace_id, command="sh check.sh")
|
||||
|
|
@ -418,6 +464,11 @@ def _scenario_review_eval(pyro: Pyro, *, root: Path, environment: str) -> None:
|
|||
assert int(rerun["exit_code"]) == 0, rerun
|
||||
pyro.export_workspace(workspace_id, "review-report.txt", output_path=export_path)
|
||||
assert export_path.read_text(encoding="utf-8") == "review=pass\n"
|
||||
summary = pyro.summarize_workspace(workspace_id)
|
||||
assert summary["workspace_id"] == workspace_id, summary
|
||||
assert summary["changes"]["available"] is True, summary
|
||||
assert summary["artifacts"]["exports"], summary
|
||||
assert summary["snapshots"]["named_count"] >= 1, summary
|
||||
finally:
|
||||
if shell_id is not None and workspace_id is not None:
|
||||
try:
|
||||
|
|
@ -451,7 +502,7 @@ def run_workspace_use_case_scenario(
|
|||
scenario_names = USE_CASE_SCENARIOS if scenario == USE_CASE_ALL_SCENARIO else (scenario,)
|
||||
for scenario_name in scenario_names:
|
||||
recipe = _RECIPE_BY_SCENARIO[scenario_name]
|
||||
_log(f"starting {recipe.scenario} ({recipe.title}) profile={recipe.profile}")
|
||||
_log(f"starting {recipe.scenario} ({recipe.title}) mode={recipe.mode}")
|
||||
scenario_root = root / scenario_name
|
||||
scenario_root.mkdir(parents=True, exist_ok=True)
|
||||
runner = _SCENARIO_RUNNERS[scenario_name]
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from pyro_mcp.api import Pyro
|
||||
from pyro_mcp.contract import (
|
||||
PUBLIC_MCP_COLD_START_MODE_TOOLS,
|
||||
PUBLIC_MCP_INSPECT_MODE_TOOLS,
|
||||
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
|
||||
PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS,
|
||||
PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
|
||||
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
|
||||
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS,
|
||||
|
|
@ -15,6 +22,28 @@ from pyro_mcp.vm_manager import VmManager
|
|||
from pyro_mcp.vm_network import TapNetworkManager
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
result = subprocess.run( # noqa: S603
|
||||
["git", "-c", "commit.gpgsign=false", *args],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _make_repo(root: Path, *, content: str = "hello\n") -> Path:
|
||||
root.mkdir()
|
||||
_git(root, "init")
|
||||
_git(root, "config", "user.name", "Pyro Tests")
|
||||
_git(root, "config", "user.email", "pyro-tests@example.com")
|
||||
(root / "note.txt").write_text(content, encoding="utf-8")
|
||||
_git(root, "add", "note.txt")
|
||||
_git(root, "commit", "-m", "init")
|
||||
return root
|
||||
|
||||
|
||||
def test_pyro_run_in_vm_delegates_to_manager(tmp_path: Path) -> None:
|
||||
pyro = Pyro(
|
||||
manager=VmManager(
|
||||
|
|
@ -134,6 +163,172 @@ def test_pyro_create_server_workspace_core_profile_registers_expected_tools_and_
|
|||
assert "workspace_disk_export" not in tool_map
|
||||
|
||||
|
||||
def test_pyro_create_server_repro_fix_mode_registers_expected_tools_and_schemas(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
pyro = Pyro(
|
||||
manager=VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
)
|
||||
|
||||
async def _run() -> tuple[list[str], dict[str, dict[str, Any]]]:
|
||||
server = pyro.create_server(mode="repro-fix")
|
||||
tools = await server.list_tools()
|
||||
tool_map = {tool.name: tool.model_dump() for tool in tools}
|
||||
return sorted(tool_map), tool_map
|
||||
|
||||
tool_names, tool_map = asyncio.run(_run())
|
||||
assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_REPRO_FIX_MODE_TOOLS))
|
||||
create_properties = tool_map["workspace_create"]["inputSchema"]["properties"]
|
||||
assert "network_policy" not in create_properties
|
||||
assert "secrets" not in create_properties
|
||||
exec_properties = tool_map["workspace_exec"]["inputSchema"]["properties"]
|
||||
assert "secret_env" not in exec_properties
|
||||
assert "service_start" not in tool_map
|
||||
assert "shell_open" not in tool_map
|
||||
assert "snapshot_create" not in tool_map
|
||||
assert "reproduce a failure" in str(tool_map["workspace_create"]["description"])
|
||||
|
||||
|
||||
def test_pyro_create_server_cold_start_mode_registers_expected_tools_and_schemas(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
pyro = Pyro(
|
||||
manager=VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
)
|
||||
|
||||
async def _run() -> tuple[list[str], dict[str, dict[str, Any]]]:
|
||||
server = pyro.create_server(mode="cold-start")
|
||||
tools = await server.list_tools()
|
||||
tool_map = {tool.name: tool.model_dump() for tool in tools}
|
||||
return sorted(tool_map), tool_map
|
||||
|
||||
tool_names, tool_map = asyncio.run(_run())
|
||||
assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_COLD_START_MODE_TOOLS))
|
||||
assert "shell_open" not in tool_map
|
||||
assert "snapshot_create" not in tool_map
|
||||
service_start_properties = tool_map["service_start"]["inputSchema"]["properties"]
|
||||
assert "secret_env" not in service_start_properties
|
||||
assert "published_ports" not in service_start_properties
|
||||
create_properties = tool_map["workspace_create"]["inputSchema"]["properties"]
|
||||
assert "network_policy" not in create_properties
|
||||
|
||||
|
||||
def test_pyro_create_server_review_eval_mode_registers_expected_tools_and_schemas(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
pyro = Pyro(
|
||||
manager=VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
)
|
||||
|
||||
async def _run() -> tuple[list[str], dict[str, dict[str, Any]]]:
|
||||
server = pyro.create_server(mode="review-eval")
|
||||
tools = await server.list_tools()
|
||||
tool_map = {tool.name: tool.model_dump() for tool in tools}
|
||||
return sorted(tool_map), tool_map
|
||||
|
||||
tool_names, tool_map = asyncio.run(_run())
|
||||
assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS))
|
||||
assert "service_start" not in tool_map
|
||||
assert "shell_open" in tool_map
|
||||
assert "snapshot_create" in tool_map
|
||||
shell_open_properties = tool_map["shell_open"]["inputSchema"]["properties"]
|
||||
assert "secret_env" not in shell_open_properties
|
||||
|
||||
|
||||
def test_pyro_create_server_inspect_mode_registers_expected_tools(tmp_path: Path) -> None:
|
||||
pyro = Pyro(
|
||||
manager=VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
)
|
||||
|
||||
async def _run() -> list[str]:
|
||||
server = pyro.create_server(mode="inspect")
|
||||
tools = await server.list_tools()
|
||||
return sorted(tool.name for tool in tools)
|
||||
|
||||
assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_INSPECT_MODE_TOOLS))
|
||||
|
||||
|
||||
def test_pyro_create_server_rejects_mode_and_non_default_profile(tmp_path: Path) -> None:
|
||||
pyro = Pyro(
|
||||
manager=VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="mutually exclusive"):
|
||||
pyro.create_server(profile="workspace-full", mode="repro-fix")
|
||||
|
||||
|
||||
def test_pyro_create_server_project_path_updates_workspace_create_description_and_default_seed(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repo = _make_repo(tmp_path / "repo", content="project-aware\n")
|
||||
pyro = Pyro(
|
||||
manager=VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
)
|
||||
|
||||
def _extract_structured(raw_result: object) -> dict[str, Any]:
|
||||
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
|
||||
raise TypeError("unexpected call_tool result shape")
|
||||
_, structured = raw_result
|
||||
if not isinstance(structured, dict):
|
||||
raise TypeError("expected structured dictionary result")
|
||||
return cast(dict[str, Any], structured)
|
||||
|
||||
async def _run() -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
||||
server = pyro.create_server(project_path=repo)
|
||||
tools = await server.list_tools()
|
||||
tool_map = {tool.name: tool.model_dump() for tool in tools}
|
||||
created = _extract_structured(
|
||||
await server.call_tool(
|
||||
"workspace_create",
|
||||
{
|
||||
"environment": "debian:12-base",
|
||||
"allow_host_compat": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
executed = _extract_structured(
|
||||
await server.call_tool(
|
||||
"workspace_exec",
|
||||
{
|
||||
"workspace_id": created["workspace_id"],
|
||||
"command": "cat note.txt",
|
||||
},
|
||||
)
|
||||
)
|
||||
return tool_map["workspace_create"], created, executed
|
||||
|
||||
workspace_create_tool, created, executed = asyncio.run(_run())
|
||||
assert "If `seed_path` is omitted" in str(workspace_create_tool["description"])
|
||||
assert str(repo.resolve()) in str(workspace_create_tool["description"])
|
||||
assert created["workspace_seed"]["origin_kind"] == "project_path"
|
||||
assert created["workspace_seed"]["origin_ref"] == str(repo.resolve())
|
||||
assert executed["stdout"] == "project-aware\n"
|
||||
|
||||
|
||||
def test_pyro_vm_run_tool_executes(tmp_path: Path) -> None:
|
||||
pyro = Pyro(
|
||||
manager=VmManager(
|
||||
|
|
@ -380,6 +575,7 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
|
|||
services = pyro.list_services(workspace_id)
|
||||
service_status = pyro.status_service(workspace_id, "app")
|
||||
service_logs = pyro.logs_service(workspace_id, "app", all=True)
|
||||
summary = pyro.summarize_workspace(workspace_id)
|
||||
reset = pyro.reset_workspace(workspace_id, snapshot="checkpoint")
|
||||
deleted_snapshot = pyro.delete_snapshot(workspace_id, "checkpoint")
|
||||
status = pyro.status_workspace(workspace_id)
|
||||
|
|
@ -416,6 +612,9 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
|
|||
assert service_status["state"] == "running"
|
||||
assert service_logs["stderr"].count("[REDACTED]") >= 1
|
||||
assert service_logs["tail_lines"] is None
|
||||
assert summary["workspace_id"] == workspace_id
|
||||
assert summary["commands"]["total"] >= 1
|
||||
assert summary["changes"]["available"] is True
|
||||
assert reset["workspace_reset"]["snapshot_name"] == "checkpoint"
|
||||
assert reset["secrets"] == created["secrets"]
|
||||
assert deleted_snapshot["deleted"] is True
|
||||
|
|
@ -979,6 +1178,14 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
|
|||
calls.append(("logs_workspace", {"workspace_id": workspace_id}))
|
||||
return {"workspace_id": workspace_id, "count": 0, "entries": []}
|
||||
|
||||
def summarize_workspace(self, workspace_id: str) -> dict[str, Any]:
|
||||
calls.append(("summarize_workspace", {"workspace_id": workspace_id}))
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"state": "started",
|
||||
"changes": {"available": True, "changed": False, "summary": None, "entries": []},
|
||||
}
|
||||
|
||||
def open_shell(
|
||||
self,
|
||||
workspace_id: str,
|
||||
|
|
@ -1110,6 +1317,9 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
|
|||
status = _extract_structured(
|
||||
await server.call_tool("workspace_status", {"workspace_id": "workspace-123"})
|
||||
)
|
||||
summary = _extract_structured(
|
||||
await server.call_tool("workspace_summary", {"workspace_id": "workspace-123"})
|
||||
)
|
||||
logs = _extract_structured(
|
||||
await server.call_tool("workspace_logs", {"workspace_id": "workspace-123"})
|
||||
)
|
||||
|
|
@ -1211,6 +1421,7 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
|
|||
)
|
||||
return (
|
||||
status,
|
||||
summary,
|
||||
logs,
|
||||
opened,
|
||||
read,
|
||||
|
|
@ -1225,13 +1436,15 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
|
|||
|
||||
results = asyncio.run(_run())
|
||||
assert results[0]["state"] == "started"
|
||||
assert results[1]["count"] == 0
|
||||
assert results[2]["shell_id"] == "shell-1"
|
||||
assert results[6]["closed"] is True
|
||||
assert results[7]["state"] == "running"
|
||||
assert results[10]["state"] == "running"
|
||||
assert results[1]["workspace_id"] == "workspace-123"
|
||||
assert results[2]["count"] == 0
|
||||
assert results[3]["shell_id"] == "shell-1"
|
||||
assert results[7]["closed"] is True
|
||||
assert results[8]["state"] == "running"
|
||||
assert results[11]["state"] == "running"
|
||||
assert calls == [
|
||||
("status_workspace", {"workspace_id": "workspace-123"}),
|
||||
("summarize_workspace", {"workspace_id": "workspace-123"}),
|
||||
("logs_workspace", {"workspace_id": "workspace-123"}),
|
||||
(
|
||||
"open_shell",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from typing import Any, cast
|
|||
import pytest
|
||||
|
||||
import pyro_mcp.cli as cli
|
||||
from pyro_mcp.host_helpers import HostDoctorEntry
|
||||
|
||||
|
||||
def _subparser_choice(parser: argparse.ArgumentParser, name: str) -> argparse.ArgumentParser:
|
||||
|
|
@ -26,17 +27,24 @@ def test_cli_help_guides_first_run() -> None:
|
|||
parser = cli._build_parser()
|
||||
help_text = parser.format_help()
|
||||
|
||||
assert "Suggested first run:" in help_text
|
||||
assert "Suggested zero-to-hero path:" in help_text
|
||||
assert "pyro doctor" in help_text
|
||||
assert "pyro env list" in help_text
|
||||
assert "pyro env pull debian:12" in help_text
|
||||
assert "pyro prepare debian:12" in help_text
|
||||
assert "pyro run debian:12 -- git --version" in help_text
|
||||
assert "Continue into the stable workspace path after that:" in help_text
|
||||
assert "pyro host connect claude-code" in help_text
|
||||
assert "Connect a chat host after that:" in help_text
|
||||
assert "pyro host connect claude-code" in help_text
|
||||
assert "pyro host connect codex" in help_text
|
||||
assert "pyro host print-config opencode" in help_text
|
||||
assert "Daily local loop after the first warmup:" in help_text
|
||||
assert "pyro doctor --environment debian:12" in help_text
|
||||
assert "pyro workspace reset WORKSPACE_ID" in help_text
|
||||
assert "If you want terminal-level visibility into the workspace model:" in help_text
|
||||
assert "pyro workspace exec WORKSPACE_ID -- cat note.txt" in help_text
|
||||
assert "pyro workspace summary WORKSPACE_ID" in help_text
|
||||
assert "pyro workspace snapshot create WORKSPACE_ID checkpoint" in help_text
|
||||
assert "pyro workspace reset WORKSPACE_ID --snapshot checkpoint" in help_text
|
||||
assert "pyro workspace sync push WORKSPACE_ID ./changes" in help_text
|
||||
assert "Use `pyro mcp serve` only after the CLI validation path works." in help_text
|
||||
|
||||
|
||||
def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
||||
|
|
@ -54,8 +62,41 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
assert "pyro env pull debian:12" in env_help
|
||||
assert "downloads from public Docker Hub" in env_help
|
||||
|
||||
prepare_help = _subparser_choice(parser, "prepare").format_help()
|
||||
assert "Warm the recommended guest-backed daily loop" in prepare_help
|
||||
assert "pyro prepare debian:12 --network" in prepare_help
|
||||
assert "--network" in prepare_help
|
||||
assert "--force" in prepare_help
|
||||
|
||||
host_help = _subparser_choice(parser, "host").format_help()
|
||||
assert "Connect or repair the supported Claude Code, Codex, and OpenCode" in host_help
|
||||
assert "pyro host connect claude-code" in host_help
|
||||
assert "pyro host repair opencode" in host_help
|
||||
|
||||
host_connect_help = _subparser_choice(
|
||||
_subparser_choice(parser, "host"), "connect"
|
||||
).format_help()
|
||||
assert "--installed-package" in host_connect_help
|
||||
assert "--project-path" in host_connect_help
|
||||
assert "--repo-url" in host_connect_help
|
||||
assert "--repo-ref" in host_connect_help
|
||||
assert "--no-project-source" in host_connect_help
|
||||
|
||||
host_print_config_help = _subparser_choice(
|
||||
_subparser_choice(parser, "host"), "print-config"
|
||||
).format_help()
|
||||
assert "--output" in host_print_config_help
|
||||
|
||||
host_doctor_help = _subparser_choice(_subparser_choice(parser, "host"), "doctor").format_help()
|
||||
assert "--config-path" in host_doctor_help
|
||||
|
||||
host_repair_help = _subparser_choice(_subparser_choice(parser, "host"), "repair").format_help()
|
||||
assert "--config-path" in host_repair_help
|
||||
|
||||
doctor_help = _subparser_choice(parser, "doctor").format_help()
|
||||
assert "Check host prerequisites and embedded runtime health" in doctor_help
|
||||
assert "--environment" in doctor_help
|
||||
assert "pyro doctor --environment debian:12" in doctor_help
|
||||
assert "pyro doctor --json" in doctor_help
|
||||
|
||||
demo_help = _subparser_choice(parser, "demo").format_help()
|
||||
|
|
@ -69,17 +110,22 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
assert "vm-run" in mcp_help
|
||||
assert "recommended first profile for most chat hosts" in mcp_help
|
||||
assert "workspace-core: default for normal persistent chat editing" in mcp_help
|
||||
assert "workspace-full: advanced 4.x opt-in surface" in mcp_help
|
||||
assert "workspace-full: larger opt-in surface" in mcp_help
|
||||
assert "--project-path" in mcp_help
|
||||
assert "--repo-url" in mcp_help
|
||||
assert "--repo-ref" in mcp_help
|
||||
assert "--no-project-source" in mcp_help
|
||||
assert "pyro mcp serve --project-path ." in mcp_help
|
||||
assert "pyro mcp serve --repo-url https://github.com/example/project.git" in mcp_help
|
||||
|
||||
workspace_help = _subparser_choice(parser, "workspace").format_help()
|
||||
assert "stable workspace contract" in workspace_help
|
||||
assert "Use the workspace model when you need one sandbox to stay alive" in workspace_help
|
||||
assert "pyro workspace create debian:12 --seed-path ./repo" in workspace_help
|
||||
assert "--id-only" in workspace_help
|
||||
assert "pyro workspace create debian:12 --name repro-fix --label issue=123" in workspace_help
|
||||
assert "pyro workspace list" in workspace_help
|
||||
assert (
|
||||
"pyro workspace update WORKSPACE_ID --name retry-run --label owner=codex"
|
||||
in workspace_help
|
||||
"pyro workspace update WORKSPACE_ID --name retry-run --label owner=codex" in workspace_help
|
||||
)
|
||||
assert "pyro workspace sync push WORKSPACE_ID ./repo --dest src" in workspace_help
|
||||
assert "pyro workspace exec WORKSPACE_ID" in workspace_help
|
||||
|
|
@ -91,6 +137,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
assert "pyro workspace start WORKSPACE_ID" in workspace_help
|
||||
assert "pyro workspace snapshot create WORKSPACE_ID checkpoint" in workspace_help
|
||||
assert "pyro workspace reset WORKSPACE_ID --snapshot checkpoint" in workspace_help
|
||||
assert "pyro workspace summary WORKSPACE_ID" in workspace_help
|
||||
assert "pyro workspace shell open WORKSPACE_ID --id-only" in workspace_help
|
||||
|
||||
workspace_create_help = _subparser_choice(
|
||||
|
|
@ -145,6 +192,12 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
assert "--label" in workspace_update_help
|
||||
assert "--clear-label" in workspace_update_help
|
||||
|
||||
workspace_summary_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "summary"
|
||||
).format_help()
|
||||
assert "Summarize the current workspace session since the last reset" in workspace_summary_help
|
||||
assert "pyro workspace summary WORKSPACE_ID" in workspace_summary_help
|
||||
|
||||
workspace_file_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "file"
|
||||
).format_help()
|
||||
|
|
@ -307,6 +360,94 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
assert "Close a persistent workspace shell" in workspace_shell_close_help
|
||||
|
||||
|
||||
def test_cli_host_connect_dispatch(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
pass
|
||||
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="host",
|
||||
host_command="connect",
|
||||
host="codex",
|
||||
installed_package=False,
|
||||
profile="workspace-core",
|
||||
project_path=None,
|
||||
repo_url=None,
|
||||
repo_ref=None,
|
||||
no_project_source=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
monkeypatch.setattr(
|
||||
cli,
|
||||
"connect_cli_host",
|
||||
lambda host, *, config: {
|
||||
"host": host,
|
||||
"server_command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"],
|
||||
"verification_command": ["codex", "mcp", "list"],
|
||||
},
|
||||
)
|
||||
|
||||
cli.main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == (
|
||||
"Connected pyro to codex.\n"
|
||||
"Server command: uvx --from pyro-mcp pyro mcp serve\n"
|
||||
"Verify with: codex mcp list\n"
|
||||
)
|
||||
assert captured.err == ""
|
||||
|
||||
|
||||
def test_cli_host_doctor_prints_human(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
pass
|
||||
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="host",
|
||||
host_command="doctor",
|
||||
installed_package=False,
|
||||
profile="workspace-core",
|
||||
project_path=None,
|
||||
repo_url=None,
|
||||
repo_ref=None,
|
||||
no_project_source=False,
|
||||
config_path=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
monkeypatch.setattr(
|
||||
cli,
|
||||
"doctor_hosts",
|
||||
lambda **_: [
|
||||
HostDoctorEntry(
|
||||
host="codex",
|
||||
installed=True,
|
||||
configured=False,
|
||||
status="missing",
|
||||
details="codex entry missing",
|
||||
repair_command="pyro host repair codex",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
cli.main()
|
||||
captured = capsys.readouterr()
|
||||
assert "codex: missing installed=yes configured=no" in captured.out
|
||||
assert "repair: pyro host repair codex" in captured.out
|
||||
assert captured.err == ""
|
||||
|
||||
|
||||
def test_cli_run_prints_json(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
|
|
@ -344,13 +485,22 @@ def test_cli_doctor_prints_json(
|
|||
) -> None:
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(command="doctor", platform="linux-x86_64", json=True)
|
||||
return argparse.Namespace(
|
||||
command="doctor",
|
||||
platform="linux-x86_64",
|
||||
environment="debian:12",
|
||||
json=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(
|
||||
cli,
|
||||
"doctor_report",
|
||||
lambda platform: {"platform": platform, "runtime_ok": True},
|
||||
lambda *, platform, environment: {
|
||||
"platform": platform,
|
||||
"environment": environment,
|
||||
"runtime_ok": True,
|
||||
},
|
||||
)
|
||||
cli.main()
|
||||
output = json.loads(capsys.readouterr().out)
|
||||
|
|
@ -569,7 +719,7 @@ def test_cli_requires_command_preserves_shell_argument_boundaries() -> None:
|
|||
command = cli._require_command(
|
||||
["--", "sh", "-lc", 'printf "hello from workspace\\n" > note.txt']
|
||||
)
|
||||
assert command == 'sh -lc \'printf "hello from workspace\\n" > note.txt\''
|
||||
assert command == "sh -lc 'printf \"hello from workspace\\n\" > note.txt'"
|
||||
|
||||
|
||||
def test_cli_read_utf8_text_file_rejects_non_utf8(tmp_path: Path) -> None:
|
||||
|
|
@ -845,10 +995,7 @@ def test_cli_workspace_exec_prints_human_output(
|
|||
cli.main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == "hello\n"
|
||||
assert (
|
||||
"[workspace-exec] workspace_id=workspace-123 sequence=2 cwd=/workspace"
|
||||
in captured.err
|
||||
)
|
||||
assert "[workspace-exec] workspace_id=workspace-123 sequence=2 cwd=/workspace" in captured.err
|
||||
|
||||
|
||||
def test_print_workspace_summary_human_handles_last_command_and_secret_filtering(
|
||||
|
|
@ -1319,13 +1466,7 @@ def test_cli_workspace_patch_apply_reads_patch_file(
|
|||
tmp_path: Path,
|
||||
) -> None:
|
||||
patch_path = tmp_path / "fix.patch"
|
||||
patch_text = (
|
||||
"--- a/src/app.py\n"
|
||||
"+++ b/src/app.py\n"
|
||||
"@@ -1 +1 @@\n"
|
||||
"-print('hi')\n"
|
||||
"+print('hello')\n"
|
||||
)
|
||||
patch_text = "--- a/src/app.py\n+++ b/src/app.py\n@@ -1 +1 @@\n-print('hi')\n+print('hello')\n"
|
||||
patch_path.write_text(patch_text, encoding="utf-8")
|
||||
|
||||
class StubPyro:
|
||||
|
|
@ -1773,10 +1914,7 @@ def test_cli_workspace_diff_prints_human_output(
|
|||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
output = capsys.readouterr().out
|
||||
assert (
|
||||
"[workspace-diff] workspace_id=workspace-123 total=1 added=0 modified=1"
|
||||
in output
|
||||
)
|
||||
assert "[workspace-diff] workspace_id=workspace-123 total=1 added=0 modified=1" in output
|
||||
assert "--- a/note.txt" in output
|
||||
|
||||
|
||||
|
|
@ -2223,8 +2361,7 @@ def test_cli_workspace_sync_push_prints_human(
|
|||
output = capsys.readouterr().out
|
||||
assert "[workspace-sync] workspace_id=workspace-123 mode=directory source=/tmp/repo" in output
|
||||
assert (
|
||||
"destination=/workspace entry_count=2 bytes_written=12 "
|
||||
"execution_mode=guest_vsock"
|
||||
"destination=/workspace entry_count=2 bytes_written=12 execution_mode=guest_vsock"
|
||||
) in output
|
||||
|
||||
|
||||
|
|
@ -2391,6 +2528,168 @@ def test_cli_workspace_logs_prints_json(
|
|||
assert payload["count"] == 0
|
||||
|
||||
|
||||
def test_cli_workspace_summary_prints_json(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def summarize_workspace(self, workspace_id: str) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"name": "review-eval",
|
||||
"labels": {"suite": "smoke"},
|
||||
"environment": "debian:12",
|
||||
"state": "started",
|
||||
"last_activity_at": 2.0,
|
||||
"session_started_at": 1.0,
|
||||
"outcome": {
|
||||
"command_count": 1,
|
||||
"last_command": {"command": "cat note.txt", "exit_code": 0},
|
||||
"service_count": 0,
|
||||
"running_service_count": 0,
|
||||
"export_count": 1,
|
||||
"snapshot_count": 1,
|
||||
"reset_count": 0,
|
||||
},
|
||||
"commands": {"total": 1, "recent": []},
|
||||
"edits": {"recent": []},
|
||||
"changes": {"available": True, "changed": False, "summary": None, "entries": []},
|
||||
"services": {"current": [], "recent": []},
|
||||
"artifacts": {"exports": []},
|
||||
"snapshots": {"named_count": 1, "recent": []},
|
||||
}
|
||||
|
||||
class SummaryParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="summary",
|
||||
workspace_id="workspace-123",
|
||||
json=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: SummaryParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["workspace_id"] == "workspace-123"
|
||||
assert payload["outcome"]["export_count"] == 1
|
||||
|
||||
|
||||
def test_cli_workspace_summary_prints_human(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def summarize_workspace(self, workspace_id: str) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"name": "review-eval",
|
||||
"labels": {"suite": "smoke", "use_case": "review-eval"},
|
||||
"environment": "debian:12",
|
||||
"state": "started",
|
||||
"last_activity_at": 3.0,
|
||||
"session_started_at": 1.0,
|
||||
"outcome": {
|
||||
"command_count": 2,
|
||||
"last_command": {"command": "sh review.sh", "exit_code": 0},
|
||||
"service_count": 1,
|
||||
"running_service_count": 0,
|
||||
"export_count": 1,
|
||||
"snapshot_count": 1,
|
||||
"reset_count": 0,
|
||||
},
|
||||
"commands": {
|
||||
"total": 2,
|
||||
"recent": [
|
||||
{
|
||||
"sequence": 2,
|
||||
"command": "sh review.sh",
|
||||
"cwd": "/workspace",
|
||||
"exit_code": 0,
|
||||
"duration_ms": 12,
|
||||
"execution_mode": "guest_vsock",
|
||||
"recorded_at": 3.0,
|
||||
}
|
||||
],
|
||||
},
|
||||
"edits": {
|
||||
"recent": [
|
||||
{
|
||||
"event_kind": "patch_apply",
|
||||
"recorded_at": 2.0,
|
||||
"path": "/workspace/note.txt",
|
||||
}
|
||||
]
|
||||
},
|
||||
"changes": {
|
||||
"available": True,
|
||||
"changed": True,
|
||||
"summary": {
|
||||
"total": 1,
|
||||
"added": 0,
|
||||
"modified": 1,
|
||||
"deleted": 0,
|
||||
"type_changed": 0,
|
||||
"text_patched": 1,
|
||||
"non_text": 0,
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"path": "/workspace/note.txt",
|
||||
"status": "modified",
|
||||
"artifact_type": "file",
|
||||
}
|
||||
],
|
||||
},
|
||||
"services": {
|
||||
"current": [{"service_name": "app", "state": "stopped"}],
|
||||
"recent": [
|
||||
{
|
||||
"event_kind": "service_stop",
|
||||
"service_name": "app",
|
||||
"state": "stopped",
|
||||
}
|
||||
],
|
||||
},
|
||||
"artifacts": {
|
||||
"exports": [
|
||||
{
|
||||
"workspace_path": "review-report.txt",
|
||||
"output_path": "/tmp/review-report.txt",
|
||||
}
|
||||
]
|
||||
},
|
||||
"snapshots": {
|
||||
"named_count": 1,
|
||||
"recent": [{"event_kind": "snapshot_create", "snapshot_name": "checkpoint"}],
|
||||
},
|
||||
}
|
||||
|
||||
class SummaryParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="summary",
|
||||
workspace_id="workspace-123",
|
||||
json=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: SummaryParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
output = capsys.readouterr().out
|
||||
assert "Workspace review: workspace-123" in output
|
||||
assert "Outcome: commands=2 services=0/1 exports=1 snapshots=1 resets=0" in output
|
||||
assert "Recent commands:" in output
|
||||
assert "Recent edits:" in output
|
||||
assert "Changes: total=1 added=0 modified=1 deleted=0 type_changed=0 non_text=0" in output
|
||||
assert "Recent exports:" in output
|
||||
assert "Recent snapshot events:" in output
|
||||
|
||||
|
||||
def test_cli_workspace_delete_prints_human(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
|
|
@ -2805,7 +3104,7 @@ def test_cli_workspace_shell_open_prints_id_only(
|
|||
assert captured.err == ""
|
||||
|
||||
|
||||
def test_chat_host_docs_and_examples_recommend_workspace_core() -> None:
|
||||
def test_chat_host_docs_and_examples_recommend_modes_first() -> None:
|
||||
readme = Path("README.md").read_text(encoding="utf-8")
|
||||
install = Path("docs/install.md").read_text(encoding="utf-8")
|
||||
first_run = Path("docs/first-run.md").read_text(encoding="utf-8")
|
||||
|
|
@ -2814,47 +3113,81 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None:
|
|||
claude_code = Path("examples/claude_code_mcp.md").read_text(encoding="utf-8")
|
||||
codex = Path("examples/codex_mcp.md").read_text(encoding="utf-8")
|
||||
opencode = json.loads(Path("examples/opencode_mcp_config.json").read_text(encoding="utf-8"))
|
||||
claude_cmd = "claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve"
|
||||
codex_cmd = "codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve"
|
||||
claude_helper = "pyro host connect claude-code --mode cold-start"
|
||||
codex_helper = "pyro host connect codex --mode repro-fix"
|
||||
inspect_helper = "pyro host connect codex --mode inspect"
|
||||
review_helper = "pyro host connect claude-code --mode review-eval"
|
||||
opencode_helper = "pyro host print-config opencode --mode repro-fix"
|
||||
claude_cmd = "claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start"
|
||||
codex_cmd = "codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix"
|
||||
|
||||
assert "## Chat Host Quickstart" in readme
|
||||
assert "uvx --from pyro-mcp pyro mcp serve" in readme
|
||||
assert claude_cmd in readme
|
||||
assert codex_cmd in readme
|
||||
assert claude_helper in readme
|
||||
assert codex_helper in readme
|
||||
assert inspect_helper in readme
|
||||
assert review_helper in readme
|
||||
assert opencode_helper in readme
|
||||
assert "examples/opencode_mcp_config.json" in readme
|
||||
assert "recommended first profile for normal persistent chat editing" in readme
|
||||
assert "pyro host doctor" in readme
|
||||
assert "pyro mcp serve --mode repro-fix" in readme
|
||||
assert "generic no-mode path" in readme
|
||||
assert "auto-detects the current Git checkout" in readme.replace("\n", " ")
|
||||
assert "--project-path /abs/path/to/repo" in readme
|
||||
assert "--repo-url https://github.com/example/project.git" in readme
|
||||
|
||||
assert "## Chat Host Quickstart" in install
|
||||
assert "uvx --from pyro-mcp pyro mcp serve" in install
|
||||
assert claude_cmd in install
|
||||
assert codex_cmd in install
|
||||
assert "## 6. Connect a chat host" in install
|
||||
assert claude_helper in install
|
||||
assert codex_helper in install
|
||||
assert inspect_helper in install
|
||||
assert review_helper in install
|
||||
assert opencode_helper in install
|
||||
assert "workspace-full" in install
|
||||
assert "--project-path /abs/path/to/repo" in install
|
||||
assert "pyro mcp serve --mode cold-start" in install
|
||||
|
||||
assert claude_cmd in first_run
|
||||
assert codex_cmd in first_run
|
||||
assert claude_helper in first_run
|
||||
assert codex_helper in first_run
|
||||
assert inspect_helper in first_run
|
||||
assert review_helper in first_run
|
||||
assert opencode_helper in first_run
|
||||
assert "--project-path /abs/path/to/repo" in first_run
|
||||
assert "pyro mcp serve --mode review-eval" in first_run
|
||||
|
||||
assert "Bare `pyro mcp serve` now starts `workspace-core`." in integrations
|
||||
assert claude_helper in integrations
|
||||
assert codex_helper in integrations
|
||||
assert inspect_helper in integrations
|
||||
assert review_helper in integrations
|
||||
assert opencode_helper in integrations
|
||||
assert "## Recommended Modes" in integrations
|
||||
assert "pyro mcp serve --mode inspect" in integrations
|
||||
assert "auto-detects the current Git checkout" in integrations
|
||||
assert "examples/claude_code_mcp.md" in integrations
|
||||
assert "examples/codex_mcp.md" in integrations
|
||||
assert "examples/opencode_mcp_config.json" in integrations
|
||||
assert (
|
||||
'`Pyro.create_server()` for most chat hosts now that `workspace-core` '
|
||||
"is the default profile" in integrations
|
||||
)
|
||||
assert "generic no-mode path" in integrations
|
||||
assert "--project-path /abs/path/to/repo" in integrations
|
||||
assert "--repo-url https://github.com/example/project.git" in integrations
|
||||
|
||||
assert "Default for most chat hosts in `4.x`: `workspace-core`." in mcp_config
|
||||
assert "Recommended named modes for most chat hosts in `4.x`:" in mcp_config
|
||||
assert "Use the host-specific examples first when they apply:" in mcp_config
|
||||
assert "claude_code_mcp.md" in mcp_config
|
||||
assert "codex_mcp.md" in mcp_config
|
||||
assert "opencode_mcp_config.json" in mcp_config
|
||||
assert '"serve", "--mode", "repro-fix"' in mcp_config
|
||||
|
||||
assert claude_helper in claude_code
|
||||
assert claude_cmd in claude_code
|
||||
assert "claude mcp list" in claude_code
|
||||
assert "pyro host repair claude-code --mode cold-start" in claude_code
|
||||
assert "workspace-full" in claude_code
|
||||
assert "--project-path /abs/path/to/repo" in claude_code
|
||||
|
||||
assert codex_helper in codex
|
||||
assert codex_cmd in codex
|
||||
assert "codex mcp list" in codex
|
||||
assert "pyro host repair codex --mode repro-fix" in codex
|
||||
assert "workspace-full" in codex
|
||||
assert "--project-path /abs/path/to/repo" in codex
|
||||
|
||||
assert opencode == {
|
||||
"mcp": {
|
||||
|
|
@ -2868,6 +3201,8 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None:
|
|||
"pyro",
|
||||
"mcp",
|
||||
"serve",
|
||||
"--mode",
|
||||
"repro-fix",
|
||||
],
|
||||
}
|
||||
}
|
||||
|
|
@ -2882,7 +3217,32 @@ def test_content_only_read_docs_are_aligned() -> None:
|
|||
assert 'workspace file read "$WORKSPACE_ID" note.txt --content-only' in readme
|
||||
assert 'workspace file read "$WORKSPACE_ID" note.txt --content-only' in install
|
||||
assert 'workspace file read "$WORKSPACE_ID" note.txt --content-only' in first_run
|
||||
assert 'workspace disk read "$WORKSPACE_ID" note.txt --content-only' in first_run
|
||||
assert 'workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch' in first_run
|
||||
|
||||
|
||||
def test_daily_loop_docs_are_aligned() -> None:
|
||||
readme = Path("README.md").read_text(encoding="utf-8")
|
||||
install = Path("docs/install.md").read_text(encoding="utf-8")
|
||||
first_run = Path("docs/first-run.md").read_text(encoding="utf-8")
|
||||
integrations = Path("docs/integrations.md").read_text(encoding="utf-8")
|
||||
|
||||
assert "pyro prepare debian:12" in readme
|
||||
assert "pyro prepare debian:12" in install
|
||||
assert "pyro prepare debian:12" in first_run
|
||||
assert "pyro prepare debian:12" in integrations
|
||||
assert "pyro doctor --environment debian:12" in readme
|
||||
assert "pyro doctor --environment debian:12" in install
|
||||
assert "pyro doctor --environment debian:12" in first_run
|
||||
|
||||
|
||||
def test_workspace_summary_docs_are_aligned() -> None:
|
||||
readme = Path("README.md").read_text(encoding="utf-8")
|
||||
install = Path("docs/install.md").read_text(encoding="utf-8")
|
||||
first_run = Path("docs/first-run.md").read_text(encoding="utf-8")
|
||||
|
||||
assert 'workspace summary "$WORKSPACE_ID"' in readme
|
||||
assert 'workspace summary "$WORKSPACE_ID"' in install
|
||||
assert 'workspace summary "$WORKSPACE_ID"' in first_run
|
||||
|
||||
|
||||
def test_cli_workspace_shell_write_signal_close_json(
|
||||
|
|
@ -3965,22 +4325,163 @@ def test_cli_doctor_prints_human(
|
|||
) -> None:
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(command="doctor", platform="linux-x86_64", json=False)
|
||||
return argparse.Namespace(
|
||||
command="doctor",
|
||||
platform="linux-x86_64",
|
||||
environment="debian:12",
|
||||
json=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(
|
||||
cli,
|
||||
"doctor_report",
|
||||
lambda platform: {
|
||||
lambda *, platform, environment: {
|
||||
"platform": platform,
|
||||
"runtime_ok": True,
|
||||
"issues": [],
|
||||
"kvm": {"exists": True, "readable": True, "writable": True},
|
||||
"runtime": {"catalog_version": "4.5.0", "cache_dir": "/cache"},
|
||||
"daily_loop": {
|
||||
"environment": environment,
|
||||
"status": "cold",
|
||||
"installed": False,
|
||||
"network_prepared": False,
|
||||
"prepared_at": None,
|
||||
"manifest_path": "/cache/.prepare/linux-x86_64/debian_12.json",
|
||||
"reason": "daily loop has not been prepared yet",
|
||||
"cache_dir": "/cache",
|
||||
},
|
||||
},
|
||||
)
|
||||
cli.main()
|
||||
output = capsys.readouterr().out
|
||||
assert "Runtime: PASS" in output
|
||||
assert "Daily loop: COLD (debian:12)" in output
|
||||
assert "Run: pyro prepare debian:12" in output
|
||||
|
||||
|
||||
def test_cli_prepare_prints_human(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubManager:
|
||||
def prepare_daily_loop(
|
||||
self,
|
||||
environment: str,
|
||||
*,
|
||||
network: bool,
|
||||
force: bool,
|
||||
) -> dict[str, object]:
|
||||
assert environment == "debian:12"
|
||||
assert network is True
|
||||
assert force is False
|
||||
return {
|
||||
"environment": environment,
|
||||
"status": "warm",
|
||||
"prepared": True,
|
||||
"reused": False,
|
||||
"executed": True,
|
||||
"network_prepared": True,
|
||||
"prepared_at": 123.0,
|
||||
"manifest_path": "/cache/.prepare/linux-x86_64/debian_12.json",
|
||||
"cache_dir": "/cache",
|
||||
"last_prepare_duration_ms": 456,
|
||||
"reason": None,
|
||||
}
|
||||
|
||||
class StubPyro:
|
||||
def __init__(self) -> None:
|
||||
self.manager = StubManager()
|
||||
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="prepare",
|
||||
environment="debian:12",
|
||||
network=True,
|
||||
force=False,
|
||||
json=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
output = capsys.readouterr().out
|
||||
assert "Prepare: debian:12" in output
|
||||
assert "Daily loop: WARM" in output
|
||||
assert "Result: prepared network_prepared=yes" in output
|
||||
|
||||
|
||||
def test_cli_prepare_prints_json_and_errors(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class SuccessManager:
|
||||
def prepare_daily_loop(
|
||||
self,
|
||||
environment: str,
|
||||
*,
|
||||
network: bool,
|
||||
force: bool,
|
||||
) -> dict[str, object]:
|
||||
assert environment == "debian:12"
|
||||
assert network is False
|
||||
assert force is True
|
||||
return {"environment": environment, "reused": True}
|
||||
|
||||
class SuccessPyro:
|
||||
def __init__(self) -> None:
|
||||
self.manager = SuccessManager()
|
||||
|
||||
class SuccessParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="prepare",
|
||||
environment="debian:12",
|
||||
network=False,
|
||||
force=True,
|
||||
json=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: SuccessParser())
|
||||
monkeypatch.setattr(cli, "Pyro", SuccessPyro)
|
||||
cli.main()
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["reused"] is True
|
||||
|
||||
class ErrorManager:
|
||||
def prepare_daily_loop(
|
||||
self,
|
||||
environment: str,
|
||||
*,
|
||||
network: bool,
|
||||
force: bool,
|
||||
) -> dict[str, object]:
|
||||
del environment, network, force
|
||||
raise RuntimeError("prepare failed")
|
||||
|
||||
class ErrorPyro:
|
||||
def __init__(self) -> None:
|
||||
self.manager = ErrorManager()
|
||||
|
||||
class ErrorParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="prepare",
|
||||
environment="debian:12",
|
||||
network=False,
|
||||
force=False,
|
||||
json=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: ErrorParser())
|
||||
monkeypatch.setattr(cli, "Pyro", ErrorPyro)
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
cli.main()
|
||||
error_payload = json.loads(capsys.readouterr().out)
|
||||
assert error_payload["ok"] is False
|
||||
assert error_payload["error"] == "prepare failed"
|
||||
|
||||
|
||||
def test_cli_run_json_error_exits_nonzero(
|
||||
|
|
@ -4017,11 +4518,25 @@ def test_cli_run_json_error_exits_nonzero(
|
|||
|
||||
|
||||
def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
observed: dict[str, str] = {}
|
||||
observed: dict[str, Any] = {}
|
||||
|
||||
class StubPyro:
|
||||
def create_server(self, *, profile: str) -> Any:
|
||||
def create_server(
|
||||
self,
|
||||
*,
|
||||
profile: str,
|
||||
mode: str | None,
|
||||
project_path: str | None,
|
||||
repo_url: str | None,
|
||||
repo_ref: str | None,
|
||||
no_project_source: bool,
|
||||
) -> Any:
|
||||
observed["profile"] = profile
|
||||
observed["mode"] = mode
|
||||
observed["project_path"] = project_path
|
||||
observed["repo_url"] = repo_url
|
||||
observed["repo_ref"] = repo_ref
|
||||
observed["no_project_source"] = no_project_source
|
||||
return type(
|
||||
"StubServer",
|
||||
(),
|
||||
|
|
@ -4030,12 +4545,29 @@ def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(command="mcp", mcp_command="serve", profile="workspace-core")
|
||||
return argparse.Namespace(
|
||||
command="mcp",
|
||||
mcp_command="serve",
|
||||
profile="workspace-core",
|
||||
mode=None,
|
||||
project_path="/repo",
|
||||
repo_url=None,
|
||||
repo_ref=None,
|
||||
no_project_source=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
assert observed == {"profile": "workspace-core", "transport": "stdio"}
|
||||
assert observed == {
|
||||
"profile": "workspace-core",
|
||||
"mode": None,
|
||||
"project_path": "/repo",
|
||||
"repo_url": None,
|
||||
"repo_ref": None,
|
||||
"no_project_source": False,
|
||||
"transport": "stdio",
|
||||
}
|
||||
|
||||
|
||||
def test_cli_demo_default_prints_json(
|
||||
|
|
@ -4153,7 +4685,7 @@ def test_cli_workspace_exec_passes_secret_env(
|
|||
class StubPyro:
|
||||
def exec_workspace(self, workspace_id: str, **kwargs: Any) -> dict[str, Any]:
|
||||
assert workspace_id == "ws-123"
|
||||
assert kwargs["command"] == "sh -lc 'test \"$API_TOKEN\" = \"expected\"'"
|
||||
assert kwargs["command"] == 'sh -lc \'test "$API_TOKEN" = "expected"\''
|
||||
assert kwargs["secret_env"] == {"API_TOKEN": "API_TOKEN", "TOKEN": "PIP_TOKEN"}
|
||||
return {"exit_code": 0, "stdout": "", "stderr": ""}
|
||||
|
||||
|
|
|
|||
359
tests/test_daily_loop.py
Normal file
359
tests/test_daily_loop.py
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from pyro_mcp.daily_loop import (
|
||||
DailyLoopManifest,
|
||||
evaluate_daily_loop_status,
|
||||
load_prepare_manifest,
|
||||
prepare_manifest_path,
|
||||
prepare_request_is_satisfied,
|
||||
)
|
||||
from pyro_mcp.runtime import RuntimeCapabilities
|
||||
from pyro_mcp.vm_manager import VmManager
|
||||
|
||||
|
||||
def test_prepare_daily_loop_executes_then_reuses(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "state",
|
||||
cache_dir=tmp_path / "cache",
|
||||
)
|
||||
monkeypatch.setattr(manager, "_backend_name", "firecracker")
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"_runtime_capabilities",
|
||||
RuntimeCapabilities(
|
||||
supports_vm_boot=True,
|
||||
supports_guest_exec=True,
|
||||
supports_guest_network=True,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"inspect_environment",
|
||||
lambda environment: {"installed": True},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager._environment_store,
|
||||
"ensure_installed",
|
||||
lambda environment: object(),
|
||||
)
|
||||
|
||||
observed: dict[str, object] = {}
|
||||
|
||||
def fake_create_workspace(**kwargs: object) -> dict[str, object]:
|
||||
observed["network_policy"] = kwargs["network_policy"]
|
||||
return {"workspace_id": "ws-123"}
|
||||
|
||||
def fake_exec_workspace(
|
||||
workspace_id: str,
|
||||
*,
|
||||
command: str,
|
||||
timeout_seconds: int = 30,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, object]:
|
||||
observed["exec"] = {
|
||||
"workspace_id": workspace_id,
|
||||
"command": command,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"secret_env": secret_env,
|
||||
}
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"stdout": "/workspace\n",
|
||||
"stderr": "",
|
||||
"exit_code": 0,
|
||||
"duration_ms": 1,
|
||||
"execution_mode": "guest_vsock",
|
||||
}
|
||||
|
||||
def fake_reset_workspace(
|
||||
workspace_id: str,
|
||||
*,
|
||||
snapshot: str = "baseline",
|
||||
) -> dict[str, object]:
|
||||
observed["reset"] = {"workspace_id": workspace_id, "snapshot": snapshot}
|
||||
return {"workspace_id": workspace_id}
|
||||
|
||||
def fake_delete_workspace(
|
||||
workspace_id: str,
|
||||
*,
|
||||
reason: str = "explicit_delete",
|
||||
) -> dict[str, object]:
|
||||
observed["delete"] = {"workspace_id": workspace_id, "reason": reason}
|
||||
return {"workspace_id": workspace_id, "deleted": True}
|
||||
|
||||
monkeypatch.setattr(manager, "create_workspace", fake_create_workspace)
|
||||
monkeypatch.setattr(manager, "exec_workspace", fake_exec_workspace)
|
||||
monkeypatch.setattr(manager, "reset_workspace", fake_reset_workspace)
|
||||
monkeypatch.setattr(manager, "delete_workspace", fake_delete_workspace)
|
||||
|
||||
first = manager.prepare_daily_loop("debian:12")
|
||||
assert first["prepared"] is True
|
||||
assert first["executed"] is True
|
||||
assert first["reused"] is False
|
||||
assert first["network_prepared"] is False
|
||||
assert first["execution_mode"] == "guest_vsock"
|
||||
assert observed["network_policy"] == "off"
|
||||
assert observed["exec"] == {
|
||||
"workspace_id": "ws-123",
|
||||
"command": "pwd",
|
||||
"timeout_seconds": 30,
|
||||
"secret_env": None,
|
||||
}
|
||||
assert observed["reset"] == {"workspace_id": "ws-123", "snapshot": "baseline"}
|
||||
assert observed["delete"] == {"workspace_id": "ws-123", "reason": "prepare_cleanup"}
|
||||
|
||||
second = manager.prepare_daily_loop("debian:12")
|
||||
assert second["prepared"] is True
|
||||
assert second["executed"] is False
|
||||
assert second["reused"] is True
|
||||
assert second["reason"] == "reused existing warm manifest"
|
||||
|
||||
|
||||
def test_prepare_daily_loop_force_and_network_upgrade_manifest(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "state",
|
||||
cache_dir=tmp_path / "cache",
|
||||
)
|
||||
monkeypatch.setattr(manager, "_backend_name", "firecracker")
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"_runtime_capabilities",
|
||||
RuntimeCapabilities(
|
||||
supports_vm_boot=True,
|
||||
supports_guest_exec=True,
|
||||
supports_guest_network=True,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"inspect_environment",
|
||||
lambda environment: {"installed": True},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager._environment_store,
|
||||
"ensure_installed",
|
||||
lambda environment: object(),
|
||||
)
|
||||
|
||||
observed_policies: list[str] = []
|
||||
|
||||
def fake_create_workspace(**kwargs: object) -> dict[str, object]:
|
||||
observed_policies.append(str(kwargs["network_policy"]))
|
||||
return {"workspace_id": "ws-1"}
|
||||
|
||||
monkeypatch.setattr(manager, "create_workspace", fake_create_workspace)
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"exec_workspace",
|
||||
lambda workspace_id, **kwargs: {
|
||||
"workspace_id": workspace_id,
|
||||
"stdout": "/workspace\n",
|
||||
"stderr": "",
|
||||
"exit_code": 0,
|
||||
"duration_ms": 1,
|
||||
"execution_mode": "guest_vsock",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"reset_workspace",
|
||||
lambda workspace_id, **kwargs: {"workspace_id": workspace_id},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"delete_workspace",
|
||||
lambda workspace_id, **kwargs: {"workspace_id": workspace_id, "deleted": True},
|
||||
)
|
||||
|
||||
manager.prepare_daily_loop("debian:12")
|
||||
payload = manager.prepare_daily_loop("debian:12", network=True, force=True)
|
||||
assert payload["executed"] is True
|
||||
assert payload["network_prepared"] is True
|
||||
assert observed_policies == ["off", "egress"]
|
||||
|
||||
manifest_path = prepare_manifest_path(
|
||||
tmp_path / "cache",
|
||||
platform="linux-x86_64",
|
||||
environment="debian:12",
|
||||
)
|
||||
manifest, manifest_error = load_prepare_manifest(manifest_path)
|
||||
assert manifest_error is None
|
||||
if manifest is None:
|
||||
raise AssertionError("expected prepare manifest")
|
||||
assert manifest.network_prepared is True
|
||||
|
||||
|
||||
def test_prepare_daily_loop_requires_guest_capabilities(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "state",
|
||||
cache_dir=tmp_path / "cache",
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="guest-backed runtime"):
|
||||
manager.prepare_daily_loop("debian:12")
|
||||
|
||||
|
||||
def test_load_prepare_manifest_reports_invalid_json(tmp_path: Path) -> None:
|
||||
manifest_path = prepare_manifest_path(
|
||||
tmp_path,
|
||||
platform="linux-x86_64",
|
||||
environment="debian:12",
|
||||
)
|
||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path.write_text("{broken", encoding="utf-8")
|
||||
|
||||
manifest, error = load_prepare_manifest(manifest_path)
|
||||
assert manifest is None
|
||||
assert error is not None
|
||||
|
||||
|
||||
def test_prepare_manifest_round_trip(tmp_path: Path) -> None:
|
||||
manifest_path = prepare_manifest_path(
|
||||
tmp_path,
|
||||
platform="linux-x86_64",
|
||||
environment="debian:12",
|
||||
)
|
||||
manifest = DailyLoopManifest(
|
||||
environment="debian:12",
|
||||
environment_version="1.0.0",
|
||||
platform="linux-x86_64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-1",
|
||||
prepared_at=123.0,
|
||||
network_prepared=True,
|
||||
last_prepare_duration_ms=456,
|
||||
)
|
||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path.write_text(json.dumps(manifest.to_payload()), encoding="utf-8")
|
||||
|
||||
loaded, error = load_prepare_manifest(manifest_path)
|
||||
assert error is None
|
||||
assert loaded == manifest
|
||||
|
||||
|
||||
def test_load_prepare_manifest_rejects_non_object(tmp_path: Path) -> None:
|
||||
manifest_path = prepare_manifest_path(
|
||||
tmp_path,
|
||||
platform="linux-x86_64",
|
||||
environment="debian:12",
|
||||
)
|
||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path.write_text('["not-an-object"]', encoding="utf-8")
|
||||
|
||||
manifest, error = load_prepare_manifest(manifest_path)
|
||||
assert manifest is None
|
||||
assert error == "prepare manifest is not a JSON object"
|
||||
|
||||
|
||||
def test_load_prepare_manifest_rejects_invalid_payload(tmp_path: Path) -> None:
|
||||
manifest_path = prepare_manifest_path(
|
||||
tmp_path,
|
||||
platform="linux-x86_64",
|
||||
environment="debian:12",
|
||||
)
|
||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path.write_text(json.dumps({"environment": "debian:12"}), encoding="utf-8")
|
||||
|
||||
manifest, error = load_prepare_manifest(manifest_path)
|
||||
assert manifest is None
|
||||
assert error is not None
|
||||
assert "prepare manifest is invalid" in error
|
||||
|
||||
|
||||
def test_evaluate_daily_loop_status_edge_cases() -> None:
|
||||
manifest = DailyLoopManifest(
|
||||
environment="debian:12",
|
||||
environment_version="1.0.0",
|
||||
platform="linux-x86_64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-1",
|
||||
prepared_at=1.0,
|
||||
network_prepared=False,
|
||||
last_prepare_duration_ms=2,
|
||||
)
|
||||
|
||||
assert evaluate_daily_loop_status(
|
||||
environment="debian:12",
|
||||
environment_version="1.0.0",
|
||||
platform="linux-x86_64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-1",
|
||||
installed=True,
|
||||
manifest=manifest,
|
||||
manifest_error="broken manifest",
|
||||
) == ("stale", "broken manifest")
|
||||
assert evaluate_daily_loop_status(
|
||||
environment="debian:12",
|
||||
environment_version="1.0.0",
|
||||
platform="linux-x86_64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-1",
|
||||
installed=False,
|
||||
manifest=manifest,
|
||||
) == ("stale", "environment install is missing")
|
||||
assert evaluate_daily_loop_status(
|
||||
environment="debian:12-build",
|
||||
environment_version="1.0.0",
|
||||
platform="linux-x86_64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-1",
|
||||
installed=True,
|
||||
manifest=manifest,
|
||||
) == ("stale", "prepare manifest environment does not match the selected environment")
|
||||
assert evaluate_daily_loop_status(
|
||||
environment="debian:12",
|
||||
environment_version="2.0.0",
|
||||
platform="linux-x86_64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-1",
|
||||
installed=True,
|
||||
manifest=manifest,
|
||||
) == ("stale", "environment version changed since the last prepare run")
|
||||
assert evaluate_daily_loop_status(
|
||||
environment="debian:12",
|
||||
environment_version="1.0.0",
|
||||
platform="linux-aarch64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-1",
|
||||
installed=True,
|
||||
manifest=manifest,
|
||||
) == ("stale", "platform changed since the last prepare run")
|
||||
assert evaluate_daily_loop_status(
|
||||
environment="debian:12",
|
||||
environment_version="1.0.0",
|
||||
platform="linux-x86_64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-2",
|
||||
installed=True,
|
||||
manifest=manifest,
|
||||
) == ("stale", "runtime bundle version changed since the last prepare run")
|
||||
|
||||
|
||||
def test_prepare_request_is_satisfied_network_gate() -> None:
|
||||
manifest = DailyLoopManifest(
|
||||
environment="debian:12",
|
||||
environment_version="1.0.0",
|
||||
platform="linux-x86_64",
|
||||
catalog_version="4.5.0",
|
||||
bundle_version="bundle-1",
|
||||
prepared_at=1.0,
|
||||
network_prepared=False,
|
||||
last_prepare_duration_ms=2,
|
||||
)
|
||||
|
||||
assert prepare_request_is_satisfied(None, require_network=False) is False
|
||||
assert prepare_request_is_satisfied(manifest, require_network=True) is False
|
||||
assert prepare_request_is_satisfied(manifest, require_network=False) is True
|
||||
138
tests/test_daily_loop_smoke.py
Normal file
138
tests/test_daily_loop_smoke.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import pyro_mcp.daily_loop_smoke as smoke_module
|
||||
|
||||
|
||||
class _FakePyro:
|
||||
def __init__(self) -> None:
|
||||
self.workspace_id = "ws-1"
|
||||
self.message = "broken\n"
|
||||
self.deleted = False
|
||||
|
||||
def create_workspace(
|
||||
self,
|
||||
*,
|
||||
environment: str,
|
||||
seed_path: Path,
|
||||
name: str | None = None,
|
||||
labels: dict[str, str] | None = None,
|
||||
) -> dict[str, object]:
|
||||
assert environment == "debian:12"
|
||||
assert seed_path.is_dir()
|
||||
assert name == "daily-loop"
|
||||
assert labels == {"suite": "daily-loop-smoke"}
|
||||
return {"workspace_id": self.workspace_id}
|
||||
|
||||
def exec_workspace(self, workspace_id: str, *, command: str) -> dict[str, object]:
|
||||
assert workspace_id == self.workspace_id
|
||||
if command != "sh check.sh":
|
||||
raise AssertionError(f"unexpected command: {command}")
|
||||
if self.message == "fixed\n":
|
||||
return {"exit_code": 0, "stdout": "fixed\n"}
|
||||
return {"exit_code": 1, "stderr": "expected fixed got broken\n"}
|
||||
|
||||
def apply_workspace_patch(self, workspace_id: str, *, patch: str) -> dict[str, object]:
|
||||
assert workspace_id == self.workspace_id
|
||||
assert "+fixed" in patch
|
||||
self.message = "fixed\n"
|
||||
return {"changed": True}
|
||||
|
||||
def export_workspace(
|
||||
self,
|
||||
workspace_id: str,
|
||||
path: str,
|
||||
*,
|
||||
output_path: Path,
|
||||
) -> dict[str, object]:
|
||||
assert workspace_id == self.workspace_id
|
||||
assert path == "message.txt"
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(self.message, encoding="utf-8")
|
||||
return {"artifact_type": "file"}
|
||||
|
||||
def reset_workspace(self, workspace_id: str) -> dict[str, object]:
|
||||
assert workspace_id == self.workspace_id
|
||||
self.message = "broken\n"
|
||||
return {"reset_count": 1}
|
||||
|
||||
def read_workspace_file(self, workspace_id: str, path: str) -> dict[str, object]:
|
||||
assert workspace_id == self.workspace_id
|
||||
assert path == "message.txt"
|
||||
return {"content": self.message}
|
||||
|
||||
def delete_workspace(self, workspace_id: str) -> dict[str, object]:
|
||||
assert workspace_id == self.workspace_id
|
||||
self.deleted = True
|
||||
return {"workspace_id": workspace_id, "deleted": True}
|
||||
|
||||
|
||||
def test_run_prepare_parses_json(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout=json.dumps({"prepared": True}),
|
||||
stderr="",
|
||||
),
|
||||
)
|
||||
payload = smoke_module._run_prepare("debian:12")
|
||||
assert payload == {"prepared": True}
|
||||
|
||||
|
||||
def test_run_prepare_raises_on_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: SimpleNamespace(
|
||||
returncode=1,
|
||||
stdout="",
|
||||
stderr="prepare failed",
|
||||
),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="prepare failed"):
|
||||
smoke_module._run_prepare("debian:12")
|
||||
|
||||
|
||||
def test_run_daily_loop_smoke_happy_path(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
prepare_calls: list[str] = []
|
||||
fake_pyro = _FakePyro()
|
||||
|
||||
def fake_run_prepare(environment: str) -> dict[str, object]:
|
||||
prepare_calls.append(environment)
|
||||
return {"prepared": True, "reused": len(prepare_calls) > 1}
|
||||
|
||||
monkeypatch.setattr(smoke_module, "_run_prepare", fake_run_prepare)
|
||||
monkeypatch.setattr(smoke_module, "Pyro", lambda: fake_pyro)
|
||||
|
||||
smoke_module.run_daily_loop_smoke(environment="debian:12")
|
||||
|
||||
assert prepare_calls == ["debian:12", "debian:12"]
|
||||
assert fake_pyro.deleted is True
|
||||
|
||||
|
||||
def test_main_runs_selected_environment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
observed: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
smoke_module,
|
||||
"run_daily_loop_smoke",
|
||||
lambda *, environment: observed.append(environment),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
smoke_module,
|
||||
"build_arg_parser",
|
||||
lambda: SimpleNamespace(
|
||||
parse_args=lambda: SimpleNamespace(environment="debian:12-build")
|
||||
),
|
||||
)
|
||||
smoke_module.main()
|
||||
assert observed == ["debian:12-build"]
|
||||
|
|
@ -15,13 +15,18 @@ def test_doctor_main_prints_json(
|
|||
) -> None:
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(platform="linux-x86_64")
|
||||
return argparse.Namespace(platform="linux-x86_64", environment="debian:12")
|
||||
|
||||
monkeypatch.setattr(doctor_module, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(
|
||||
doctor_module,
|
||||
"doctor_report",
|
||||
lambda platform: {"platform": platform, "runtime_ok": True, "issues": []},
|
||||
lambda *, platform, environment: {
|
||||
"platform": platform,
|
||||
"environment": environment,
|
||||
"runtime_ok": True,
|
||||
"issues": [],
|
||||
},
|
||||
)
|
||||
doctor_module.main()
|
||||
output = json.loads(capsys.readouterr().out)
|
||||
|
|
@ -32,3 +37,4 @@ def test_doctor_build_parser_defaults_platform() -> None:
|
|||
parser = doctor_module._build_parser()
|
||||
args = parser.parse_args([])
|
||||
assert args.platform == DEFAULT_PLATFORM
|
||||
assert args.environment == "debian:12"
|
||||
|
|
|
|||
501
tests/test_host_helpers.py
Normal file
501
tests/test_host_helpers.py
Normal file
|
|
@ -0,0 +1,501 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from subprocess import CompletedProcess
|
||||
|
||||
import pytest
|
||||
|
||||
import pyro_mcp.host_helpers as host_helpers
|
||||
from pyro_mcp.host_helpers import (
|
||||
DEFAULT_OPENCODE_CONFIG_PATH,
|
||||
HostServerConfig,
|
||||
_canonical_server_command,
|
||||
_command_matches,
|
||||
_repair_command,
|
||||
connect_cli_host,
|
||||
doctor_hosts,
|
||||
print_or_write_opencode_config,
|
||||
repair_host,
|
||||
)
|
||||
|
||||
|
||||
def _install_fake_mcp_cli(tmp_path: Path, name: str) -> tuple[Path, Path]:
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir(parents=True)
|
||||
state_path = tmp_path / f"{name}-state.json"
|
||||
script_path = bin_dir / name
|
||||
script_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
f"#!{sys.executable}",
|
||||
"import json",
|
||||
"import shlex",
|
||||
"import sys",
|
||||
f"STATE_PATH = {str(state_path)!r}",
|
||||
"try:",
|
||||
" with open(STATE_PATH, 'r', encoding='utf-8') as handle:",
|
||||
" state = json.load(handle)",
|
||||
"except FileNotFoundError:",
|
||||
" state = {}",
|
||||
"args = sys.argv[1:]",
|
||||
"if args[:2] == ['mcp', 'add']:",
|
||||
" name = args[2]",
|
||||
" marker = args.index('--')",
|
||||
" state[name] = args[marker + 1:]",
|
||||
" with open(STATE_PATH, 'w', encoding='utf-8') as handle:",
|
||||
" json.dump(state, handle)",
|
||||
" print(f'added {name}')",
|
||||
" raise SystemExit(0)",
|
||||
"if args[:2] == ['mcp', 'remove']:",
|
||||
" name = args[2]",
|
||||
" if name in state:",
|
||||
" del state[name]",
|
||||
" with open(STATE_PATH, 'w', encoding='utf-8') as handle:",
|
||||
" json.dump(state, handle)",
|
||||
" print(f'removed {name}')",
|
||||
" raise SystemExit(0)",
|
||||
" print('not found', file=sys.stderr)",
|
||||
" raise SystemExit(1)",
|
||||
"if args[:2] == ['mcp', 'get']:",
|
||||
" name = args[2]",
|
||||
" if name not in state:",
|
||||
" print('not found', file=sys.stderr)",
|
||||
" raise SystemExit(1)",
|
||||
" print(f'{name}: {shlex.join(state[name])}')",
|
||||
" raise SystemExit(0)",
|
||||
"if args[:2] == ['mcp', 'list']:",
|
||||
" for item in sorted(state):",
|
||||
" print(item)",
|
||||
" raise SystemExit(0)",
|
||||
"print('unsupported', file=sys.stderr)",
|
||||
"raise SystemExit(2)",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
script_path.chmod(0o755)
|
||||
return bin_dir, state_path
|
||||
|
||||
|
||||
def test_connect_cli_host_replaces_existing_entry(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
bin_dir, state_path = _install_fake_mcp_cli(tmp_path, "codex")
|
||||
state_path.write_text(json.dumps({"pyro": ["old", "command"]}), encoding="utf-8")
|
||||
monkeypatch.setenv("PATH", str(bin_dir))
|
||||
|
||||
payload = connect_cli_host("codex", config=HostServerConfig())
|
||||
|
||||
assert payload["host"] == "codex"
|
||||
assert payload["server_command"] == ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]
|
||||
assert json.loads(state_path.read_text(encoding="utf-8")) == {
|
||||
"pyro": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]
|
||||
}
|
||||
|
||||
|
||||
def test_canonical_server_command_validates_and_renders_variants() -> None:
|
||||
assert _canonical_server_command(HostServerConfig(installed_package=True)) == [
|
||||
"pyro",
|
||||
"mcp",
|
||||
"serve",
|
||||
]
|
||||
assert _canonical_server_command(
|
||||
HostServerConfig(profile="workspace-full", project_path="/repo")
|
||||
) == [
|
||||
"uvx",
|
||||
"--from",
|
||||
"pyro-mcp",
|
||||
"pyro",
|
||||
"mcp",
|
||||
"serve",
|
||||
"--profile",
|
||||
"workspace-full",
|
||||
"--project-path",
|
||||
"/repo",
|
||||
]
|
||||
assert _canonical_server_command(
|
||||
HostServerConfig(repo_url="https://example.com/repo.git", repo_ref="main")
|
||||
) == [
|
||||
"uvx",
|
||||
"--from",
|
||||
"pyro-mcp",
|
||||
"pyro",
|
||||
"mcp",
|
||||
"serve",
|
||||
"--repo-url",
|
||||
"https://example.com/repo.git",
|
||||
"--repo-ref",
|
||||
"main",
|
||||
]
|
||||
assert _canonical_server_command(HostServerConfig(mode="repro-fix")) == [
|
||||
"uvx",
|
||||
"--from",
|
||||
"pyro-mcp",
|
||||
"pyro",
|
||||
"mcp",
|
||||
"serve",
|
||||
"--mode",
|
||||
"repro-fix",
|
||||
]
|
||||
assert _canonical_server_command(HostServerConfig(no_project_source=True)) == [
|
||||
"uvx",
|
||||
"--from",
|
||||
"pyro-mcp",
|
||||
"pyro",
|
||||
"mcp",
|
||||
"serve",
|
||||
"--no-project-source",
|
||||
]
|
||||
|
||||
with pytest.raises(ValueError, match="mutually exclusive"):
|
||||
_canonical_server_command(
|
||||
HostServerConfig(project_path="/repo", repo_url="https://example.com/repo.git")
|
||||
)
|
||||
with pytest.raises(ValueError, match="cannot be combined"):
|
||||
_canonical_server_command(HostServerConfig(project_path="/repo", no_project_source=True))
|
||||
with pytest.raises(ValueError, match="requires --repo-url"):
|
||||
_canonical_server_command(HostServerConfig(repo_ref="main"))
|
||||
with pytest.raises(ValueError, match="mutually exclusive"):
|
||||
_canonical_server_command(
|
||||
HostServerConfig(profile="workspace-full", mode="repro-fix")
|
||||
)
|
||||
|
||||
|
||||
def test_repair_command_and_command_matches_cover_edge_cases() -> None:
|
||||
assert _repair_command("codex", HostServerConfig()) == "pyro host repair codex"
|
||||
assert _repair_command("codex", HostServerConfig(project_path="/repo")) == (
|
||||
"pyro host repair codex --project-path /repo"
|
||||
)
|
||||
assert _repair_command(
|
||||
"opencode",
|
||||
HostServerConfig(installed_package=True, profile="workspace-full", repo_url="file:///repo"),
|
||||
config_path=Path("/tmp/opencode.json"),
|
||||
) == (
|
||||
"pyro host repair opencode --installed-package --profile workspace-full "
|
||||
"--repo-url file:///repo --config-path /tmp/opencode.json"
|
||||
)
|
||||
assert _repair_command("codex", HostServerConfig(no_project_source=True)) == (
|
||||
"pyro host repair codex --no-project-source"
|
||||
)
|
||||
assert _repair_command("codex", HostServerConfig(mode="inspect")) == (
|
||||
"pyro host repair codex --mode inspect"
|
||||
)
|
||||
assert _command_matches(
|
||||
"pyro: uvx --from pyro-mcp pyro mcp serve",
|
||||
["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"],
|
||||
)
|
||||
assert _command_matches(
|
||||
'"uvx --from pyro-mcp pyro mcp serve',
|
||||
['"uvx', "--from", "pyro-mcp", "pyro", "mcp", "serve"],
|
||||
)
|
||||
assert not _command_matches("pyro: uvx --from pyro-mcp pyro mcp serve --profile vm-run", [
|
||||
"uvx",
|
||||
"--from",
|
||||
"pyro-mcp",
|
||||
"pyro",
|
||||
"mcp",
|
||||
"serve",
|
||||
])
|
||||
|
||||
|
||||
def test_connect_cli_host_reports_missing_cli_and_add_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
with pytest.raises(ValueError, match="unsupported CLI host"):
|
||||
connect_cli_host("unsupported", config=HostServerConfig())
|
||||
|
||||
monkeypatch.setenv("PATH", "")
|
||||
with pytest.raises(RuntimeError, match="codex CLI is not installed"):
|
||||
connect_cli_host("codex", config=HostServerConfig())
|
||||
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
script_path = bin_dir / "codex"
|
||||
script_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
f"#!{sys.executable}",
|
||||
"import sys",
|
||||
"raise SystemExit(1 if sys.argv[1:3] == ['mcp', 'add'] else 0)",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
script_path.chmod(0o755)
|
||||
monkeypatch.setenv("PATH", str(bin_dir))
|
||||
|
||||
with pytest.raises(RuntimeError, match="codex mcp add failed"):
|
||||
connect_cli_host("codex", config=HostServerConfig())
|
||||
|
||||
|
||||
def test_doctor_hosts_reports_ok_and_drifted(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
codex_bin, codex_state = _install_fake_mcp_cli(tmp_path / "codex", "codex")
|
||||
claude_bin, claude_state = _install_fake_mcp_cli(tmp_path / "claude", "claude")
|
||||
combined_path = str(codex_bin) + ":" + str(claude_bin)
|
||||
monkeypatch.setenv("PATH", combined_path)
|
||||
|
||||
codex_state.write_text(
|
||||
json.dumps({"pyro": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
claude_state.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"pyro": [
|
||||
"uvx",
|
||||
"--from",
|
||||
"pyro-mcp",
|
||||
"pyro",
|
||||
"mcp",
|
||||
"serve",
|
||||
"--profile",
|
||||
"workspace-full",
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
opencode_config = tmp_path / "opencode.json"
|
||||
opencode_config.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mcp": {
|
||||
"pyro": {
|
||||
"type": "local",
|
||||
"enabled": True,
|
||||
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"],
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
entries = doctor_hosts(config=HostServerConfig(), config_path=opencode_config)
|
||||
by_host = {entry.host: entry for entry in entries}
|
||||
|
||||
assert by_host["codex"].status == "ok"
|
||||
assert by_host["codex"].configured is True
|
||||
assert by_host["claude-code"].status == "drifted"
|
||||
assert by_host["claude-code"].configured is True
|
||||
assert by_host["opencode"].status == "ok"
|
||||
assert by_host["opencode"].configured is True
|
||||
|
||||
|
||||
def test_doctor_hosts_reports_missing_and_drifted_opencode_shapes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setenv("PATH", "")
|
||||
config_path = tmp_path / "opencode.json"
|
||||
|
||||
config_path.write_text("[]", encoding="utf-8")
|
||||
entries = doctor_hosts(config=HostServerConfig(), config_path=config_path)
|
||||
by_host = {entry.host: entry for entry in entries}
|
||||
assert by_host["opencode"].status == "unavailable"
|
||||
assert "JSON object" in by_host["opencode"].details
|
||||
|
||||
config_path.write_text(json.dumps({"mcp": {}}), encoding="utf-8")
|
||||
entries = doctor_hosts(config=HostServerConfig(), config_path=config_path)
|
||||
by_host = {entry.host: entry for entry in entries}
|
||||
assert by_host["opencode"].status == "unavailable"
|
||||
assert "missing mcp.pyro" in by_host["opencode"].details
|
||||
|
||||
config_path.write_text(
|
||||
json.dumps({"mcp": {"pyro": {"type": "local", "enabled": False, "command": ["wrong"]}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
entries = doctor_hosts(config=HostServerConfig(), config_path=config_path)
|
||||
by_host = {entry.host: entry for entry in entries}
|
||||
assert by_host["opencode"].status == "drifted"
|
||||
assert by_host["opencode"].configured is True
|
||||
|
||||
|
||||
def test_doctor_hosts_reports_invalid_json_for_installed_opencode(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
config_path = tmp_path / "opencode.json"
|
||||
config_path.write_text("{invalid", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
shutil,
|
||||
"which",
|
||||
lambda name: "/usr/bin/opencode" if name == "opencode" else None,
|
||||
)
|
||||
|
||||
entries = doctor_hosts(config=HostServerConfig(), config_path=config_path)
|
||||
by_host = {entry.host: entry for entry in entries}
|
||||
|
||||
assert by_host["opencode"].status == "drifted"
|
||||
assert "invalid JSON" in by_host["opencode"].details
|
||||
|
||||
|
||||
def test_repair_opencode_preserves_unrelated_keys(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setenv("PATH", "")
|
||||
config_path = tmp_path / "opencode.json"
|
||||
config_path.write_text(
|
||||
json.dumps({"theme": "light", "mcp": {"other": {"type": "local"}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path)
|
||||
|
||||
assert payload["config_path"] == str(config_path.resolve())
|
||||
repaired = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert repaired["theme"] == "light"
|
||||
assert repaired["mcp"]["other"] == {"type": "local"}
|
||||
assert repaired["mcp"]["pyro"] == {
|
||||
"type": "local",
|
||||
"enabled": True,
|
||||
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"],
|
||||
}
|
||||
|
||||
|
||||
def test_repair_opencode_backs_up_non_object_json(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setenv("PATH", "")
|
||||
config_path = tmp_path / "opencode.json"
|
||||
config_path.write_text("[]", encoding="utf-8")
|
||||
|
||||
payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path)
|
||||
|
||||
backup_path = Path(str(payload["backup_path"]))
|
||||
assert backup_path.exists()
|
||||
assert backup_path.read_text(encoding="utf-8") == "[]"
|
||||
|
||||
|
||||
def test_repair_opencode_backs_up_invalid_json(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setenv("PATH", "")
|
||||
config_path = tmp_path / "opencode.json"
|
||||
config_path.write_text("{invalid", encoding="utf-8")
|
||||
|
||||
payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path)
|
||||
|
||||
backup_path = Path(str(payload["backup_path"]))
|
||||
assert backup_path.exists()
|
||||
assert backup_path.read_text(encoding="utf-8") == "{invalid"
|
||||
repaired = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert repaired["mcp"]["pyro"]["command"] == [
|
||||
"uvx",
|
||||
"--from",
|
||||
"pyro-mcp",
|
||||
"pyro",
|
||||
"mcp",
|
||||
"serve",
|
||||
]
|
||||
|
||||
|
||||
def test_print_or_write_opencode_config_writes_json(tmp_path: Path) -> None:
|
||||
output_path = tmp_path / "opencode.json"
|
||||
payload = print_or_write_opencode_config(
|
||||
config=HostServerConfig(project_path="/repo"),
|
||||
output_path=output_path,
|
||||
)
|
||||
|
||||
assert payload["output_path"] == str(output_path)
|
||||
rendered = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
assert rendered == {
|
||||
"mcp": {
|
||||
"pyro": {
|
||||
"type": "local",
|
||||
"enabled": True,
|
||||
"command": [
|
||||
"uvx",
|
||||
"--from",
|
||||
"pyro-mcp",
|
||||
"pyro",
|
||||
"mcp",
|
||||
"serve",
|
||||
"--project-path",
|
||||
"/repo",
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_print_or_write_opencode_config_returns_rendered_text() -> None:
|
||||
payload = print_or_write_opencode_config(config=HostServerConfig(profile="vm-run"))
|
||||
|
||||
assert payload["host"] == "opencode"
|
||||
assert payload["server_command"] == [
|
||||
"uvx",
|
||||
"--from",
|
||||
"pyro-mcp",
|
||||
"pyro",
|
||||
"mcp",
|
||||
"serve",
|
||||
"--profile",
|
||||
"vm-run",
|
||||
]
|
||||
rendered = str(payload["rendered_config"])
|
||||
assert '"type": "local"' in rendered
|
||||
assert '"command": [' in rendered
|
||||
|
||||
|
||||
def test_doctor_reports_opencode_missing_when_config_absent(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setenv("PATH", "")
|
||||
|
||||
entries = doctor_hosts(
|
||||
config=HostServerConfig(),
|
||||
config_path=tmp_path / "missing-opencode.json",
|
||||
)
|
||||
by_host = {entry.host: entry for entry in entries}
|
||||
|
||||
assert by_host["opencode"].status == "unavailable"
|
||||
assert str(DEFAULT_OPENCODE_CONFIG_PATH) not in by_host["opencode"].details
|
||||
|
||||
|
||||
def test_repair_host_delegates_non_opencode_and_doctor_handles_list_only_configured(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
host_helpers,
|
||||
"connect_cli_host",
|
||||
lambda host, *, config: {"host": host, "profile": config.profile},
|
||||
)
|
||||
assert repair_host("codex", config=HostServerConfig(profile="vm-run")) == {
|
||||
"host": "codex",
|
||||
"profile": "vm-run",
|
||||
}
|
||||
|
||||
commands: list[list[str]] = []
|
||||
|
||||
def _fake_run_command(command: list[str]) -> CompletedProcess[str]:
|
||||
commands.append(command)
|
||||
if command[:3] == ["codex", "mcp", "get"]:
|
||||
return CompletedProcess(command, 1, "", "not found")
|
||||
if command[:3] == ["codex", "mcp", "list"]:
|
||||
return CompletedProcess(command, 0, "pyro\n", "")
|
||||
raise AssertionError(command)
|
||||
|
||||
monkeypatch.setattr(
|
||||
shutil,
|
||||
"which",
|
||||
lambda name: "/usr/bin/codex" if name == "codex" else None,
|
||||
)
|
||||
monkeypatch.setattr(host_helpers, "_run_command", _fake_run_command)
|
||||
|
||||
entry = host_helpers._doctor_cli_host("codex", config=HostServerConfig())
|
||||
assert entry.status == "drifted"
|
||||
assert entry.configured is True
|
||||
assert commands == [["codex", "mcp", "get", "pyro"], ["codex", "mcp", "list"]]
|
||||
236
tests/test_project_startup.py
Normal file
236
tests/test_project_startup.py
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import pyro_mcp.project_startup as project_startup
|
||||
from pyro_mcp.project_startup import (
|
||||
ProjectStartupSource,
|
||||
describe_project_startup_source,
|
||||
materialize_project_startup_source,
|
||||
resolve_project_startup_source,
|
||||
)
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
result = subprocess.run( # noqa: S603
|
||||
["git", "-c", "commit.gpgsign=false", *args],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _make_repo(root: Path, *, filename: str = "note.txt", content: str = "hello\n") -> Path:
|
||||
root.mkdir()
|
||||
_git(root, "init")
|
||||
_git(root, "config", "user.name", "Pyro Tests")
|
||||
_git(root, "config", "user.email", "pyro-tests@example.com")
|
||||
(root / filename).write_text(content, encoding="utf-8")
|
||||
_git(root, "add", filename)
|
||||
_git(root, "commit", "-m", "init")
|
||||
return root
|
||||
|
||||
|
||||
def test_resolve_project_startup_source_detects_nearest_git_root(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path / "repo")
|
||||
nested = repo / "src" / "pkg"
|
||||
nested.mkdir(parents=True)
|
||||
|
||||
resolved = resolve_project_startup_source(cwd=nested)
|
||||
|
||||
assert resolved == ProjectStartupSource(
|
||||
kind="project_path",
|
||||
origin_ref=str(repo.resolve()),
|
||||
resolved_path=repo.resolve(),
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_project_startup_source_project_path_prefers_git_root(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path / "repo")
|
||||
nested = repo / "nested"
|
||||
nested.mkdir()
|
||||
|
||||
resolved = resolve_project_startup_source(project_path=nested, cwd=tmp_path)
|
||||
|
||||
assert resolved == ProjectStartupSource(
|
||||
kind="project_path",
|
||||
origin_ref=str(repo.resolve()),
|
||||
resolved_path=repo.resolve(),
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_project_startup_source_validates_flag_combinations(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path / "repo")
|
||||
|
||||
with pytest.raises(ValueError, match="mutually exclusive"):
|
||||
resolve_project_startup_source(project_path=repo, repo_url="https://example.com/repo.git")
|
||||
|
||||
with pytest.raises(ValueError, match="requires --repo-url"):
|
||||
resolve_project_startup_source(repo_ref="main")
|
||||
|
||||
with pytest.raises(ValueError, match="cannot be combined"):
|
||||
resolve_project_startup_source(project_path=repo, no_project_source=True)
|
||||
|
||||
|
||||
def test_resolve_project_startup_source_handles_explicit_none_and_empty_values(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repo = _make_repo(tmp_path / "repo")
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
|
||||
assert resolve_project_startup_source(no_project_source=True, cwd=tmp_path) is None
|
||||
assert resolve_project_startup_source(cwd=outside) is None
|
||||
|
||||
with pytest.raises(ValueError, match="must not be empty"):
|
||||
resolve_project_startup_source(repo_url=" ", cwd=repo)
|
||||
|
||||
with pytest.raises(ValueError, match="must not be empty"):
|
||||
resolve_project_startup_source(repo_url="https://example.com/repo.git", repo_ref=" ")
|
||||
|
||||
|
||||
def test_resolve_project_startup_source_rejects_missing_or_non_directory_project_path(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
missing = tmp_path / "missing"
|
||||
file_path = tmp_path / "note.txt"
|
||||
file_path.write_text("hello\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="does not exist"):
|
||||
resolve_project_startup_source(project_path=missing, cwd=tmp_path)
|
||||
|
||||
with pytest.raises(ValueError, match="must be a directory"):
|
||||
resolve_project_startup_source(project_path=file_path, cwd=tmp_path)
|
||||
|
||||
|
||||
def test_resolve_project_startup_source_keeps_plain_relative_directory_when_not_a_repo(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
plain = tmp_path / "plain"
|
||||
plain.mkdir()
|
||||
|
||||
resolved = resolve_project_startup_source(project_path="plain", cwd=tmp_path)
|
||||
|
||||
assert resolved == ProjectStartupSource(
|
||||
kind="project_path",
|
||||
origin_ref=str(plain.resolve()),
|
||||
resolved_path=plain.resolve(),
|
||||
)
|
||||
|
||||
|
||||
def test_materialize_project_startup_source_clones_local_repo_url_at_ref(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path / "repo", content="one\n")
|
||||
first_commit = _git(repo, "rev-parse", "HEAD")
|
||||
(repo / "note.txt").write_text("two\n", encoding="utf-8")
|
||||
_git(repo, "add", "note.txt")
|
||||
_git(repo, "commit", "-m", "update")
|
||||
|
||||
source = ProjectStartupSource(
|
||||
kind="repo_url",
|
||||
origin_ref=str(repo.resolve()),
|
||||
repo_ref=first_commit,
|
||||
)
|
||||
|
||||
with materialize_project_startup_source(source) as clone_dir:
|
||||
assert (clone_dir / "note.txt").read_text(encoding="utf-8") == "one\n"
|
||||
|
||||
|
||||
def test_materialize_project_startup_source_validates_project_source_and_clone_failures(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
with pytest.raises(RuntimeError, match="missing a resolved path"):
|
||||
with materialize_project_startup_source(
|
||||
ProjectStartupSource(kind="project_path", origin_ref="/repo", resolved_path=None)
|
||||
):
|
||||
pass
|
||||
|
||||
source = ProjectStartupSource(kind="repo_url", origin_ref="https://example.com/repo.git")
|
||||
|
||||
def _clone_failure(
|
||||
command: list[str],
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
del cwd
|
||||
return subprocess.CompletedProcess(command, 1, "", "clone failed")
|
||||
|
||||
monkeypatch.setattr("pyro_mcp.project_startup._run_git", _clone_failure)
|
||||
with pytest.raises(RuntimeError, match="failed to clone repo_url"):
|
||||
with materialize_project_startup_source(source):
|
||||
pass
|
||||
|
||||
|
||||
def test_materialize_project_startup_source_reports_checkout_failure(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repo = _make_repo(tmp_path / "repo", content="one\n")
|
||||
source = ProjectStartupSource(
|
||||
kind="repo_url",
|
||||
origin_ref=str(repo.resolve()),
|
||||
repo_ref="missing-ref",
|
||||
)
|
||||
|
||||
original_run_git = project_startup._run_git
|
||||
|
||||
def _checkout_failure(
|
||||
command: list[str],
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
if command[:2] == ["git", "checkout"]:
|
||||
return subprocess.CompletedProcess(command, 1, "", "checkout failed")
|
||||
return original_run_git(command, cwd=cwd)
|
||||
|
||||
monkeypatch.setattr("pyro_mcp.project_startup._run_git", _checkout_failure)
|
||||
with pytest.raises(RuntimeError, match="failed to checkout repo_ref"):
|
||||
with materialize_project_startup_source(source):
|
||||
pass
|
||||
|
||||
|
||||
def test_describe_project_startup_source_formats_project_and_repo_sources(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path / "repo")
|
||||
|
||||
project_description = describe_project_startup_source(
|
||||
ProjectStartupSource(
|
||||
kind="project_path",
|
||||
origin_ref=str(repo.resolve()),
|
||||
resolved_path=repo.resolve(),
|
||||
)
|
||||
)
|
||||
repo_description = describe_project_startup_source(
|
||||
ProjectStartupSource(
|
||||
kind="repo_url",
|
||||
origin_ref="https://example.com/repo.git",
|
||||
repo_ref="main",
|
||||
)
|
||||
)
|
||||
|
||||
assert project_description == f"the current project at {repo.resolve()}"
|
||||
assert repo_description == "the clean clone source https://example.com/repo.git at ref main"
|
||||
|
||||
|
||||
def test_describe_project_startup_source_handles_none_and_repo_without_ref() -> None:
|
||||
assert describe_project_startup_source(None) is None
|
||||
assert (
|
||||
describe_project_startup_source(
|
||||
ProjectStartupSource(kind="repo_url", origin_ref="https://example.com/repo.git")
|
||||
)
|
||||
== "the clean clone source https://example.com/repo.git"
|
||||
)
|
||||
|
||||
|
||||
def test_detect_git_root_returns_none_for_empty_stdout(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
project_startup,
|
||||
"_run_git",
|
||||
lambda command, *, cwd=None: subprocess.CompletedProcess(command, 0, "\n", ""),
|
||||
)
|
||||
|
||||
assert project_startup._detect_git_root(Path.cwd()) is None
|
||||
|
|
@ -15,9 +15,16 @@ from pyro_mcp.cli import _build_parser
|
|||
from pyro_mcp.contract import (
|
||||
PUBLIC_CLI_COMMANDS,
|
||||
PUBLIC_CLI_DEMO_SUBCOMMANDS,
|
||||
PUBLIC_CLI_DOCTOR_FLAGS,
|
||||
PUBLIC_CLI_ENV_SUBCOMMANDS,
|
||||
PUBLIC_CLI_HOST_CONNECT_FLAGS,
|
||||
PUBLIC_CLI_HOST_DOCTOR_FLAGS,
|
||||
PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS,
|
||||
PUBLIC_CLI_HOST_REPAIR_FLAGS,
|
||||
PUBLIC_CLI_HOST_SUBCOMMANDS,
|
||||
PUBLIC_CLI_MCP_SERVE_FLAGS,
|
||||
PUBLIC_CLI_MCP_SUBCOMMANDS,
|
||||
PUBLIC_CLI_PREPARE_FLAGS,
|
||||
PUBLIC_CLI_RUN_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_CREATE_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_DIFF_FLAGS,
|
||||
|
|
@ -53,10 +60,16 @@ from pyro_mcp.contract import (
|
|||
PUBLIC_CLI_WORKSPACE_START_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_STOP_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SUBCOMMANDS,
|
||||
PUBLIC_CLI_WORKSPACE_SUMMARY_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS,
|
||||
PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS,
|
||||
PUBLIC_MCP_COLD_START_MODE_TOOLS,
|
||||
PUBLIC_MCP_INSPECT_MODE_TOOLS,
|
||||
PUBLIC_MCP_MODES,
|
||||
PUBLIC_MCP_PROFILES,
|
||||
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
|
||||
PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS,
|
||||
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
|
||||
PUBLIC_SDK_METHODS,
|
||||
)
|
||||
|
|
@ -102,6 +115,35 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
|
|||
env_help_text = _subparser_choice(parser, "env").format_help()
|
||||
for subcommand_name in PUBLIC_CLI_ENV_SUBCOMMANDS:
|
||||
assert subcommand_name in env_help_text
|
||||
prepare_help_text = _subparser_choice(parser, "prepare").format_help()
|
||||
for flag in PUBLIC_CLI_PREPARE_FLAGS:
|
||||
assert flag in prepare_help_text
|
||||
host_help_text = _subparser_choice(parser, "host").format_help()
|
||||
for subcommand_name in PUBLIC_CLI_HOST_SUBCOMMANDS:
|
||||
assert subcommand_name in host_help_text
|
||||
host_connect_help_text = _subparser_choice(
|
||||
_subparser_choice(parser, "host"), "connect"
|
||||
).format_help()
|
||||
for flag in PUBLIC_CLI_HOST_CONNECT_FLAGS:
|
||||
assert flag in host_connect_help_text
|
||||
host_doctor_help_text = _subparser_choice(
|
||||
_subparser_choice(parser, "host"), "doctor"
|
||||
).format_help()
|
||||
for flag in PUBLIC_CLI_HOST_DOCTOR_FLAGS:
|
||||
assert flag in host_doctor_help_text
|
||||
host_print_config_help_text = _subparser_choice(
|
||||
_subparser_choice(parser, "host"), "print-config"
|
||||
).format_help()
|
||||
for flag in PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS:
|
||||
assert flag in host_print_config_help_text
|
||||
host_repair_help_text = _subparser_choice(
|
||||
_subparser_choice(parser, "host"), "repair"
|
||||
).format_help()
|
||||
for flag in PUBLIC_CLI_HOST_REPAIR_FLAGS:
|
||||
assert flag in host_repair_help_text
|
||||
doctor_help_text = _subparser_choice(parser, "doctor").format_help()
|
||||
for flag in PUBLIC_CLI_DOCTOR_FLAGS:
|
||||
assert flag in doctor_help_text
|
||||
mcp_help_text = _subparser_choice(parser, "mcp").format_help()
|
||||
for subcommand_name in PUBLIC_CLI_MCP_SUBCOMMANDS:
|
||||
assert subcommand_name in mcp_help_text
|
||||
|
|
@ -110,6 +152,8 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
|
|||
assert flag in mcp_serve_help_text
|
||||
for profile_name in PUBLIC_MCP_PROFILES:
|
||||
assert profile_name in mcp_serve_help_text
|
||||
for mode_name in PUBLIC_MCP_MODES:
|
||||
assert mode_name in mcp_serve_help_text
|
||||
|
||||
workspace_help_text = _subparser_choice(parser, "workspace").format_help()
|
||||
for subcommand_name in PUBLIC_CLI_WORKSPACE_SUBCOMMANDS:
|
||||
|
|
@ -244,6 +288,11 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
|
|||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_STOP_FLAGS:
|
||||
assert flag in workspace_stop_help_text
|
||||
workspace_summary_help_text = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "summary"
|
||||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_SUMMARY_FLAGS:
|
||||
assert flag in workspace_summary_help_text
|
||||
workspace_shell_help_text = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"),
|
||||
"shell",
|
||||
|
|
@ -338,6 +387,14 @@ def test_public_mcp_tools_match_contract(tmp_path: Path) -> None:
|
|||
assert tool_names == tuple(sorted(PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS))
|
||||
|
||||
|
||||
def test_public_mcp_modes_are_declared_and_non_empty() -> None:
|
||||
assert PUBLIC_MCP_MODES == ("repro-fix", "inspect", "cold-start", "review-eval")
|
||||
assert PUBLIC_MCP_REPRO_FIX_MODE_TOOLS
|
||||
assert PUBLIC_MCP_INSPECT_MODE_TOOLS
|
||||
assert PUBLIC_MCP_COLD_START_MODE_TOOLS
|
||||
assert PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS
|
||||
|
||||
|
||||
def test_pyproject_exposes_single_public_cli_script() -> None:
|
||||
pyproject = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
|
||||
scripts = pyproject["project"]["scripts"]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,32 @@ from pathlib import Path
|
|||
|
||||
import pytest
|
||||
|
||||
from pyro_mcp.daily_loop import DailyLoopManifest, prepare_manifest_path, write_prepare_manifest
|
||||
from pyro_mcp.runtime import doctor_report, resolve_runtime_paths, runtime_capabilities
|
||||
from pyro_mcp.vm_environments import EnvironmentStore, get_environment
|
||||
|
||||
|
||||
def _materialize_installed_environment(
|
||||
environment_store: EnvironmentStore,
|
||||
*,
|
||||
name: str,
|
||||
) -> None:
|
||||
spec = get_environment(name, runtime_paths=environment_store._runtime_paths)
|
||||
install_dir = environment_store._install_dir(spec)
|
||||
install_dir.mkdir(parents=True, exist_ok=True)
|
||||
(install_dir / "vmlinux").write_text("kernel\n", encoding="utf-8")
|
||||
(install_dir / "rootfs.ext4").write_text("rootfs\n", encoding="utf-8")
|
||||
(install_dir / "environment.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"name": spec.name,
|
||||
"version": spec.version,
|
||||
"source": "test-cache",
|
||||
"source_digest": spec.source_digest,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_runtime_paths_default_bundle() -> None:
|
||||
|
|
@ -109,6 +134,7 @@ def test_doctor_report_has_runtime_fields() -> None:
|
|||
assert "runtime_ok" in report
|
||||
assert "kvm" in report
|
||||
assert "networking" in report
|
||||
assert "daily_loop" in report
|
||||
if report["runtime_ok"]:
|
||||
runtime = report.get("runtime")
|
||||
assert isinstance(runtime, dict)
|
||||
|
|
@ -122,6 +148,61 @@ def test_doctor_report_has_runtime_fields() -> None:
|
|||
assert "tun_available" in networking
|
||||
|
||||
|
||||
def test_doctor_report_daily_loop_statuses(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setenv("PYRO_ENVIRONMENT_CACHE_DIR", str(tmp_path))
|
||||
cold_report = doctor_report(environment="debian:12")
|
||||
cold_daily_loop = cold_report["daily_loop"]
|
||||
assert cold_daily_loop["status"] == "cold"
|
||||
assert cold_daily_loop["installed"] is False
|
||||
|
||||
paths = resolve_runtime_paths()
|
||||
environment_store = EnvironmentStore(runtime_paths=paths, cache_dir=tmp_path)
|
||||
_materialize_installed_environment(environment_store, name="debian:12")
|
||||
|
||||
installed_report = doctor_report(environment="debian:12")
|
||||
installed_daily_loop = installed_report["daily_loop"]
|
||||
assert installed_daily_loop["status"] == "cold"
|
||||
assert installed_daily_loop["installed"] is True
|
||||
|
||||
manifest_path = prepare_manifest_path(
|
||||
tmp_path,
|
||||
platform="linux-x86_64",
|
||||
environment="debian:12",
|
||||
)
|
||||
write_prepare_manifest(
|
||||
manifest_path,
|
||||
DailyLoopManifest(
|
||||
environment="debian:12",
|
||||
environment_version="1.0.0",
|
||||
platform="linux-x86_64",
|
||||
catalog_version=environment_store.catalog_version,
|
||||
bundle_version=(
|
||||
None
|
||||
if paths.manifest.get("bundle_version") is None
|
||||
else str(paths.manifest.get("bundle_version"))
|
||||
),
|
||||
prepared_at=123.0,
|
||||
network_prepared=True,
|
||||
last_prepare_duration_ms=456,
|
||||
),
|
||||
)
|
||||
warm_report = doctor_report(environment="debian:12")
|
||||
warm_daily_loop = warm_report["daily_loop"]
|
||||
assert warm_daily_loop["status"] == "warm"
|
||||
assert warm_daily_loop["network_prepared"] is True
|
||||
|
||||
stale_manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
stale_manifest["catalog_version"] = "0.0.0"
|
||||
manifest_path.write_text(json.dumps(stale_manifest), encoding="utf-8")
|
||||
stale_report = doctor_report(environment="debian:12")
|
||||
stale_daily_loop = stale_report["daily_loop"]
|
||||
assert stale_daily_loop["status"] == "stale"
|
||||
assert "catalog version changed" in str(stale_daily_loop["reason"])
|
||||
|
||||
|
||||
def test_runtime_capabilities_reports_real_bundle_flags() -> None:
|
||||
paths = resolve_runtime_paths()
|
||||
capabilities = runtime_capabilities(paths)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
|
|
@ -8,6 +9,8 @@ import pytest
|
|||
|
||||
import pyro_mcp.server as server_module
|
||||
from pyro_mcp.contract import (
|
||||
PUBLIC_MCP_COLD_START_MODE_TOOLS,
|
||||
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
|
||||
PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
|
||||
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
|
||||
)
|
||||
|
|
@ -16,6 +19,28 @@ from pyro_mcp.vm_manager import VmManager
|
|||
from pyro_mcp.vm_network import TapNetworkManager
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
result = subprocess.run( # noqa: S603
|
||||
["git", "-c", "commit.gpgsign=false", *args],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _make_repo(root: Path, *, content: str = "hello\n") -> Path:
|
||||
root.mkdir()
|
||||
_git(root, "init")
|
||||
_git(root, "config", "user.name", "Pyro Tests")
|
||||
_git(root, "config", "user.email", "pyro-tests@example.com")
|
||||
(root / "note.txt").write_text(content, encoding="utf-8")
|
||||
_git(root, "add", "note.txt")
|
||||
_git(root, "commit", "-m", "init")
|
||||
return root
|
||||
|
||||
|
||||
def test_create_server_registers_vm_tools(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
|
|
@ -62,6 +87,151 @@ def test_create_server_workspace_core_profile_registers_expected_tools(tmp_path:
|
|||
assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS))
|
||||
|
||||
|
||||
def test_create_server_repro_fix_mode_registers_expected_tools(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
|
||||
async def _run() -> list[str]:
|
||||
server = create_server(manager=manager, mode="repro-fix")
|
||||
tools = await server.list_tools()
|
||||
return sorted(tool.name for tool in tools)
|
||||
|
||||
assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_REPRO_FIX_MODE_TOOLS))
|
||||
|
||||
|
||||
def test_create_server_cold_start_mode_registers_expected_tools(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
|
||||
async def _run() -> list[str]:
|
||||
server = create_server(manager=manager, mode="cold-start")
|
||||
tools = await server.list_tools()
|
||||
return sorted(tool.name for tool in tools)
|
||||
|
||||
assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_COLD_START_MODE_TOOLS))
|
||||
|
||||
|
||||
def test_create_server_workspace_create_description_mentions_project_source(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
repo = _make_repo(tmp_path / "repo")
|
||||
|
||||
async def _run() -> dict[str, Any]:
|
||||
server = create_server(manager=manager, project_path=repo)
|
||||
tools = await server.list_tools()
|
||||
tool_map = {tool.name: tool.model_dump() for tool in tools}
|
||||
return tool_map["workspace_create"]
|
||||
|
||||
workspace_create = asyncio.run(_run())
|
||||
description = cast(str, workspace_create["description"])
|
||||
assert "If `seed_path` is omitted" in description
|
||||
assert str(repo.resolve()) in description
|
||||
|
||||
|
||||
def test_create_server_project_path_seeds_workspace_when_seed_path_is_omitted(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
repo = _make_repo(tmp_path / "repo", content="project-aware\n")
|
||||
|
||||
def _extract_structured(raw_result: object) -> dict[str, Any]:
|
||||
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
|
||||
raise TypeError("unexpected call_tool result shape")
|
||||
_, structured = raw_result
|
||||
if not isinstance(structured, dict):
|
||||
raise TypeError("expected structured dictionary result")
|
||||
return cast(dict[str, Any], structured)
|
||||
|
||||
async def _run() -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
server = create_server(manager=manager, project_path=repo)
|
||||
created = _extract_structured(
|
||||
await server.call_tool(
|
||||
"workspace_create",
|
||||
{
|
||||
"environment": "debian:12-base",
|
||||
"allow_host_compat": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
executed = _extract_structured(
|
||||
await server.call_tool(
|
||||
"workspace_exec",
|
||||
{
|
||||
"workspace_id": created["workspace_id"],
|
||||
"command": "cat note.txt",
|
||||
},
|
||||
)
|
||||
)
|
||||
return created, executed
|
||||
|
||||
created, executed = asyncio.run(_run())
|
||||
assert created["workspace_seed"]["mode"] == "directory"
|
||||
assert created["workspace_seed"]["seed_path"] == str(repo.resolve())
|
||||
assert created["workspace_seed"]["origin_kind"] == "project_path"
|
||||
assert created["workspace_seed"]["origin_ref"] == str(repo.resolve())
|
||||
assert executed["stdout"] == "project-aware\n"
|
||||
|
||||
|
||||
def test_create_server_repo_url_seeds_workspace_when_seed_path_is_omitted(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
repo = _make_repo(tmp_path / "repo", content="committed\n")
|
||||
(repo / "note.txt").write_text("dirty\n", encoding="utf-8")
|
||||
|
||||
def _extract_structured(raw_result: object) -> dict[str, Any]:
|
||||
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
|
||||
raise TypeError("unexpected call_tool result shape")
|
||||
_, structured = raw_result
|
||||
if not isinstance(structured, dict):
|
||||
raise TypeError("expected structured dictionary result")
|
||||
return cast(dict[str, Any], structured)
|
||||
|
||||
async def _run() -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
server = create_server(manager=manager, repo_url=str(repo.resolve()))
|
||||
created = _extract_structured(
|
||||
await server.call_tool(
|
||||
"workspace_create",
|
||||
{
|
||||
"environment": "debian:12-base",
|
||||
"allow_host_compat": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
executed = _extract_structured(
|
||||
await server.call_tool(
|
||||
"workspace_exec",
|
||||
{
|
||||
"workspace_id": created["workspace_id"],
|
||||
"command": "cat note.txt",
|
||||
},
|
||||
)
|
||||
)
|
||||
return created, executed
|
||||
|
||||
created, executed = asyncio.run(_run())
|
||||
assert created["workspace_seed"]["mode"] == "directory"
|
||||
assert created["workspace_seed"]["seed_path"] is None
|
||||
assert created["workspace_seed"]["origin_kind"] == "repo_url"
|
||||
assert created["workspace_seed"]["origin_ref"] == str(repo.resolve())
|
||||
assert executed["stdout"] == "committed\n"
|
||||
|
||||
|
||||
def test_vm_run_round_trip(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
|
|
@ -464,6 +634,9 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
},
|
||||
)
|
||||
)
|
||||
summary = _extract_structured(
|
||||
await server.call_tool("workspace_summary", {"workspace_id": workspace_id})
|
||||
)
|
||||
reset = _extract_structured(
|
||||
await server.call_tool(
|
||||
"workspace_reset",
|
||||
|
|
@ -501,6 +674,7 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
service_status,
|
||||
service_logs,
|
||||
service_stopped,
|
||||
summary,
|
||||
reset,
|
||||
deleted_snapshot,
|
||||
logs,
|
||||
|
|
@ -526,6 +700,7 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
service_status,
|
||||
service_logs,
|
||||
service_stopped,
|
||||
summary,
|
||||
reset,
|
||||
deleted_snapshot,
|
||||
logs,
|
||||
|
|
@ -562,6 +737,10 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
assert service_logs["stderr"].count("[REDACTED]") >= 1
|
||||
assert service_logs["tail_lines"] is None
|
||||
assert service_stopped["state"] == "stopped"
|
||||
assert summary["workspace_id"] == created["workspace_id"]
|
||||
assert summary["commands"]["total"] >= 1
|
||||
assert summary["changes"]["available"] is True
|
||||
assert summary["artifacts"]["exports"][0]["workspace_path"] == "/workspace/subdir/more.txt"
|
||||
assert reset["workspace_reset"]["snapshot_name"] == "checkpoint"
|
||||
assert reset["secrets"] == created["secrets"]
|
||||
assert reset["command_count"] == 0
|
||||
|
|
|
|||
|
|
@ -699,6 +699,124 @@ def test_workspace_diff_and_export_round_trip(tmp_path: Path) -> None:
|
|||
assert logs["count"] == 0
|
||||
|
||||
|
||||
def test_workspace_summary_synthesizes_current_session(tmp_path: Path) -> None:
|
||||
seed_dir = tmp_path / "seed"
|
||||
seed_dir.mkdir()
|
||||
(seed_dir / "note.txt").write_text("hello\n", encoding="utf-8")
|
||||
update_dir = tmp_path / "update"
|
||||
update_dir.mkdir()
|
||||
(update_dir / "more.txt").write_text("more\n", encoding="utf-8")
|
||||
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
|
||||
workspace_id = str(
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
seed_path=seed_dir,
|
||||
name="review-eval",
|
||||
labels={"suite": "smoke"},
|
||||
)["workspace_id"]
|
||||
)
|
||||
manager.push_workspace_sync(workspace_id, source_path=update_dir)
|
||||
manager.write_workspace_file(workspace_id, "src/app.py", text="print('hello')\n")
|
||||
manager.apply_workspace_patch(
|
||||
workspace_id,
|
||||
patch=(
|
||||
"--- a/note.txt\n"
|
||||
"+++ b/note.txt\n"
|
||||
"@@ -1 +1 @@\n"
|
||||
"-hello\n"
|
||||
"+patched\n"
|
||||
),
|
||||
)
|
||||
manager.exec_workspace(workspace_id, command="cat note.txt", timeout_seconds=30)
|
||||
manager.create_snapshot(workspace_id, "checkpoint")
|
||||
export_path = tmp_path / "exported-note.txt"
|
||||
manager.export_workspace(workspace_id, "note.txt", output_path=export_path)
|
||||
manager.start_service(
|
||||
workspace_id,
|
||||
"app",
|
||||
command='sh -lc \'trap "exit 0" TERM; touch .ready; while true; do sleep 60; done\'',
|
||||
readiness={"type": "file", "path": ".ready"},
|
||||
)
|
||||
manager.stop_service(workspace_id, "app")
|
||||
|
||||
summary = manager.summarize_workspace(workspace_id)
|
||||
|
||||
assert summary["workspace_id"] == workspace_id
|
||||
assert summary["name"] == "review-eval"
|
||||
assert summary["labels"] == {"suite": "smoke"}
|
||||
assert summary["outcome"]["command_count"] == 1
|
||||
assert summary["outcome"]["export_count"] == 1
|
||||
assert summary["outcome"]["snapshot_count"] == 1
|
||||
assert summary["commands"]["total"] == 1
|
||||
assert summary["commands"]["recent"][0]["command"] == "cat note.txt"
|
||||
assert [event["event_kind"] for event in summary["edits"]["recent"]] == [
|
||||
"patch_apply",
|
||||
"file_write",
|
||||
"sync_push",
|
||||
]
|
||||
assert summary["changes"]["available"] is True
|
||||
assert summary["changes"]["changed"] is True
|
||||
assert summary["changes"]["summary"]["total"] == 4
|
||||
assert summary["services"]["current"][0]["service_name"] == "app"
|
||||
assert [event["event_kind"] for event in summary["services"]["recent"]] == [
|
||||
"service_stop",
|
||||
"service_start",
|
||||
]
|
||||
assert summary["artifacts"]["exports"][0]["workspace_path"] == "/workspace/note.txt"
|
||||
assert summary["snapshots"]["named_count"] == 1
|
||||
assert summary["snapshots"]["recent"][0]["snapshot_name"] == "checkpoint"
|
||||
|
||||
|
||||
def test_workspace_summary_degrades_gracefully_for_stopped_and_legacy_workspaces(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
seed_dir = tmp_path / "seed"
|
||||
seed_dir.mkdir()
|
||||
(seed_dir / "note.txt").write_text("hello\n", encoding="utf-8")
|
||||
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
|
||||
stopped_workspace_id = str(
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
seed_path=seed_dir,
|
||||
)["workspace_id"]
|
||||
)
|
||||
manager.exec_workspace(stopped_workspace_id, command="cat note.txt", timeout_seconds=30)
|
||||
manager.stop_workspace(stopped_workspace_id)
|
||||
stopped_summary = manager.summarize_workspace(stopped_workspace_id)
|
||||
assert stopped_summary["commands"]["total"] == 1
|
||||
assert stopped_summary["changes"]["available"] is False
|
||||
assert "must be in 'started' state" in str(stopped_summary["changes"]["reason"])
|
||||
|
||||
legacy_workspace_id = str(
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
seed_path=seed_dir,
|
||||
)["workspace_id"]
|
||||
)
|
||||
baseline_path = (
|
||||
tmp_path / "vms" / "workspaces" / legacy_workspace_id / "baseline" / "workspace.tar"
|
||||
)
|
||||
baseline_path.unlink()
|
||||
legacy_summary = manager.summarize_workspace(legacy_workspace_id)
|
||||
assert legacy_summary["changes"]["available"] is False
|
||||
assert "baseline snapshot" in str(legacy_summary["changes"]["reason"])
|
||||
|
||||
|
||||
def test_workspace_file_ops_and_patch_round_trip(tmp_path: Path) -> None:
|
||||
seed_dir = tmp_path / "seed"
|
||||
seed_dir.mkdir()
|
||||
|
|
|
|||
|
|
@ -15,6 +15,13 @@ import pytest
|
|||
from pyro_mcp import workspace_ports
|
||||
|
||||
|
||||
def _socketpair_or_skip() -> tuple[socket.socket, socket.socket]:
|
||||
try:
|
||||
return socket.socketpair()
|
||||
except PermissionError as exc:
|
||||
pytest.skip(f"socketpair unavailable in this environment: {exc}")
|
||||
|
||||
|
||||
class _EchoHandler(socketserver.BaseRequestHandler):
|
||||
def handle(self) -> None:
|
||||
data = self.request.recv(65536)
|
||||
|
|
@ -50,18 +57,26 @@ def test_workspace_port_proxy_handler_ignores_upstream_connect_failure(
|
|||
|
||||
|
||||
def test_workspace_port_proxy_forwards_tcp_traffic() -> None:
|
||||
upstream = socketserver.ThreadingTCPServer(
|
||||
(workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0),
|
||||
_EchoHandler,
|
||||
)
|
||||
try:
|
||||
upstream = socketserver.ThreadingTCPServer(
|
||||
(workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0),
|
||||
_EchoHandler,
|
||||
)
|
||||
except PermissionError as exc:
|
||||
pytest.skip(f"TCP bind unavailable in this environment: {exc}")
|
||||
upstream_thread = threading.Thread(target=upstream.serve_forever, daemon=True)
|
||||
upstream_thread.start()
|
||||
upstream_host = str(upstream.server_address[0])
|
||||
upstream_port = int(upstream.server_address[1])
|
||||
proxy = workspace_ports._ProxyServer( # noqa: SLF001
|
||||
(workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0),
|
||||
(upstream_host, upstream_port),
|
||||
)
|
||||
try:
|
||||
proxy = workspace_ports._ProxyServer( # noqa: SLF001
|
||||
(workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0),
|
||||
(upstream_host, upstream_port),
|
||||
)
|
||||
except PermissionError as exc:
|
||||
upstream.shutdown()
|
||||
upstream.server_close()
|
||||
pytest.skip(f"proxy TCP bind unavailable in this environment: {exc}")
|
||||
proxy_thread = threading.Thread(target=proxy.serve_forever, daemon=True)
|
||||
proxy_thread.start()
|
||||
try:
|
||||
|
|
@ -202,8 +217,8 @@ def test_workspace_ports_main_shutdown_handler_stops_server(
|
|||
def test_workspace_port_proxy_handler_handles_empty_and_invalid_selector_events(
|
||||
monkeypatch: Any,
|
||||
) -> None:
|
||||
source, source_peer = socket.socketpair()
|
||||
upstream, upstream_peer = socket.socketpair()
|
||||
source, source_peer = _socketpair_or_skip()
|
||||
upstream, upstream_peer = _socketpair_or_skip()
|
||||
source_peer.close()
|
||||
|
||||
class FakeSelector:
|
||||
|
|
@ -246,10 +261,17 @@ def test_workspace_port_proxy_handler_handles_recv_and_send_errors(
|
|||
monkeypatch: Any,
|
||||
) -> None:
|
||||
def _run_once(*, close_source: bool) -> None:
|
||||
source, source_peer = socket.socketpair()
|
||||
upstream, upstream_peer = socket.socketpair()
|
||||
source, source_peer = _socketpair_or_skip()
|
||||
upstream, upstream_peer = _socketpair_or_skip()
|
||||
if not close_source:
|
||||
source_peer.sendall(b"hello")
|
||||
try:
|
||||
source_peer.sendall(b"hello")
|
||||
except PermissionError as exc:
|
||||
source.close()
|
||||
source_peer.close()
|
||||
upstream.close()
|
||||
upstream_peer.close()
|
||||
pytest.skip(f"socket send unavailable in this environment: {exc}")
|
||||
|
||||
class FakeSelector:
|
||||
def register(self, *_args: Any, **_kwargs: Any) -> None:
|
||||
|
|
|
|||
|
|
@ -391,6 +391,69 @@ class _FakePyro:
|
|||
"workspace_reset": {"snapshot_name": snapshot},
|
||||
}
|
||||
|
||||
def summarize_workspace(self, workspace_id: str) -> dict[str, Any]:
|
||||
workspace = self._resolve_workspace(workspace_id)
|
||||
changed = self._diff_changed(workspace)
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"name": workspace.name,
|
||||
"labels": dict(workspace.labels),
|
||||
"environment": workspace.environment,
|
||||
"state": "started",
|
||||
"last_activity_at": workspace.last_activity_at,
|
||||
"session_started_at": workspace.created_at,
|
||||
"outcome": {
|
||||
"command_count": 0,
|
||||
"last_command": None,
|
||||
"service_count": len(workspace.services),
|
||||
"running_service_count": sum(
|
||||
1
|
||||
for service in workspace.services.values()
|
||||
if service["state"] == "running"
|
||||
),
|
||||
"export_count": 1,
|
||||
"snapshot_count": max(len(workspace.snapshots) - 1, 0),
|
||||
"reset_count": workspace.reset_count,
|
||||
},
|
||||
"commands": {"total": 0, "recent": []},
|
||||
"edits": {"recent": []},
|
||||
"changes": {
|
||||
"available": True,
|
||||
"reason": None,
|
||||
"changed": changed,
|
||||
"summary": {"total": 1 if changed else 0},
|
||||
"entries": (
|
||||
[
|
||||
{
|
||||
"path": "/workspace/artifact.txt",
|
||||
"status": "modified",
|
||||
"artifact_type": "file",
|
||||
}
|
||||
]
|
||||
if changed
|
||||
else []
|
||||
),
|
||||
},
|
||||
"services": {
|
||||
"current": [
|
||||
{"service_name": name, "state": service["state"]}
|
||||
for name, service in sorted(workspace.services.items())
|
||||
],
|
||||
"recent": [],
|
||||
},
|
||||
"artifacts": {
|
||||
"exports": [
|
||||
{
|
||||
"workspace_path": "review-report.txt",
|
||||
"output_path": str(
|
||||
self._workspace_dir(workspace_id) / "exported-review.txt"
|
||||
),
|
||||
}
|
||||
]
|
||||
},
|
||||
"snapshots": {"named_count": max(len(workspace.snapshots) - 1, 0), "recent": []},
|
||||
}
|
||||
|
||||
def open_shell(self, workspace_id: str, **_: Any) -> dict[str, Any]:
|
||||
workspace = self._resolve_workspace(workspace_id)
|
||||
self._shell_counter += 1
|
||||
|
|
@ -436,6 +499,43 @@ class _FakePyro:
|
|||
workspace.shells.pop(shell_id, None)
|
||||
return {"workspace_id": workspace_id, "shell_id": shell_id, "closed": True}
|
||||
|
||||
def create_server(
|
||||
self,
|
||||
*,
|
||||
profile: str = "workspace-core",
|
||||
mode: str | None = None,
|
||||
project_path: Path,
|
||||
) -> Any:
|
||||
assert profile == "workspace-core"
|
||||
assert mode in {"repro-fix", "cold-start"}
|
||||
seed_path = Path(project_path)
|
||||
outer = self
|
||||
|
||||
class _FakeServer:
|
||||
async def call_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
) -> tuple[None, dict[str, Any]]:
|
||||
if tool_name != "workspace_create":
|
||||
raise AssertionError(f"unexpected tool call: {tool_name}")
|
||||
result = outer.create_workspace(
|
||||
environment=cast(str, arguments["environment"]),
|
||||
seed_path=seed_path,
|
||||
name=cast(str | None, arguments.get("name")),
|
||||
labels=cast(dict[str, str] | None, arguments.get("labels")),
|
||||
)
|
||||
created = outer.status_workspace(cast(str, result["workspace_id"]))
|
||||
created["workspace_seed"] = {
|
||||
"mode": "directory",
|
||||
"seed_path": str(seed_path.resolve()),
|
||||
"origin_kind": "project_path",
|
||||
"origin_ref": str(seed_path.resolve()),
|
||||
}
|
||||
return None, created
|
||||
|
||||
return _FakeServer()
|
||||
|
||||
|
||||
def test_use_case_registry_has_expected_scenarios() -> None:
|
||||
expected = (
|
||||
|
|
@ -461,7 +561,7 @@ def test_use_case_docs_and_targets_stay_aligned() -> None:
|
|||
recipe_text = (repo_root / recipe.doc_path).read_text(encoding="utf-8")
|
||||
assert recipe.smoke_target in index_text
|
||||
assert recipe.doc_path.rsplit("/", 1)[-1] in index_text
|
||||
assert recipe.profile in recipe_text
|
||||
assert recipe.mode in recipe_text
|
||||
assert recipe.smoke_target in recipe_text
|
||||
assert f"{recipe.smoke_target}:" in makefile_text
|
||||
|
||||
|
|
|
|||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -715,7 +715,7 @@ crypto = [
|
|||
|
||||
[[package]]
|
||||
name = "pyro-mcp"
|
||||
version = "4.0.0"
|
||||
version = "4.5.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "mcp" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue