Add daily-loop prepare and readiness checks
Make the local chat-host loop explicit and cheap so users can warm the machine once instead of rediscovering environment and guest setup on every session. Add cache-backed daily-loop manifests plus the new `pyro prepare` flow, extend `pyro doctor --environment` with warm/cold/stale readiness reporting, and add `make smoke-daily-loop` to prove the warmed repro-fix reset path end to end. Also fix `python -m pyro_mcp.cli` to invoke `main()` so the new smoke and `dist-check` actually exercise the CLI module, and update the docs/roadmap to present `doctor -> prepare -> connect host -> reset` as the recommended daily path. Validation: `uv lock`, `UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make check`, `UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make dist-check`, and `UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make smoke-daily-loop`.
This commit is contained in:
parent
d0cf6d8f21
commit
663241d5d2
26 changed files with 1592 additions and 199 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -2,6 +2,17 @@
|
||||||
|
|
||||||
All notable user-visible changes to `pyro-mcp` are documented here.
|
All notable user-visible changes to `pyro-mcp` are documented here.
|
||||||
|
|
||||||
|
## 4.5.0
|
||||||
|
|
||||||
|
- Added `pyro prepare` as the machine-level warmup path for the daily local
|
||||||
|
loop, with cached reuse when the runtime, catalog, and environment state are
|
||||||
|
already warm.
|
||||||
|
- Extended `pyro doctor` with daily-loop readiness output so users can see
|
||||||
|
whether the machine is cold, warm, or stale for `debian:12` before they
|
||||||
|
reconnect a chat host.
|
||||||
|
- Added `make smoke-daily-loop` to prove the warmed repro/fix/reset path end
|
||||||
|
to end on a real guest-backed machine.
|
||||||
|
|
||||||
## 4.4.0
|
## 4.4.0
|
||||||
|
|
||||||
- Added explicit named MCP/server modes for the main workspace workflows:
|
- Added explicit named MCP/server modes for the main workspace workflows:
|
||||||
|
|
|
||||||
10
Makefile
10
Makefile
|
|
@ -18,8 +18,9 @@ TWINE_USERNAME ?= __token__
|
||||||
PYPI_REPOSITORY_URL ?=
|
PYPI_REPOSITORY_URL ?=
|
||||||
USE_CASE_ENVIRONMENT ?= debian:12
|
USE_CASE_ENVIRONMENT ?= debian:12
|
||||||
USE_CASE_SMOKE_FLAGS ?=
|
USE_CASE_SMOKE_FLAGS ?=
|
||||||
|
DAILY_LOOP_ENVIRONMENT ?= debian:12
|
||||||
|
|
||||||
.PHONY: help setup lint format typecheck test check dist-check pypi-publish demo network-demo doctor ollama ollama-demo run-server install-hooks smoke-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:
|
help:
|
||||||
@printf '%s\n' \
|
@printf '%s\n' \
|
||||||
|
|
@ -36,6 +37,7 @@ help:
|
||||||
' demo Run the deterministic VM demo' \
|
' demo Run the deterministic VM demo' \
|
||||||
' network-demo Run the deterministic VM demo with guest networking enabled' \
|
' network-demo Run the deterministic VM demo with guest networking enabled' \
|
||||||
' doctor Show runtime and host diagnostics' \
|
' doctor Show runtime and host diagnostics' \
|
||||||
|
' smoke-daily-loop Run the real guest-backed prepare plus reset daily-loop smoke' \
|
||||||
' smoke-use-cases Run all real guest-backed workspace use-case smokes' \
|
' smoke-use-cases Run all real guest-backed workspace use-case smokes' \
|
||||||
' smoke-cold-start-validation Run the cold-start repo validation smoke' \
|
' smoke-cold-start-validation Run the cold-start repo validation smoke' \
|
||||||
' smoke-repro-fix-loop Run the repro-plus-fix loop smoke' \
|
' smoke-repro-fix-loop Run the repro-plus-fix loop smoke' \
|
||||||
|
|
@ -85,13 +87,14 @@ check: lint typecheck test
|
||||||
dist-check:
|
dist-check:
|
||||||
uv run python -m pyro_mcp.cli --version
|
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 --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 --help >/dev/null
|
||||||
uv run python -m pyro_mcp.cli host doctor >/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 mcp --help >/dev/null
|
||||||
uv run python -m pyro_mcp.cli run --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 list >/dev/null
|
||||||
uv run python -m pyro_mcp.cli env inspect debian:12 >/dev/null
|
uv run python -m pyro_mcp.cli env inspect debian:12 >/dev/null
|
||||||
uv run python -m pyro_mcp.cli doctor >/dev/null
|
uv run python -m pyro_mcp.cli doctor --environment debian:12 >/dev/null
|
||||||
|
|
||||||
pypi-publish:
|
pypi-publish:
|
||||||
@if [ -z "$$TWINE_PASSWORD" ]; then \
|
@if [ -z "$$TWINE_PASSWORD" ]; then \
|
||||||
|
|
@ -116,6 +119,9 @@ network-demo:
|
||||||
doctor:
|
doctor:
|
||||||
uv run pyro doctor
|
uv run pyro doctor
|
||||||
|
|
||||||
|
smoke-daily-loop:
|
||||||
|
uv run python scripts/daily_loop_smoke.py --environment "$(DAILY_LOOP_ENVIRONMENT)"
|
||||||
|
|
||||||
smoke-use-cases:
|
smoke-use-cases:
|
||||||
uv run python scripts/workspace_use_case_smoke.py --scenario all --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS)
|
uv run python scripts/workspace_use_case_smoke.py --scenario all --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS)
|
||||||
|
|
||||||
|
|
|
||||||
33
README.md
33
README.md
|
|
@ -30,7 +30,7 @@ SDK-first platform.
|
||||||
- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md)
|
- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md)
|
||||||
- Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif)
|
- Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif)
|
||||||
- Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif)
|
- Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif)
|
||||||
- What's new in 4.4.0: [CHANGELOG.md#440](CHANGELOG.md#440)
|
- What's new in 4.5.0: [CHANGELOG.md#450](CHANGELOG.md#450)
|
||||||
- PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/)
|
- PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/)
|
||||||
|
|
||||||
## Who It's For
|
## Who It's For
|
||||||
|
|
@ -54,8 +54,7 @@ Use either of these equivalent quickstart paths:
|
||||||
# Package without install
|
# Package without install
|
||||||
python -m pip install uv
|
python -m pip install uv
|
||||||
uvx --from pyro-mcp pyro doctor
|
uvx --from pyro-mcp pyro doctor
|
||||||
uvx --from pyro-mcp pyro env list
|
uvx --from pyro-mcp pyro prepare debian:12
|
||||||
uvx --from pyro-mcp pyro env pull debian:12
|
|
||||||
uvx --from pyro-mcp pyro run debian:12 -- git --version
|
uvx --from pyro-mcp pyro run debian:12 -- git --version
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -64,8 +63,7 @@ uvx --from pyro-mcp pyro run debian:12 -- git --version
|
||||||
```bash
|
```bash
|
||||||
# Already installed
|
# Already installed
|
||||||
pyro doctor
|
pyro doctor
|
||||||
pyro env list
|
pyro prepare debian:12
|
||||||
pyro env pull debian:12
|
|
||||||
pyro run debian:12 -- git --version
|
pyro run debian:12 -- git --version
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -91,12 +89,21 @@ git version ...
|
||||||
|
|
||||||
The first pull downloads an OCI environment from public Docker Hub, requires
|
The first pull downloads an OCI environment from public Docker Hub, requires
|
||||||
outbound HTTPS access to `registry-1.docker.io`, and needs local cache space
|
outbound HTTPS access to `registry-1.docker.io`, and needs local cache space
|
||||||
for the guest image.
|
for the guest image. `pyro prepare debian:12` performs that install step
|
||||||
|
automatically, then proves create, exec, reset, and delete on one throwaway
|
||||||
|
workspace so the daily loop is warm before the chat host connects.
|
||||||
|
|
||||||
## Chat Host Quickstart
|
## Chat Host Quickstart
|
||||||
|
|
||||||
After the quickstart works, the intended next step is to connect a chat host in
|
After the quickstart works, make the daily loop explicit before you connect the
|
||||||
one named mode. Use the helper flow first:
|
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
|
```bash
|
||||||
uvx --from pyro-mcp pyro host connect codex --mode repro-fix
|
uvx --from pyro-mcp pyro host connect codex --mode repro-fix
|
||||||
|
|
@ -198,13 +205,15 @@ snapshots, secrets, network policy, or disk tools.
|
||||||
## Zero To Hero
|
## Zero To Hero
|
||||||
|
|
||||||
1. Validate the host with `pyro doctor`.
|
1. Validate the host with `pyro doctor`.
|
||||||
2. Pull `debian:12` and prove guest execution with `pyro run debian:12 -- git --version`.
|
2. Warm the machine-level daily loop with `pyro prepare debian:12`.
|
||||||
3. Connect Claude Code, Codex, or OpenCode with one named mode such as
|
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 host connect codex --mode repro-fix`, then fall back to raw
|
||||||
`pyro mcp serve --mode ...` or the generic no-mode path when needed.
|
`pyro mcp serve --mode ...` or the generic no-mode path when needed.
|
||||||
4. Start with one recipe from [docs/use-cases/README.md](docs/use-cases/README.md).
|
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.
|
`repro-fix` is the shortest chat-first mode and story.
|
||||||
5. Use `make smoke-use-cases` as the trustworthy guest-backed verification path
|
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.
|
for the advertised workflows.
|
||||||
|
|
||||||
That is the intended user journey. The terminal commands exist to validate and
|
That is the intended user journey. The terminal commands exist to validate and
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,16 @@ path is still being shaped.
|
||||||
## 1. Verify the host
|
## 1. Verify the host
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ uvx --from pyro-mcp pyro doctor
|
$ uvx --from pyro-mcp pyro doctor --environment debian:12
|
||||||
Platform: linux-x86_64
|
Platform: linux-x86_64
|
||||||
Runtime: PASS
|
Runtime: PASS
|
||||||
KVM: exists=yes readable=yes writable=yes
|
KVM: exists=yes readable=yes writable=yes
|
||||||
Environment cache: /home/you/.cache/pyro-mcp/environments
|
Environment cache: /home/you/.cache/pyro-mcp/environments
|
||||||
|
Catalog version: 4.5.0
|
||||||
Capabilities: vm_boot=yes guest_exec=yes guest_network=yes
|
Capabilities: vm_boot=yes guest_exec=yes guest_network=yes
|
||||||
Networking: tun=yes ip_forward=yes
|
Networking: tun=yes ip_forward=yes
|
||||||
|
Daily loop: COLD (debian:12)
|
||||||
|
Run: pyro prepare debian:12
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2. Inspect the catalog
|
## 2. Inspect the catalog
|
||||||
|
|
@ -71,6 +74,16 @@ streams, so they may appear in either order in terminals or capture tools. Use
|
||||||
|
|
||||||
## 5. Start the MCP server
|
## 5. Start the MCP server
|
||||||
|
|
||||||
|
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:
|
Use a named mode when one workflow already matches the job:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -191,6 +204,12 @@ That runner creates real guest-backed workspaces, exercises all five documented
|
||||||
stories, exports concrete results where relevant, and cleans up on both success
|
stories, exports concrete results where relevant, and cleans up on both success
|
||||||
and failure.
|
and failure.
|
||||||
|
|
||||||
|
For the machine-level warmup plus retry story specifically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ make smoke-daily-loop
|
||||||
|
```
|
||||||
|
|
||||||
## 9. Optional one-shot demo
|
## 9. Optional one-shot demo
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -46,29 +46,27 @@ Use either of these equivalent evaluator paths:
|
||||||
```bash
|
```bash
|
||||||
# Package without install
|
# Package without install
|
||||||
uvx --from pyro-mcp pyro doctor
|
uvx --from pyro-mcp pyro doctor
|
||||||
uvx --from pyro-mcp pyro env list
|
uvx --from pyro-mcp pyro prepare debian:12
|
||||||
uvx --from pyro-mcp pyro env pull debian:12
|
|
||||||
uvx --from pyro-mcp pyro run debian:12 -- git --version
|
uvx --from pyro-mcp pyro run debian:12 -- git --version
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Already installed
|
# Already installed
|
||||||
pyro doctor
|
pyro doctor
|
||||||
pyro env list
|
pyro prepare debian:12
|
||||||
pyro env pull debian:12
|
|
||||||
pyro run debian:12 -- git --version
|
pyro run debian:12 -- git --version
|
||||||
```
|
```
|
||||||
|
|
||||||
If you are running from a repo checkout instead, replace `pyro` with
|
If you are running from a repo checkout instead, replace `pyro` with
|
||||||
`uv run pyro`.
|
`uv run pyro`.
|
||||||
|
|
||||||
After that one-shot proof works, the intended next step is a named chat mode
|
After that one-shot proof works, the intended next step is a warmed daily loop
|
||||||
through `pyro host connect` or `pyro host print-config`.
|
plus a named chat mode through `pyro host connect` or `pyro host print-config`.
|
||||||
|
|
||||||
## 1. Check the host
|
## 1. Check the host
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uvx --from pyro-mcp pyro doctor
|
uvx --from pyro-mcp pyro doctor --environment debian:12
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected success signals:
|
Expected success signals:
|
||||||
|
|
@ -78,8 +76,11 @@ Platform: linux-x86_64
|
||||||
Runtime: PASS
|
Runtime: PASS
|
||||||
KVM: exists=yes readable=yes writable=yes
|
KVM: exists=yes readable=yes writable=yes
|
||||||
Environment cache: /home/you/.cache/pyro-mcp/environments
|
Environment cache: /home/you/.cache/pyro-mcp/environments
|
||||||
|
Catalog version: 4.5.0
|
||||||
Capabilities: vm_boot=yes guest_exec=yes guest_network=yes
|
Capabilities: vm_boot=yes guest_exec=yes guest_network=yes
|
||||||
Networking: tun=yes ip_forward=yes
|
Networking: tun=yes ip_forward=yes
|
||||||
|
Daily loop: COLD (debian:12)
|
||||||
|
Run: pyro prepare debian:12
|
||||||
```
|
```
|
||||||
|
|
||||||
If `Runtime: FAIL`, stop here and use [troubleshooting.md](troubleshooting.md).
|
If `Runtime: FAIL`, stop here and use [troubleshooting.md](troubleshooting.md).
|
||||||
|
|
@ -139,7 +140,17 @@ 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
|
streams, so they may appear in either order. Use `--json` if you need a
|
||||||
deterministic structured result.
|
deterministic structured result.
|
||||||
|
|
||||||
## 5. Connect a chat host
|
## 5. Warm the daily loop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx --from pyro-mcp pyro prepare debian:12
|
||||||
|
```
|
||||||
|
|
||||||
|
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:
|
Use the helper flow first:
|
||||||
|
|
||||||
|
|
@ -221,23 +232,24 @@ 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,
|
`--profile workspace-full` only when the chat truly needs shells, services,
|
||||||
snapshots, secrets, network policy, or disk tools.
|
snapshots, secrets, network policy, or disk tools.
|
||||||
|
|
||||||
## 6. Go from zero to hero
|
## 7. Go from zero to hero
|
||||||
|
|
||||||
The intended user journey is:
|
The intended user journey is:
|
||||||
|
|
||||||
1. validate the host with `pyro doctor`
|
1. validate the host with `pyro doctor --environment debian:12`
|
||||||
2. pull `debian:12`
|
2. warm the machine with `pyro prepare debian:12`
|
||||||
3. prove guest execution with `pyro run debian:12 -- git --version`
|
3. prove guest execution with `pyro run debian:12 -- git --version`
|
||||||
4. connect Claude Code, Codex, or OpenCode with one named mode such as
|
4. connect Claude Code, Codex, or OpenCode with one named mode such as
|
||||||
`pyro host connect codex --mode repro-fix`, then use raw
|
`pyro host connect codex --mode repro-fix`, then use raw
|
||||||
`pyro mcp serve --mode ...` or the generic no-mode path when needed
|
`pyro mcp serve --mode ...` or the generic no-mode path when needed
|
||||||
5. start with one use-case recipe from [use-cases/README.md](use-cases/README.md)
|
5. use `workspace reset` as the normal retry step inside that warmed loop
|
||||||
6. trust but verify with `make smoke-use-cases`
|
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
|
If you want the shortest chat-first story, start with
|
||||||
[use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md).
|
[use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md).
|
||||||
|
|
||||||
## 7. Manual terminal workspace flow
|
## 8. Manual terminal workspace flow
|
||||||
|
|
||||||
If you want to inspect the workspace model directly from the terminal, use the
|
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
|
companion flow below. This is for understanding and debugging the chat-host
|
||||||
|
|
@ -269,7 +281,7 @@ When you need deeper debugging or richer recipes, add:
|
||||||
private tokens
|
private tokens
|
||||||
- `pyro workspace stop` plus `workspace disk *` for offline inspection
|
- `pyro workspace stop` plus `workspace disk *` for offline inspection
|
||||||
|
|
||||||
## 8. Trustworthy verification path
|
## 9. Trustworthy verification path
|
||||||
|
|
||||||
The five recipe docs in [use-cases/README.md](use-cases/README.md) are backed
|
The five recipe docs in [use-cases/README.md](use-cases/README.md) are backed
|
||||||
by a real Firecracker smoke pack:
|
by a real Firecracker smoke pack:
|
||||||
|
|
@ -288,9 +300,8 @@ If you already installed the package, the same path works with plain `pyro ...`:
|
||||||
```bash
|
```bash
|
||||||
uv tool install pyro-mcp
|
uv tool install pyro-mcp
|
||||||
pyro --version
|
pyro --version
|
||||||
pyro doctor
|
pyro doctor --environment debian:12
|
||||||
pyro env list
|
pyro prepare debian:12
|
||||||
pyro env pull debian:12
|
|
||||||
pyro run debian:12 -- git --version
|
pyro run debian:12 -- git --version
|
||||||
pyro mcp serve
|
pyro mcp serve
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
This page documents the intended product path for `pyro-mcp`:
|
This page documents the intended product path for `pyro-mcp`:
|
||||||
|
|
||||||
- validate the host with the CLI
|
- validate the host with the CLI
|
||||||
|
- warm the daily loop with `pyro prepare debian:12`
|
||||||
- run `pyro mcp serve`
|
- run `pyro mcp serve`
|
||||||
- connect a chat host
|
- connect a chat host
|
||||||
- let the agent work inside disposable workspaces
|
- let the agent work inside disposable workspaces
|
||||||
|
|
@ -13,6 +14,13 @@ path is still being shaped.
|
||||||
Use this page after you have already validated the host and guest execution
|
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).
|
through [install.md](install.md) or [first-run.md](first-run.md).
|
||||||
|
|
||||||
|
Recommended first commands before connecting a host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pyro doctor --environment debian:12
|
||||||
|
pyro prepare debian:12
|
||||||
|
```
|
||||||
|
|
||||||
## Recommended Modes
|
## Recommended Modes
|
||||||
|
|
||||||
Use a named mode when one workflow already matches the job:
|
Use a named mode when one workflow already matches the job:
|
||||||
|
|
@ -241,3 +249,9 @@ Validate the whole story with:
|
||||||
```bash
|
```bash
|
||||||
make smoke-use-cases
|
make smoke-use-cases
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For the machine-warmup plus reset/retry path specifically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make smoke-daily-loop
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,11 @@ documents the chat-host path the project is actively shaping.
|
||||||
The intended user journey is:
|
The intended user journey is:
|
||||||
|
|
||||||
1. `pyro doctor`
|
1. `pyro doctor`
|
||||||
2. `pyro env list`
|
2. `pyro prepare debian:12`
|
||||||
3. `pyro env pull debian:12`
|
3. `pyro run debian:12 -- git --version`
|
||||||
4. `pyro run debian:12 -- git --version`
|
4. `pyro mcp serve`
|
||||||
5. `pyro mcp serve`
|
5. connect Claude Code, Codex, or OpenCode
|
||||||
6. connect Claude Code, Codex, or OpenCode
|
6. use `workspace reset` as the normal retry step
|
||||||
7. run one of the documented recipe-backed workflows
|
7. run one of the documented recipe-backed workflows
|
||||||
8. validate the whole story with `make smoke-use-cases`
|
8. validate the whole story with `make smoke-use-cases`
|
||||||
|
|
||||||
|
|
@ -44,6 +44,7 @@ These terminal commands are the documented companion path for the chat-host
|
||||||
product:
|
product:
|
||||||
|
|
||||||
- `pyro doctor`
|
- `pyro doctor`
|
||||||
|
- `pyro prepare`
|
||||||
- `pyro env list`
|
- `pyro env list`
|
||||||
- `pyro env pull`
|
- `pyro env pull`
|
||||||
- `pyro run`
|
- `pyro run`
|
||||||
|
|
@ -55,10 +56,12 @@ What to expect from that path:
|
||||||
- `pyro run` fails if guest boot or guest exec is unavailable unless
|
- `pyro run` fails if guest boot or guest exec is unavailable unless
|
||||||
`--allow-host-compat` is set
|
`--allow-host-compat` is set
|
||||||
- `pyro run`, `pyro env list`, `pyro env pull`, `pyro env inspect`,
|
- `pyro run`, `pyro env list`, `pyro env pull`, `pyro env inspect`,
|
||||||
`pyro env prune`, and `pyro doctor` are human-readable by default and return
|
`pyro env prune`, `pyro doctor`, and `pyro prepare` are human-readable by
|
||||||
structured JSON with `--json`
|
default and return structured JSON with `--json`
|
||||||
- the first official environment pull downloads from public Docker Hub into the
|
- the first official environment pull downloads from public Docker Hub into the
|
||||||
local environment cache
|
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
|
- `pyro demo` proves the one-shot create/start/exec/delete VM lifecycle end to
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ goal:
|
||||||
make the core agent-workspace use cases feel trivial from a chat-driven LLM
|
make the core agent-workspace use cases feel trivial from a chat-driven LLM
|
||||||
interface.
|
interface.
|
||||||
|
|
||||||
Current baseline is `4.4.0`:
|
Current baseline is `4.5.0`:
|
||||||
|
|
||||||
- `pyro mcp serve` is now the default product entrypoint
|
- `pyro mcp serve` is now the default product entrypoint
|
||||||
- `workspace-core` is now the default MCP profile
|
- `workspace-core` is now the default MCP profile
|
||||||
|
|
@ -83,7 +83,7 @@ capability gaps:
|
||||||
13. [`4.2.0` Host Bootstrap And Repair](llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.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
|
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
|
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) - Planned
|
16. [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md) - Done
|
||||||
|
|
||||||
Completed so far:
|
Completed so far:
|
||||||
|
|
||||||
|
|
@ -126,10 +126,9 @@ Completed so far:
|
||||||
- `4.4.0` adds named use-case modes so chat hosts can start from `repro-fix`,
|
- `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
|
`inspect`, `cold-start`, or `review-eval` instead of choosing from the full
|
||||||
generic workspace surface first.
|
generic workspace surface first.
|
||||||
|
- `4.5.0` adds `pyro prepare`, daily-loop readiness in `pyro doctor`, and a
|
||||||
Planned next:
|
real `make smoke-daily-loop` verification path so the local machine warmup
|
||||||
|
story is explicit before the chat host connects.
|
||||||
- [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md)
|
|
||||||
|
|
||||||
## Expected Outcome
|
## Expected Outcome
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# `4.5.0` Faster Daily Loops
|
# `4.5.0` Faster Daily Loops
|
||||||
|
|
||||||
Status: Planned
|
Status: Done
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
|
|
@ -9,11 +9,11 @@ for normal work, not only for special high-isolation tasks.
|
||||||
|
|
||||||
## Public API Changes
|
## Public API Changes
|
||||||
|
|
||||||
The product should add an explicit fast-path for repeated local use, such as:
|
The product now adds an explicit fast-path for repeated local use:
|
||||||
|
|
||||||
- a prewarm or prepare path for the recommended environment and runtime
|
- `pyro prepare [ENVIRONMENT] [--network] [--force] [--json]`
|
||||||
- a clearer fast reset or retry path for repeated repro-fix loops
|
- `pyro doctor --environment ENVIRONMENT` daily-loop readiness output
|
||||||
- visible diagnostics for cache, prewarm, or ready-state health
|
- `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:
|
The exact command names can still move, but the user-visible story needs to be:
|
||||||
|
|
||||||
|
|
@ -50,8 +50,8 @@ The exact command names can still move, but the user-visible story needs to be:
|
||||||
|
|
||||||
## Required Repo Updates
|
## Required Repo Updates
|
||||||
|
|
||||||
- docs updated to show the recommended daily-use fast path
|
- docs now show the recommended daily-use fast path
|
||||||
- diagnostics and help text updated so the user can tell whether the machine is
|
- diagnostics and help text now show whether the machine is already warm and
|
||||||
already warm and ready
|
ready
|
||||||
- at least one repeat-loop smoke or benchmark-style verification scenario
|
- the repo now includes `make smoke-daily-loop` as a repeat-loop verification
|
||||||
added to prevent regressions in the daily workflow
|
scenario for the daily workflow
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "pyro-mcp"
|
name = "pyro-mcp"
|
||||||
version = "4.4.0"
|
version = "4.5.0"
|
||||||
description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM."
|
description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "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()
|
||||||
|
|
@ -13,6 +13,7 @@ from typing import Any, cast
|
||||||
from pyro_mcp import __version__
|
from pyro_mcp import __version__
|
||||||
from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode
|
from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode
|
||||||
from pyro_mcp.contract import PUBLIC_MCP_MODES, PUBLIC_MCP_PROFILES
|
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.demo import run_demo
|
||||||
from pyro_mcp.host_helpers import (
|
from pyro_mcp.host_helpers import (
|
||||||
HostDoctorEntry,
|
HostDoctorEntry,
|
||||||
|
|
@ -154,6 +155,7 @@ def _print_doctor_human(payload: dict[str, Any]) -> None:
|
||||||
)
|
)
|
||||||
runtime = payload.get("runtime")
|
runtime = payload.get("runtime")
|
||||||
if isinstance(runtime, dict):
|
if isinstance(runtime, dict):
|
||||||
|
print(f"Catalog version: {str(runtime.get('catalog_version', 'unknown'))}")
|
||||||
print(f"Environment cache: {str(runtime.get('cache_dir', 'unknown'))}")
|
print(f"Environment cache: {str(runtime.get('cache_dir', 'unknown'))}")
|
||||||
capabilities = runtime.get("capabilities")
|
capabilities = runtime.get("capabilities")
|
||||||
if isinstance(capabilities, dict):
|
if isinstance(capabilities, dict):
|
||||||
|
|
@ -171,12 +173,51 @@ def _print_doctor_human(payload: dict[str, Any]) -> None:
|
||||||
f"tun={'yes' if bool(networking.get('tun_available')) else 'no'} "
|
f"tun={'yes' if bool(networking.get('tun_available')) else 'no'} "
|
||||||
f"ip_forward={'yes' if bool(networking.get('ip_forward_enabled')) else 'no'}"
|
f"ip_forward={'yes' if bool(networking.get('ip_forward_enabled')) else 'no'}"
|
||||||
)
|
)
|
||||||
|
daily_loop = payload.get("daily_loop")
|
||||||
|
if isinstance(daily_loop, dict):
|
||||||
|
status = str(daily_loop.get("status", "cold")).upper()
|
||||||
|
environment = str(daily_loop.get("environment", DEFAULT_PREPARE_ENVIRONMENT))
|
||||||
|
print(f"Daily loop: {status} ({environment})")
|
||||||
|
print(
|
||||||
|
" "
|
||||||
|
f"installed={'yes' if bool(daily_loop.get('installed')) else 'no'} "
|
||||||
|
f"network_prepared={'yes' if bool(daily_loop.get('network_prepared')) else 'no'}"
|
||||||
|
)
|
||||||
|
prepared_at = daily_loop.get("prepared_at")
|
||||||
|
if prepared_at is not None:
|
||||||
|
print(f" prepared_at={prepared_at}")
|
||||||
|
reason = daily_loop.get("reason")
|
||||||
|
if isinstance(reason, str) and reason != "":
|
||||||
|
print(f" reason={reason}")
|
||||||
|
if str(daily_loop.get("status", "cold")) != "warm":
|
||||||
|
print(f" Run: pyro prepare {environment}")
|
||||||
if isinstance(issues, list) and issues:
|
if isinstance(issues, list) and issues:
|
||||||
print("Issues:")
|
print("Issues:")
|
||||||
for issue in issues:
|
for issue in issues:
|
||||||
print(f"- {issue}")
|
print(f"- {issue}")
|
||||||
|
|
||||||
|
|
||||||
|
def _print_prepare_human(payload: dict[str, Any]) -> None:
|
||||||
|
environment = str(payload.get("environment", DEFAULT_PREPARE_ENVIRONMENT))
|
||||||
|
status = str(payload.get("status", "cold")).upper()
|
||||||
|
print(f"Prepare: {environment}")
|
||||||
|
print(f"Daily loop: {status}")
|
||||||
|
print(
|
||||||
|
"Result: "
|
||||||
|
f"{'reused' if bool(payload.get('reused')) else 'prepared'} "
|
||||||
|
f"network_prepared={'yes' if bool(payload.get('network_prepared')) else 'no'}"
|
||||||
|
)
|
||||||
|
print(f"Cache dir: {str(payload.get('cache_dir', 'unknown'))}")
|
||||||
|
print(f"Manifest: {str(payload.get('manifest_path', 'unknown'))}")
|
||||||
|
prepared_at = payload.get("prepared_at")
|
||||||
|
if prepared_at is not None:
|
||||||
|
print(f"Prepared at: {prepared_at}")
|
||||||
|
print(f"Duration: {int(payload.get('last_prepare_duration_ms', 0))} ms")
|
||||||
|
reason = payload.get("reason")
|
||||||
|
if isinstance(reason, str) and reason != "":
|
||||||
|
print(f"Reason: {reason}")
|
||||||
|
|
||||||
|
|
||||||
def _build_host_server_config(args: argparse.Namespace) -> HostServerConfig:
|
def _build_host_server_config(args: argparse.Namespace) -> HostServerConfig:
|
||||||
return HostServerConfig(
|
return HostServerConfig(
|
||||||
installed_package=bool(getattr(args, "installed_package", False)),
|
installed_package=bool(getattr(args, "installed_package", False)),
|
||||||
|
|
@ -899,8 +940,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
"""
|
"""
|
||||||
Suggested zero-to-hero path:
|
Suggested zero-to-hero path:
|
||||||
pyro doctor
|
pyro doctor
|
||||||
pyro env list
|
pyro prepare debian:12
|
||||||
pyro env pull debian:12
|
|
||||||
pyro run debian:12 -- git --version
|
pyro run debian:12 -- git --version
|
||||||
pyro host connect claude-code
|
pyro host connect claude-code
|
||||||
|
|
||||||
|
|
@ -909,6 +949,11 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
pyro host connect codex
|
pyro host connect codex
|
||||||
pyro host print-config opencode
|
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:
|
If you want terminal-level visibility into the workspace model:
|
||||||
pyro workspace create debian:12 --seed-path ./repo --id-only
|
pyro workspace create debian:12 --seed-path ./repo --id-only
|
||||||
pyro workspace sync push WORKSPACE_ID ./changes
|
pyro workspace sync push WORKSPACE_ID ./changes
|
||||||
|
|
@ -928,6 +973,51 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
||||||
subparsers = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
|
subparsers = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
|
||||||
|
|
||||||
|
prepare_parser = subparsers.add_parser(
|
||||||
|
"prepare",
|
||||||
|
help="Warm the local machine for the daily workspace loop.",
|
||||||
|
description=(
|
||||||
|
"Warm the recommended guest-backed daily loop by ensuring the "
|
||||||
|
"environment is installed and proving create, exec, reset, and "
|
||||||
|
"delete on one throwaway workspace."
|
||||||
|
),
|
||||||
|
epilog=dedent(
|
||||||
|
f"""
|
||||||
|
Examples:
|
||||||
|
pyro prepare
|
||||||
|
pyro prepare {DEFAULT_PREPARE_ENVIRONMENT}
|
||||||
|
pyro prepare {DEFAULT_PREPARE_ENVIRONMENT} --network
|
||||||
|
pyro prepare {DEFAULT_PREPARE_ENVIRONMENT} --force
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
formatter_class=_HelpFormatter,
|
||||||
|
)
|
||||||
|
prepare_parser.add_argument(
|
||||||
|
"environment",
|
||||||
|
nargs="?",
|
||||||
|
default=DEFAULT_PREPARE_ENVIRONMENT,
|
||||||
|
metavar="ENVIRONMENT",
|
||||||
|
help=(
|
||||||
|
"Curated environment to warm for the daily loop. Defaults to "
|
||||||
|
f"`{DEFAULT_PREPARE_ENVIRONMENT}`."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
prepare_parser.add_argument(
|
||||||
|
"--network",
|
||||||
|
action="store_true",
|
||||||
|
help="Also warm guest networking by proving one egress-enabled workspace cycle.",
|
||||||
|
)
|
||||||
|
prepare_parser.add_argument(
|
||||||
|
"--force",
|
||||||
|
action="store_true",
|
||||||
|
help="Rerun warmup even when a compatible warm manifest already exists.",
|
||||||
|
)
|
||||||
|
prepare_parser.add_argument(
|
||||||
|
"--json",
|
||||||
|
action="store_true",
|
||||||
|
help="Print structured JSON instead of human-readable output.",
|
||||||
|
)
|
||||||
|
|
||||||
env_parser = subparsers.add_parser(
|
env_parser = subparsers.add_parser(
|
||||||
"env",
|
"env",
|
||||||
help="Inspect and manage curated environments.",
|
help="Inspect and manage curated environments.",
|
||||||
|
|
@ -1245,10 +1335,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
mcp_serve_parser.add_argument(
|
mcp_serve_parser.add_argument(
|
||||||
"--no-project-source",
|
"--no-project-source",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help=(
|
help=("Disable automatic Git checkout detection from the current working directory."),
|
||||||
"Disable automatic Git checkout detection from the current working "
|
|
||||||
"directory."
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
run_parser = subparsers.add_parser(
|
run_parser = subparsers.add_parser(
|
||||||
|
|
@ -1306,8 +1393,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
"--allow-host-compat",
|
"--allow-host-compat",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help=(
|
help=(
|
||||||
"Opt into host-side compatibility execution if guest boot or guest exec "
|
"Opt into host-side compatibility execution if guest boot or guest exec is unavailable."
|
||||||
"is unavailable."
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
run_parser.add_argument(
|
run_parser.add_argument(
|
||||||
|
|
@ -1428,8 +1514,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
"--allow-host-compat",
|
"--allow-host-compat",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help=(
|
help=(
|
||||||
"Opt into host-side compatibility execution if guest boot or guest exec "
|
"Opt into host-side compatibility execution if guest boot or guest exec is unavailable."
|
||||||
"is unavailable."
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
workspace_create_parser.add_argument(
|
workspace_create_parser.add_argument(
|
||||||
|
|
@ -1479,8 +1564,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
"exec",
|
"exec",
|
||||||
help="Run one command inside an existing workspace.",
|
help="Run one command inside an existing workspace.",
|
||||||
description=(
|
description=(
|
||||||
"Run one non-interactive command in the persistent `/workspace` "
|
"Run one non-interactive command in the persistent `/workspace` for a workspace."
|
||||||
"for a workspace."
|
|
||||||
),
|
),
|
||||||
epilog=dedent(
|
epilog=dedent(
|
||||||
"""
|
"""
|
||||||
|
|
@ -1716,8 +1800,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
"created automatically."
|
"created automatically."
|
||||||
),
|
),
|
||||||
epilog=(
|
epilog=(
|
||||||
"Example:\n"
|
"Example:\n pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py"
|
||||||
" pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py"
|
|
||||||
),
|
),
|
||||||
formatter_class=_HelpFormatter,
|
formatter_class=_HelpFormatter,
|
||||||
)
|
)
|
||||||
|
|
@ -1909,8 +1992,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
"start",
|
"start",
|
||||||
help="Start one stopped workspace without resetting it.",
|
help="Start one stopped workspace without resetting it.",
|
||||||
description=(
|
description=(
|
||||||
"Start a previously stopped workspace from its preserved rootfs and "
|
"Start a previously stopped workspace from its preserved rootfs and workspace state."
|
||||||
"workspace state."
|
|
||||||
),
|
),
|
||||||
epilog="Example:\n pyro workspace start WORKSPACE_ID",
|
epilog="Example:\n pyro workspace start WORKSPACE_ID",
|
||||||
formatter_class=_HelpFormatter,
|
formatter_class=_HelpFormatter,
|
||||||
|
|
@ -2036,8 +2118,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
"shell",
|
"shell",
|
||||||
help="Open and manage persistent interactive shells.",
|
help="Open and manage persistent interactive shells.",
|
||||||
description=(
|
description=(
|
||||||
"Open one or more persistent interactive PTY shell sessions inside a started "
|
"Open one or more persistent interactive PTY shell sessions inside a started workspace."
|
||||||
"workspace."
|
|
||||||
),
|
),
|
||||||
epilog=dedent(
|
epilog=dedent(
|
||||||
"""
|
"""
|
||||||
|
|
@ -2520,8 +2601,7 @@ while true; do sleep 60; done'
|
||||||
"logs",
|
"logs",
|
||||||
help="Show command history for one workspace.",
|
help="Show command history for one workspace.",
|
||||||
description=(
|
description=(
|
||||||
"Show persisted command history, including stdout and stderr, "
|
"Show persisted command history, including stdout and stderr, for one workspace."
|
||||||
"for one workspace."
|
|
||||||
),
|
),
|
||||||
epilog="Example:\n pyro workspace logs WORKSPACE_ID",
|
epilog="Example:\n pyro workspace logs WORKSPACE_ID",
|
||||||
formatter_class=_HelpFormatter,
|
formatter_class=_HelpFormatter,
|
||||||
|
|
@ -2557,11 +2637,16 @@ while true; do sleep 60; done'
|
||||||
doctor_parser = subparsers.add_parser(
|
doctor_parser = subparsers.add_parser(
|
||||||
"doctor",
|
"doctor",
|
||||||
help="Inspect runtime and host diagnostics.",
|
help="Inspect runtime and host diagnostics.",
|
||||||
description="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(
|
epilog=dedent(
|
||||||
"""
|
"""
|
||||||
Examples:
|
Examples:
|
||||||
pyro doctor
|
pyro doctor
|
||||||
|
pyro doctor --environment debian:12
|
||||||
pyro doctor --json
|
pyro doctor --json
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
|
@ -2572,6 +2657,14 @@ while true; do sleep 60; done'
|
||||||
default=DEFAULT_PLATFORM,
|
default=DEFAULT_PLATFORM,
|
||||||
help="Runtime platform to inspect.",
|
help="Runtime platform to inspect.",
|
||||||
)
|
)
|
||||||
|
doctor_parser.add_argument(
|
||||||
|
"--environment",
|
||||||
|
default=DEFAULT_PREPARE_ENVIRONMENT,
|
||||||
|
help=(
|
||||||
|
"Environment to inspect for the daily-loop warm manifest. "
|
||||||
|
f"Defaults to `{DEFAULT_PREPARE_ENVIRONMENT}`."
|
||||||
|
),
|
||||||
|
)
|
||||||
doctor_parser.add_argument(
|
doctor_parser.add_argument(
|
||||||
"--json",
|
"--json",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
|
@ -2734,6 +2827,24 @@ def _parse_workspace_publish_options(values: list[str]) -> list[dict[str, int |
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
args = _build_parser().parse_args()
|
args = _build_parser().parse_args()
|
||||||
pyro = Pyro()
|
pyro = Pyro()
|
||||||
|
if args.command == "prepare":
|
||||||
|
try:
|
||||||
|
payload = pyro.manager.prepare_daily_loop(
|
||||||
|
args.environment,
|
||||||
|
network=bool(args.network),
|
||||||
|
force=bool(args.force),
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
if bool(args.json):
|
||||||
|
_print_json({"ok": False, "error": str(exc)})
|
||||||
|
else:
|
||||||
|
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||||
|
raise SystemExit(1) from exc
|
||||||
|
if bool(args.json):
|
||||||
|
_print_json(payload)
|
||||||
|
else:
|
||||||
|
_print_prepare_human(payload)
|
||||||
|
return
|
||||||
if args.command == "env":
|
if args.command == "env":
|
||||||
if args.env_command == "list":
|
if args.env_command == "list":
|
||||||
list_payload: dict[str, Any] = {
|
list_payload: dict[str, Any] = {
|
||||||
|
|
@ -2881,10 +2992,7 @@ def main() -> None:
|
||||||
if args.command == "workspace":
|
if args.command == "workspace":
|
||||||
if args.workspace_command == "create":
|
if args.workspace_command == "create":
|
||||||
secrets = [
|
secrets = [
|
||||||
*(
|
*(_parse_workspace_secret_option(value) for value in getattr(args, "secret", [])),
|
||||||
_parse_workspace_secret_option(value)
|
|
||||||
for value in getattr(args, "secret", [])
|
|
||||||
),
|
|
||||||
*(
|
*(
|
||||||
_parse_workspace_secret_file_option(value)
|
_parse_workspace_secret_file_option(value)
|
||||||
for value in getattr(args, "secret_file", [])
|
for value in getattr(args, "secret_file", [])
|
||||||
|
|
@ -2919,9 +3027,7 @@ def main() -> None:
|
||||||
return
|
return
|
||||||
if args.workspace_command == "update":
|
if args.workspace_command == "update":
|
||||||
labels = _parse_workspace_label_options(getattr(args, "label", []))
|
labels = _parse_workspace_label_options(getattr(args, "label", []))
|
||||||
clear_labels = _parse_workspace_clear_label_options(
|
clear_labels = _parse_workspace_clear_label_options(getattr(args, "clear_label", []))
|
||||||
getattr(args, "clear_label", [])
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
payload = pyro.update_workspace(
|
payload = pyro.update_workspace(
|
||||||
args.workspace_id,
|
args.workspace_id,
|
||||||
|
|
@ -3527,7 +3633,17 @@ def main() -> None:
|
||||||
print(f"Deleted workspace: {str(payload.get('workspace_id', 'unknown'))}")
|
print(f"Deleted workspace: {str(payload.get('workspace_id', 'unknown'))}")
|
||||||
return
|
return
|
||||||
if args.command == "doctor":
|
if args.command == "doctor":
|
||||||
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):
|
if bool(args.json):
|
||||||
_print_json(payload)
|
_print_json(payload)
|
||||||
else:
|
else:
|
||||||
|
|
@ -3558,3 +3674,7 @@ def main() -> None:
|
||||||
return
|
return
|
||||||
result = run_demo(network=bool(args.network))
|
result = run_demo(network=bool(args.network))
|
||||||
_print_json(result)
|
_print_json(result)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "host", "mcp", "run", "workspace")
|
PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "host", "mcp", "prepare", "run", "workspace")
|
||||||
PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",)
|
PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",)
|
||||||
PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune")
|
PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune")
|
||||||
|
PUBLIC_CLI_DOCTOR_FLAGS = ("--platform", "--environment", "--json")
|
||||||
PUBLIC_CLI_HOST_SUBCOMMANDS = ("connect", "doctor", "print-config", "repair")
|
PUBLIC_CLI_HOST_SUBCOMMANDS = ("connect", "doctor", "print-config", "repair")
|
||||||
PUBLIC_CLI_HOST_COMMON_FLAGS = (
|
PUBLIC_CLI_HOST_COMMON_FLAGS = (
|
||||||
"--installed-package",
|
"--installed-package",
|
||||||
|
|
@ -28,6 +29,7 @@ PUBLIC_CLI_MCP_SERVE_FLAGS = (
|
||||||
"--repo-ref",
|
"--repo-ref",
|
||||||
"--no-project-source",
|
"--no-project-source",
|
||||||
)
|
)
|
||||||
|
PUBLIC_CLI_PREPARE_FLAGS = ("--network", "--force", "--json")
|
||||||
PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = (
|
PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = (
|
||||||
"create",
|
"create",
|
||||||
"delete",
|
"delete",
|
||||||
|
|
|
||||||
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 argparse
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT
|
||||||
from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report
|
from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report
|
||||||
|
|
||||||
|
|
||||||
def _build_parser() -> argparse.ArgumentParser:
|
def _build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description="Inspect bundled runtime health for pyro-mcp.")
|
parser = argparse.ArgumentParser(description="Inspect bundled runtime health for pyro-mcp.")
|
||||||
parser.add_argument("--platform", default=DEFAULT_PLATFORM)
|
parser.add_argument("--platform", default=DEFAULT_PLATFORM)
|
||||||
|
parser.add_argument("--environment", default=DEFAULT_PREPARE_ENVIRONMENT)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
args = _build_parser().parse_args()
|
args = _build_parser().parse_args()
|
||||||
report = doctor_report(platform=args.platform)
|
report = doctor_report(platform=args.platform, environment=args.environment)
|
||||||
print(json.dumps(report, indent=2, sort_keys=True))
|
print(json.dumps(report, indent=2, sort_keys=True))
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,13 @@ from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from pyro_mcp.daily_loop import (
|
||||||
|
DEFAULT_PREPARE_ENVIRONMENT,
|
||||||
|
evaluate_daily_loop_status,
|
||||||
|
load_prepare_manifest,
|
||||||
|
prepare_manifest_path,
|
||||||
|
serialize_daily_loop_report,
|
||||||
|
)
|
||||||
from pyro_mcp.vm_network import TapNetworkManager
|
from pyro_mcp.vm_network import TapNetworkManager
|
||||||
|
|
||||||
DEFAULT_PLATFORM = "linux-x86_64"
|
DEFAULT_PLATFORM = "linux-x86_64"
|
||||||
|
|
@ -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."""
|
"""Build a runtime diagnostics report."""
|
||||||
report: dict[str, Any] = {
|
report: dict[str, Any] = {
|
||||||
"platform": platform,
|
"platform": platform,
|
||||||
|
|
@ -258,6 +269,36 @@ def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]:
|
||||||
"cache_dir": str(environment_store.cache_dir),
|
"cache_dir": str(environment_store.cache_dir),
|
||||||
"environments": environment_store.list_environments(),
|
"environments": environment_store.list_environments(),
|
||||||
}
|
}
|
||||||
|
environment_details = environment_store.inspect_environment(environment)
|
||||||
|
manifest_path = prepare_manifest_path(
|
||||||
|
environment_store.cache_dir,
|
||||||
|
platform=platform,
|
||||||
|
environment=environment,
|
||||||
|
)
|
||||||
|
manifest, manifest_error = load_prepare_manifest(manifest_path)
|
||||||
|
status, reason = evaluate_daily_loop_status(
|
||||||
|
environment=environment,
|
||||||
|
environment_version=str(environment_details["version"]),
|
||||||
|
platform=platform,
|
||||||
|
catalog_version=environment_store.catalog_version,
|
||||||
|
bundle_version=(
|
||||||
|
None
|
||||||
|
if paths.manifest.get("bundle_version") is None
|
||||||
|
else str(paths.manifest["bundle_version"])
|
||||||
|
),
|
||||||
|
installed=bool(environment_details["installed"]),
|
||||||
|
manifest=manifest,
|
||||||
|
manifest_error=manifest_error,
|
||||||
|
)
|
||||||
|
report["daily_loop"] = serialize_daily_loop_report(
|
||||||
|
environment=environment,
|
||||||
|
status=status,
|
||||||
|
installed=bool(environment_details["installed"]),
|
||||||
|
cache_dir=environment_store.cache_dir,
|
||||||
|
manifest_path=manifest_path,
|
||||||
|
reason=reason,
|
||||||
|
manifest=manifest,
|
||||||
|
)
|
||||||
if not report["kvm"]["exists"]:
|
if not report["kvm"]["exists"]:
|
||||||
report["issues"] = ["/dev/kvm is not available on this host"]
|
report["issues"] = ["/dev/kvm is not available on this host"]
|
||||||
return report
|
return report
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ from typing import Any
|
||||||
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
|
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
|
||||||
|
|
||||||
DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
|
DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
|
||||||
DEFAULT_CATALOG_VERSION = "4.4.0"
|
DEFAULT_CATALOG_VERSION = "4.5.0"
|
||||||
OCI_MANIFEST_ACCEPT = ", ".join(
|
OCI_MANIFEST_ACCEPT = ", ".join(
|
||||||
(
|
(
|
||||||
"application/vnd.oci.image.index.v1+json",
|
"application/vnd.oci.image.index.v1+json",
|
||||||
|
|
@ -48,7 +48,7 @@ class VmEnvironment:
|
||||||
oci_repository: str | None = None
|
oci_repository: str | None = None
|
||||||
oci_reference: str | None = None
|
oci_reference: str | None = None
|
||||||
source_digest: str | None = None
|
source_digest: str | None = None
|
||||||
compatibility: str = ">=4.4.0,<5.0.0"
|
compatibility: str = ">=4.5.0,<5.0.0"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,15 @@ from dataclasses import dataclass, field
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
from typing import Any, Literal, cast
|
from typing import Any, Literal, cast
|
||||||
|
|
||||||
|
from pyro_mcp.daily_loop import (
|
||||||
|
DailyLoopManifest,
|
||||||
|
evaluate_daily_loop_status,
|
||||||
|
load_prepare_manifest,
|
||||||
|
prepare_manifest_path,
|
||||||
|
prepare_request_is_satisfied,
|
||||||
|
serialize_daily_loop_report,
|
||||||
|
write_prepare_manifest,
|
||||||
|
)
|
||||||
from pyro_mcp.runtime import (
|
from pyro_mcp.runtime import (
|
||||||
RuntimeCapabilities,
|
RuntimeCapabilities,
|
||||||
RuntimePaths,
|
RuntimePaths,
|
||||||
|
|
@ -288,9 +297,7 @@ class WorkspaceRecord:
|
||||||
network=_deserialize_network(payload.get("network")),
|
network=_deserialize_network(payload.get("network")),
|
||||||
name=_normalize_workspace_name(_optional_str(payload.get("name")), allow_none=True),
|
name=_normalize_workspace_name(_optional_str(payload.get("name")), allow_none=True),
|
||||||
labels=_normalize_workspace_labels(payload.get("labels")),
|
labels=_normalize_workspace_labels(payload.get("labels")),
|
||||||
last_activity_at=float(
|
last_activity_at=float(payload.get("last_activity_at", float(payload["created_at"]))),
|
||||||
payload.get("last_activity_at", float(payload["created_at"]))
|
|
||||||
),
|
|
||||||
command_count=int(payload.get("command_count", 0)),
|
command_count=int(payload.get("command_count", 0)),
|
||||||
last_command=_optional_dict(payload.get("last_command")),
|
last_command=_optional_dict(payload.get("last_command")),
|
||||||
workspace_seed=_workspace_seed_dict(payload.get("workspace_seed")),
|
workspace_seed=_workspace_seed_dict(payload.get("workspace_seed")),
|
||||||
|
|
@ -544,9 +551,7 @@ class WorkspacePublishedPortRecord:
|
||||||
host=str(payload.get("host", DEFAULT_PUBLISHED_PORT_HOST)),
|
host=str(payload.get("host", DEFAULT_PUBLISHED_PORT_HOST)),
|
||||||
protocol=str(payload.get("protocol", "tcp")),
|
protocol=str(payload.get("protocol", "tcp")),
|
||||||
proxy_pid=(
|
proxy_pid=(
|
||||||
None
|
None if payload.get("proxy_pid") is None else int(payload.get("proxy_pid", 0))
|
||||||
if payload.get("proxy_pid") is None
|
|
||||||
else int(payload.get("proxy_pid", 0))
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -921,9 +926,7 @@ def _validate_workspace_file_read_max_bytes(max_bytes: int) -> int:
|
||||||
if max_bytes <= 0:
|
if max_bytes <= 0:
|
||||||
raise ValueError("max_bytes must be positive")
|
raise ValueError("max_bytes must be positive")
|
||||||
if max_bytes > WORKSPACE_FILE_MAX_BYTES:
|
if max_bytes > WORKSPACE_FILE_MAX_BYTES:
|
||||||
raise ValueError(
|
raise ValueError(f"max_bytes must be at most {WORKSPACE_FILE_MAX_BYTES} bytes")
|
||||||
f"max_bytes must be at most {WORKSPACE_FILE_MAX_BYTES} bytes"
|
|
||||||
)
|
|
||||||
return max_bytes
|
return max_bytes
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -951,9 +954,7 @@ def _decode_workspace_patch_text(path: str, content_bytes: bytes) -> str:
|
||||||
try:
|
try:
|
||||||
return content_bytes.decode("utf-8")
|
return content_bytes.decode("utf-8")
|
||||||
except UnicodeDecodeError as exc:
|
except UnicodeDecodeError as exc:
|
||||||
raise RuntimeError(
|
raise RuntimeError(f"workspace patch only supports UTF-8 text files: {path}") from exc
|
||||||
f"workspace patch only supports UTF-8 text files: {path}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_archive_member_name(name: str) -> PurePosixPath:
|
def _normalize_archive_member_name(name: str) -> PurePosixPath:
|
||||||
|
|
@ -1043,9 +1044,7 @@ def _prepare_workspace_secrets(
|
||||||
has_value = "value" in item
|
has_value = "value" in item
|
||||||
has_file_path = "file_path" in item
|
has_file_path = "file_path" in item
|
||||||
if has_value == has_file_path:
|
if has_value == has_file_path:
|
||||||
raise ValueError(
|
raise ValueError(f"secret {name!r} must provide exactly one of 'value' or 'file_path'")
|
||||||
f"secret {name!r} must provide exactly one of 'value' or 'file_path'"
|
|
||||||
)
|
|
||||||
source_kind: WorkspaceSecretSourceKind
|
source_kind: WorkspaceSecretSourceKind
|
||||||
if has_value:
|
if has_value:
|
||||||
value = _validate_workspace_secret_value(name, str(item["value"]))
|
value = _validate_workspace_secret_value(name, str(item["value"]))
|
||||||
|
|
@ -1525,9 +1524,7 @@ def _normalize_workspace_published_port_specs(
|
||||||
)
|
)
|
||||||
dedupe_key = (spec.host_port, spec.guest_port)
|
dedupe_key = (spec.host_port, spec.guest_port)
|
||||||
if dedupe_key in seen_guest_ports:
|
if dedupe_key in seen_guest_ports:
|
||||||
raise ValueError(
|
raise ValueError("published ports must not repeat the same host/guest port mapping")
|
||||||
"published ports must not repeat the same host/guest port mapping"
|
|
||||||
)
|
|
||||||
seen_guest_ports.add(dedupe_key)
|
seen_guest_ports.add(dedupe_key)
|
||||||
normalized.append(spec)
|
normalized.append(spec)
|
||||||
return normalized
|
return normalized
|
||||||
|
|
@ -1790,7 +1787,7 @@ def _start_local_service(
|
||||||
),
|
),
|
||||||
"status=$?",
|
"status=$?",
|
||||||
f"printf '%s' \"$status\" > {shlex.quote(str(status_path))}",
|
f"printf '%s' \"$status\" > {shlex.quote(str(status_path))}",
|
||||||
"exit \"$status\"",
|
'exit "$status"',
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
+ "\n",
|
+ "\n",
|
||||||
|
|
@ -1973,9 +1970,7 @@ def _patch_rootfs_runtime_file(
|
||||||
) -> None:
|
) -> None:
|
||||||
debugfs_path = shutil.which("debugfs")
|
debugfs_path = shutil.which("debugfs")
|
||||||
if debugfs_path is None:
|
if debugfs_path is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError("debugfs is required to seed workspaces on guest-backed runtimes")
|
||||||
"debugfs is required to seed workspaces on guest-backed runtimes"
|
|
||||||
)
|
|
||||||
with tempfile.TemporaryDirectory(prefix=f"pyro-{asset_label}-") as temp_dir:
|
with tempfile.TemporaryDirectory(prefix=f"pyro-{asset_label}-") as temp_dir:
|
||||||
staged_path = Path(temp_dir) / Path(destination_path).name
|
staged_path = Path(temp_dir) / Path(destination_path).name
|
||||||
shutil.copy2(source_path, staged_path)
|
shutil.copy2(source_path, staged_path)
|
||||||
|
|
@ -3634,6 +3629,152 @@ class VmManager:
|
||||||
def prune_environments(self) -> dict[str, object]:
|
def prune_environments(self) -> dict[str, object]:
|
||||||
return self._environment_store.prune_environments()
|
return self._environment_store.prune_environments()
|
||||||
|
|
||||||
|
def prepare_daily_loop(
|
||||||
|
self,
|
||||||
|
environment: str,
|
||||||
|
*,
|
||||||
|
network: bool = False,
|
||||||
|
force: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
spec = get_environment(environment, runtime_paths=self._runtime_paths)
|
||||||
|
if self._backend_name != "firecracker":
|
||||||
|
raise RuntimeError("pyro prepare requires a guest-backed runtime and is unavailable")
|
||||||
|
if not self._runtime_capabilities.supports_vm_boot:
|
||||||
|
reason = self._runtime_capabilities.reason or "runtime does not support guest boot"
|
||||||
|
raise RuntimeError(
|
||||||
|
f"pyro prepare requires guest-backed workspace boot and is unavailable: {reason}"
|
||||||
|
)
|
||||||
|
if not self._runtime_capabilities.supports_guest_exec:
|
||||||
|
reason = self._runtime_capabilities.reason or (
|
||||||
|
"runtime does not support guest command execution"
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"pyro prepare requires guest command execution and is unavailable: {reason}"
|
||||||
|
)
|
||||||
|
if network and not self._runtime_capabilities.supports_guest_network:
|
||||||
|
reason = self._runtime_capabilities.reason or (
|
||||||
|
"runtime does not support guest networking"
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"pyro prepare --network requires guest networking and is unavailable: {reason}"
|
||||||
|
)
|
||||||
|
|
||||||
|
runtime_paths = self._runtime_paths
|
||||||
|
if runtime_paths is None:
|
||||||
|
raise RuntimeError("runtime paths are unavailable for pyro prepare")
|
||||||
|
platform = str(runtime_paths.manifest.get("platform", "linux-x86_64"))
|
||||||
|
bundle_version = cast(str | None, runtime_paths.manifest.get("bundle_version"))
|
||||||
|
manifest_path = prepare_manifest_path(
|
||||||
|
self._environment_store.cache_dir,
|
||||||
|
platform=platform,
|
||||||
|
environment=environment,
|
||||||
|
)
|
||||||
|
manifest, manifest_error = load_prepare_manifest(manifest_path)
|
||||||
|
status, status_reason = evaluate_daily_loop_status(
|
||||||
|
environment=environment,
|
||||||
|
environment_version=spec.version,
|
||||||
|
platform=platform,
|
||||||
|
catalog_version=self._environment_store.catalog_version,
|
||||||
|
bundle_version=bundle_version,
|
||||||
|
installed=bool(self.inspect_environment(environment)["installed"]),
|
||||||
|
manifest=manifest,
|
||||||
|
manifest_error=manifest_error,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not force
|
||||||
|
and status == "warm"
|
||||||
|
and prepare_request_is_satisfied(manifest, require_network=network)
|
||||||
|
):
|
||||||
|
if manifest is None:
|
||||||
|
raise AssertionError("warm prepare state requires a manifest")
|
||||||
|
payload = serialize_daily_loop_report(
|
||||||
|
environment=environment,
|
||||||
|
status="warm",
|
||||||
|
installed=True,
|
||||||
|
cache_dir=self._environment_store.cache_dir,
|
||||||
|
manifest_path=manifest_path,
|
||||||
|
reason="reused existing warm manifest",
|
||||||
|
manifest=manifest,
|
||||||
|
)
|
||||||
|
payload.update(
|
||||||
|
{
|
||||||
|
"prepared": True,
|
||||||
|
"reused": True,
|
||||||
|
"executed": False,
|
||||||
|
"forced": force,
|
||||||
|
"network_requested": network,
|
||||||
|
"last_prepare_duration_ms": manifest.last_prepare_duration_ms,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
self._environment_store.ensure_installed(environment)
|
||||||
|
started = time.monotonic()
|
||||||
|
workspace_id: str | None = None
|
||||||
|
execution_mode = "pending"
|
||||||
|
try:
|
||||||
|
created = self.create_workspace(
|
||||||
|
environment=environment,
|
||||||
|
network_policy="egress" if network else "off",
|
||||||
|
allow_host_compat=False,
|
||||||
|
)
|
||||||
|
workspace_id = str(created["workspace_id"])
|
||||||
|
exec_result = self.exec_workspace(
|
||||||
|
workspace_id,
|
||||||
|
command="pwd",
|
||||||
|
timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
execution_mode = str(exec_result.get("execution_mode", "unknown"))
|
||||||
|
if int(exec_result.get("exit_code", 1)) != 0:
|
||||||
|
raise RuntimeError("prepare guest exec failed")
|
||||||
|
if str(exec_result.get("stdout", "")) != f"{WORKSPACE_GUEST_PATH}\n":
|
||||||
|
raise RuntimeError("prepare guest exec returned an unexpected working directory")
|
||||||
|
self.reset_workspace(workspace_id)
|
||||||
|
finally:
|
||||||
|
if workspace_id is not None:
|
||||||
|
try:
|
||||||
|
self.delete_workspace(workspace_id, reason="prepare_cleanup")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
duration_ms = int((time.monotonic() - started) * 1000)
|
||||||
|
prepared_at = time.time()
|
||||||
|
preserved_network_prepared = bool(
|
||||||
|
manifest is not None and status == "warm" and manifest.network_prepared
|
||||||
|
)
|
||||||
|
prepared_manifest = DailyLoopManifest(
|
||||||
|
environment=environment,
|
||||||
|
environment_version=spec.version,
|
||||||
|
platform=platform,
|
||||||
|
catalog_version=self._environment_store.catalog_version,
|
||||||
|
bundle_version=bundle_version,
|
||||||
|
prepared_at=prepared_at,
|
||||||
|
network_prepared=network or preserved_network_prepared,
|
||||||
|
last_prepare_duration_ms=duration_ms,
|
||||||
|
)
|
||||||
|
write_prepare_manifest(manifest_path, prepared_manifest)
|
||||||
|
payload = serialize_daily_loop_report(
|
||||||
|
environment=environment,
|
||||||
|
status="warm",
|
||||||
|
installed=True,
|
||||||
|
cache_dir=self._environment_store.cache_dir,
|
||||||
|
manifest_path=manifest_path,
|
||||||
|
reason=status_reason,
|
||||||
|
manifest=prepared_manifest,
|
||||||
|
)
|
||||||
|
payload.update(
|
||||||
|
{
|
||||||
|
"prepared": True,
|
||||||
|
"reused": False,
|
||||||
|
"executed": True,
|
||||||
|
"forced": force,
|
||||||
|
"network_requested": network,
|
||||||
|
"last_prepare_duration_ms": duration_ms,
|
||||||
|
"execution_mode": execution_mode,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
def create_vm(
|
def create_vm(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -3859,9 +4000,7 @@ class VmManager:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"max active VMs reached ({self._max_active_vms}); delete old VMs first"
|
f"max active VMs reached ({self._max_active_vms}); delete old VMs first"
|
||||||
)
|
)
|
||||||
self._require_workspace_network_policy_support(
|
self._require_workspace_network_policy_support(network_policy=normalized_network_policy)
|
||||||
network_policy=normalized_network_policy
|
|
||||||
)
|
|
||||||
self._backend.create(instance)
|
self._backend.create(instance)
|
||||||
if self._runtime_capabilities.supports_guest_exec:
|
if self._runtime_capabilities.supports_guest_exec:
|
||||||
self._ensure_workspace_guest_bootstrap_support(instance)
|
self._ensure_workspace_guest_bootstrap_support(instance)
|
||||||
|
|
@ -3963,9 +4102,7 @@ class VmManager:
|
||||||
"destination": str(workspace_sync["destination"]),
|
"destination": str(workspace_sync["destination"]),
|
||||||
"entry_count": int(workspace_sync["entry_count"]),
|
"entry_count": int(workspace_sync["entry_count"]),
|
||||||
"bytes_written": int(workspace_sync["bytes_written"]),
|
"bytes_written": int(workspace_sync["bytes_written"]),
|
||||||
"execution_mode": str(
|
"execution_mode": str(instance.metadata.get("execution_mode", "pending")),
|
||||||
instance.metadata.get("execution_mode", "pending")
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self._save_workspace_locked(workspace)
|
self._save_workspace_locked(workspace)
|
||||||
|
|
@ -4397,9 +4534,7 @@ class VmManager:
|
||||||
payload={
|
payload={
|
||||||
"summary": dict(summary),
|
"summary": dict(summary),
|
||||||
"entries": [dict(entry) for entry in entries[:10]],
|
"entries": [dict(entry) for entry in entries[:10]],
|
||||||
"execution_mode": str(
|
"execution_mode": str(instance.metadata.get("execution_mode", "pending")),
|
||||||
instance.metadata.get("execution_mode", "pending")
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self._save_workspace_locked(workspace)
|
self._save_workspace_locked(workspace)
|
||||||
|
|
@ -4568,9 +4703,7 @@ class VmManager:
|
||||||
recreated = workspace.to_instance(
|
recreated = workspace.to_instance(
|
||||||
workdir=self._workspace_runtime_dir(workspace.workspace_id)
|
workdir=self._workspace_runtime_dir(workspace.workspace_id)
|
||||||
)
|
)
|
||||||
self._require_workspace_network_policy_support(
|
self._require_workspace_network_policy_support(network_policy=workspace.network_policy)
|
||||||
network_policy=workspace.network_policy
|
|
||||||
)
|
|
||||||
self._backend.create(recreated)
|
self._backend.create(recreated)
|
||||||
if self._runtime_capabilities.supports_guest_exec:
|
if self._runtime_capabilities.supports_guest_exec:
|
||||||
self._ensure_workspace_guest_bootstrap_support(recreated)
|
self._ensure_workspace_guest_bootstrap_support(recreated)
|
||||||
|
|
@ -4764,9 +4897,7 @@ class VmManager:
|
||||||
if wait_for_idle_ms is not None and (
|
if wait_for_idle_ms is not None and (
|
||||||
wait_for_idle_ms <= 0 or wait_for_idle_ms > MAX_SHELL_WAIT_FOR_IDLE_MS
|
wait_for_idle_ms <= 0 or wait_for_idle_ms > MAX_SHELL_WAIT_FOR_IDLE_MS
|
||||||
):
|
):
|
||||||
raise ValueError(
|
raise ValueError(f"wait_for_idle_ms must be between 1 and {MAX_SHELL_WAIT_FOR_IDLE_MS}")
|
||||||
f"wait_for_idle_ms must be between 1 and {MAX_SHELL_WAIT_FOR_IDLE_MS}"
|
|
||||||
)
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
workspace = self._load_workspace_locked(workspace_id)
|
workspace = self._load_workspace_locked(workspace_id)
|
||||||
instance = self._workspace_instance_for_live_shell_locked(workspace)
|
instance = self._workspace_instance_for_live_shell_locked(workspace)
|
||||||
|
|
@ -5041,8 +5172,7 @@ class VmManager:
|
||||||
if normalized_published_ports:
|
if normalized_published_ports:
|
||||||
if workspace.network_policy != "egress+published-ports":
|
if workspace.network_policy != "egress+published-ports":
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"published ports require workspace network_policy "
|
"published ports require workspace network_policy 'egress+published-ports'"
|
||||||
"'egress+published-ports'"
|
|
||||||
)
|
)
|
||||||
if instance.network is None:
|
if instance.network is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
|
|
@ -5447,11 +5577,7 @@ class VmManager:
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
continue
|
continue
|
||||||
diff_entries.append(
|
diff_entries.append(
|
||||||
{
|
{key: value for key, value in entry.items() if key != "text_patch"}
|
||||||
key: value
|
|
||||||
for key, value in entry.items()
|
|
||||||
if key != "text_patch"
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
payload["changes"] = {
|
payload["changes"] = {
|
||||||
"available": True,
|
"available": True,
|
||||||
|
|
@ -5509,9 +5635,7 @@ class VmManager:
|
||||||
self._stop_workspace_services_locked(workspace, instance)
|
self._stop_workspace_services_locked(workspace, instance)
|
||||||
self._close_workspace_shells_locked(workspace, instance)
|
self._close_workspace_shells_locked(workspace, instance)
|
||||||
try:
|
try:
|
||||||
self._require_workspace_network_policy_support(
|
self._require_workspace_network_policy_support(network_policy=workspace.network_policy)
|
||||||
network_policy=workspace.network_policy
|
|
||||||
)
|
|
||||||
if self._runtime_capabilities.supports_guest_exec:
|
if self._runtime_capabilities.supports_guest_exec:
|
||||||
self._ensure_workspace_guest_bootstrap_support(instance)
|
self._ensure_workspace_guest_bootstrap_support(instance)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|
@ -5694,9 +5818,7 @@ class VmManager:
|
||||||
"execution_mode": workspace.metadata.get("execution_mode", "pending"),
|
"execution_mode": workspace.metadata.get("execution_mode", "pending"),
|
||||||
"workspace_path": WORKSPACE_GUEST_PATH,
|
"workspace_path": WORKSPACE_GUEST_PATH,
|
||||||
"workspace_seed": _workspace_seed_dict(workspace.workspace_seed),
|
"workspace_seed": _workspace_seed_dict(workspace.workspace_seed),
|
||||||
"secrets": [
|
"secrets": [_serialize_workspace_secret_public(secret) for secret in workspace.secrets],
|
||||||
_serialize_workspace_secret_public(secret) for secret in workspace.secrets
|
|
||||||
],
|
|
||||||
"command_count": workspace.command_count,
|
"command_count": workspace.command_count,
|
||||||
"last_command": workspace.last_command,
|
"last_command": workspace.last_command,
|
||||||
"reset_count": workspace.reset_count,
|
"reset_count": workspace.reset_count,
|
||||||
|
|
@ -5867,9 +5989,7 @@ class VmManager:
|
||||||
env_values: dict[str, str] = {}
|
env_values: dict[str, str] = {}
|
||||||
for secret_name, env_name in secret_env.items():
|
for secret_name, env_name in secret_env.items():
|
||||||
if secret_name not in secret_values:
|
if secret_name not in secret_values:
|
||||||
raise ValueError(
|
raise ValueError(f"secret_env references unknown workspace secret {secret_name!r}")
|
||||||
f"secret_env references unknown workspace secret {secret_name!r}"
|
|
||||||
)
|
|
||||||
env_values[env_name] = secret_values[secret_name]
|
env_values[env_name] = secret_values[secret_name]
|
||||||
return env_values
|
return env_values
|
||||||
|
|
||||||
|
|
@ -6019,13 +6139,10 @@ class VmManager:
|
||||||
bytes_written=bytes_written,
|
bytes_written=bytes_written,
|
||||||
cleanup_dir=cleanup_dir,
|
cleanup_dir=cleanup_dir,
|
||||||
)
|
)
|
||||||
if (
|
if not resolved_source_path.is_file() or not _is_supported_seed_archive(
|
||||||
not resolved_source_path.is_file()
|
resolved_source_path
|
||||||
or not _is_supported_seed_archive(resolved_source_path)
|
|
||||||
):
|
):
|
||||||
raise ValueError(
|
raise ValueError("seed_path must be a directory or a .tar/.tar.gz/.tgz archive")
|
||||||
"seed_path must be a directory or a .tar/.tar.gz/.tgz archive"
|
|
||||||
)
|
|
||||||
entry_count, bytes_written = _inspect_seed_archive(resolved_source_path)
|
entry_count, bytes_written = _inspect_seed_archive(resolved_source_path)
|
||||||
return PreparedWorkspaceSeed(
|
return PreparedWorkspaceSeed(
|
||||||
mode="tar_archive",
|
mode="tar_archive",
|
||||||
|
|
@ -6128,8 +6245,7 @@ class VmManager:
|
||||||
rootfs_path = Path(raw_rootfs_image)
|
rootfs_path = Path(raw_rootfs_image)
|
||||||
if not rootfs_path.exists():
|
if not rootfs_path.exists():
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"workspace {workspace.workspace_id!r} rootfs image is unavailable at "
|
f"workspace {workspace.workspace_id!r} rootfs image is unavailable at {rootfs_path}"
|
||||||
f"{rootfs_path}"
|
|
||||||
)
|
)
|
||||||
return rootfs_path
|
return rootfs_path
|
||||||
|
|
||||||
|
|
@ -6146,9 +6262,7 @@ class VmManager:
|
||||||
f"workspace {workspace.workspace_id!r} must be stopped before {operation_name}"
|
f"workspace {workspace.workspace_id!r} must be stopped before {operation_name}"
|
||||||
)
|
)
|
||||||
if workspace.metadata.get("execution_mode") == "host_compat":
|
if workspace.metadata.get("execution_mode") == "host_compat":
|
||||||
raise RuntimeError(
|
raise RuntimeError(f"{operation_name} is unavailable for host_compat workspaces")
|
||||||
f"{operation_name} is unavailable for host_compat workspaces"
|
|
||||||
)
|
|
||||||
return self._workspace_rootfs_image_path_locked(workspace)
|
return self._workspace_rootfs_image_path_locked(workspace)
|
||||||
|
|
||||||
def _scrub_workspace_runtime_state_locked(
|
def _scrub_workspace_runtime_state_locked(
|
||||||
|
|
|
||||||
|
|
@ -29,14 +29,16 @@ def test_cli_help_guides_first_run() -> None:
|
||||||
|
|
||||||
assert "Suggested zero-to-hero path:" in help_text
|
assert "Suggested zero-to-hero path:" in help_text
|
||||||
assert "pyro doctor" in help_text
|
assert "pyro doctor" in help_text
|
||||||
assert "pyro env list" in help_text
|
assert "pyro prepare debian:12" in help_text
|
||||||
assert "pyro env pull debian:12" in help_text
|
|
||||||
assert "pyro run debian:12 -- git --version" in help_text
|
assert "pyro run debian:12 -- git --version" in help_text
|
||||||
assert "pyro host connect claude-code" in help_text
|
assert "pyro host connect claude-code" in help_text
|
||||||
assert "Connect a chat host after that:" in help_text
|
assert "Connect a chat host after that:" in help_text
|
||||||
assert "pyro host connect claude-code" in help_text
|
assert "pyro host connect claude-code" in help_text
|
||||||
assert "pyro host connect codex" in help_text
|
assert "pyro host connect codex" in help_text
|
||||||
assert "pyro host print-config opencode" 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 "If you want terminal-level visibility into the workspace model:" in help_text
|
||||||
assert "pyro workspace exec WORKSPACE_ID -- cat note.txt" in help_text
|
assert "pyro workspace exec WORKSPACE_ID -- cat note.txt" in help_text
|
||||||
assert "pyro workspace summary WORKSPACE_ID" in help_text
|
assert "pyro workspace summary WORKSPACE_ID" in help_text
|
||||||
|
|
@ -60,6 +62,12 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
||||||
assert "pyro env pull debian:12" in env_help
|
assert "pyro env pull debian:12" in env_help
|
||||||
assert "downloads from public Docker Hub" in env_help
|
assert "downloads from public Docker Hub" in env_help
|
||||||
|
|
||||||
|
prepare_help = _subparser_choice(parser, "prepare").format_help()
|
||||||
|
assert "Warm the recommended guest-backed daily loop" in prepare_help
|
||||||
|
assert "pyro prepare debian:12 --network" in prepare_help
|
||||||
|
assert "--network" in prepare_help
|
||||||
|
assert "--force" in prepare_help
|
||||||
|
|
||||||
host_help = _subparser_choice(parser, "host").format_help()
|
host_help = _subparser_choice(parser, "host").format_help()
|
||||||
assert "Connect or repair the supported Claude Code, Codex, and OpenCode" in host_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 connect claude-code" in host_help
|
||||||
|
|
@ -87,6 +95,8 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
||||||
|
|
||||||
doctor_help = _subparser_choice(parser, "doctor").format_help()
|
doctor_help = _subparser_choice(parser, "doctor").format_help()
|
||||||
assert "Check host prerequisites and embedded runtime health" in doctor_help
|
assert "Check host prerequisites and embedded runtime health" in doctor_help
|
||||||
|
assert "--environment" in doctor_help
|
||||||
|
assert "pyro doctor --environment debian:12" in doctor_help
|
||||||
assert "pyro doctor --json" in doctor_help
|
assert "pyro doctor --json" in doctor_help
|
||||||
|
|
||||||
demo_help = _subparser_choice(parser, "demo").format_help()
|
demo_help = _subparser_choice(parser, "demo").format_help()
|
||||||
|
|
@ -115,8 +125,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
||||||
assert "pyro workspace create debian:12 --name repro-fix --label issue=123" in workspace_help
|
assert "pyro workspace create debian:12 --name repro-fix --label issue=123" in workspace_help
|
||||||
assert "pyro workspace list" in workspace_help
|
assert "pyro workspace list" in workspace_help
|
||||||
assert (
|
assert (
|
||||||
"pyro workspace update WORKSPACE_ID --name retry-run --label owner=codex"
|
"pyro workspace update WORKSPACE_ID --name retry-run --label owner=codex" in workspace_help
|
||||||
in workspace_help
|
|
||||||
)
|
)
|
||||||
assert "pyro workspace sync push WORKSPACE_ID ./repo --dest src" in workspace_help
|
assert "pyro workspace sync push WORKSPACE_ID ./repo --dest src" in workspace_help
|
||||||
assert "pyro workspace exec WORKSPACE_ID" in workspace_help
|
assert "pyro workspace exec WORKSPACE_ID" in workspace_help
|
||||||
|
|
@ -476,13 +485,22 @@ def test_cli_doctor_prints_json(
|
||||||
) -> None:
|
) -> None:
|
||||||
class StubParser:
|
class StubParser:
|
||||||
def parse_args(self) -> argparse.Namespace:
|
def parse_args(self) -> argparse.Namespace:
|
||||||
return argparse.Namespace(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, "_build_parser", lambda: StubParser())
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
cli,
|
cli,
|
||||||
"doctor_report",
|
"doctor_report",
|
||||||
lambda platform: {"platform": platform, "runtime_ok": True},
|
lambda *, platform, environment: {
|
||||||
|
"platform": platform,
|
||||||
|
"environment": environment,
|
||||||
|
"runtime_ok": True,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
cli.main()
|
cli.main()
|
||||||
output = json.loads(capsys.readouterr().out)
|
output = json.loads(capsys.readouterr().out)
|
||||||
|
|
@ -701,7 +719,7 @@ def test_cli_requires_command_preserves_shell_argument_boundaries() -> None:
|
||||||
command = cli._require_command(
|
command = cli._require_command(
|
||||||
["--", "sh", "-lc", 'printf "hello from workspace\\n" > note.txt']
|
["--", "sh", "-lc", 'printf "hello from workspace\\n" > note.txt']
|
||||||
)
|
)
|
||||||
assert command == 'sh -lc \'printf "hello from workspace\\n" > note.txt\''
|
assert command == "sh -lc 'printf \"hello from workspace\\n\" > note.txt'"
|
||||||
|
|
||||||
|
|
||||||
def test_cli_read_utf8_text_file_rejects_non_utf8(tmp_path: Path) -> None:
|
def test_cli_read_utf8_text_file_rejects_non_utf8(tmp_path: Path) -> None:
|
||||||
|
|
@ -977,10 +995,7 @@ def test_cli_workspace_exec_prints_human_output(
|
||||||
cli.main()
|
cli.main()
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert captured.out == "hello\n"
|
assert captured.out == "hello\n"
|
||||||
assert (
|
assert "[workspace-exec] workspace_id=workspace-123 sequence=2 cwd=/workspace" in captured.err
|
||||||
"[workspace-exec] workspace_id=workspace-123 sequence=2 cwd=/workspace"
|
|
||||||
in captured.err
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_print_workspace_summary_human_handles_last_command_and_secret_filtering(
|
def test_print_workspace_summary_human_handles_last_command_and_secret_filtering(
|
||||||
|
|
@ -1451,13 +1466,7 @@ def test_cli_workspace_patch_apply_reads_patch_file(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
patch_path = tmp_path / "fix.patch"
|
patch_path = tmp_path / "fix.patch"
|
||||||
patch_text = (
|
patch_text = "--- a/src/app.py\n+++ b/src/app.py\n@@ -1 +1 @@\n-print('hi')\n+print('hello')\n"
|
||||||
"--- a/src/app.py\n"
|
|
||||||
"+++ b/src/app.py\n"
|
|
||||||
"@@ -1 +1 @@\n"
|
|
||||||
"-print('hi')\n"
|
|
||||||
"+print('hello')\n"
|
|
||||||
)
|
|
||||||
patch_path.write_text(patch_text, encoding="utf-8")
|
patch_path.write_text(patch_text, encoding="utf-8")
|
||||||
|
|
||||||
class StubPyro:
|
class StubPyro:
|
||||||
|
|
@ -1905,10 +1914,7 @@ def test_cli_workspace_diff_prints_human_output(
|
||||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||||
cli.main()
|
cli.main()
|
||||||
output = capsys.readouterr().out
|
output = capsys.readouterr().out
|
||||||
assert (
|
assert "[workspace-diff] workspace_id=workspace-123 total=1 added=0 modified=1" in output
|
||||||
"[workspace-diff] workspace_id=workspace-123 total=1 added=0 modified=1"
|
|
||||||
in output
|
|
||||||
)
|
|
||||||
assert "--- a/note.txt" in output
|
assert "--- a/note.txt" in output
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2355,8 +2361,7 @@ def test_cli_workspace_sync_push_prints_human(
|
||||||
output = capsys.readouterr().out
|
output = capsys.readouterr().out
|
||||||
assert "[workspace-sync] workspace_id=workspace-123 mode=directory source=/tmp/repo" in output
|
assert "[workspace-sync] workspace_id=workspace-123 mode=directory source=/tmp/repo" in output
|
||||||
assert (
|
assert (
|
||||||
"destination=/workspace entry_count=2 bytes_written=12 "
|
"destination=/workspace entry_count=2 bytes_written=12 execution_mode=guest_vsock"
|
||||||
"execution_mode=guest_vsock"
|
|
||||||
) in output
|
) in output
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2659,9 +2664,7 @@ def test_cli_workspace_summary_prints_human(
|
||||||
},
|
},
|
||||||
"snapshots": {
|
"snapshots": {
|
||||||
"named_count": 1,
|
"named_count": 1,
|
||||||
"recent": [
|
"recent": [{"event_kind": "snapshot_create", "snapshot_name": "checkpoint"}],
|
||||||
{"event_kind": "snapshot_create", "snapshot_name": "checkpoint"}
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3132,7 +3135,7 @@ def test_chat_host_docs_and_examples_recommend_modes_first() -> None:
|
||||||
assert "--project-path /abs/path/to/repo" in readme
|
assert "--project-path /abs/path/to/repo" in readme
|
||||||
assert "--repo-url https://github.com/example/project.git" in readme
|
assert "--repo-url https://github.com/example/project.git" in readme
|
||||||
|
|
||||||
assert "## 5. Connect a chat host" in install
|
assert "## 6. Connect a chat host" in install
|
||||||
assert claude_helper in install
|
assert claude_helper in install
|
||||||
assert codex_helper in install
|
assert codex_helper in install
|
||||||
assert inspect_helper in install
|
assert inspect_helper in install
|
||||||
|
|
@ -3217,6 +3220,21 @@ def test_content_only_read_docs_are_aligned() -> None:
|
||||||
assert 'workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch' 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:
|
def test_workspace_summary_docs_are_aligned() -> None:
|
||||||
readme = Path("README.md").read_text(encoding="utf-8")
|
readme = Path("README.md").read_text(encoding="utf-8")
|
||||||
install = Path("docs/install.md").read_text(encoding="utf-8")
|
install = Path("docs/install.md").read_text(encoding="utf-8")
|
||||||
|
|
@ -4307,22 +4325,163 @@ def test_cli_doctor_prints_human(
|
||||||
) -> None:
|
) -> None:
|
||||||
class StubParser:
|
class StubParser:
|
||||||
def parse_args(self) -> argparse.Namespace:
|
def parse_args(self) -> argparse.Namespace:
|
||||||
return argparse.Namespace(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, "_build_parser", lambda: StubParser())
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
cli,
|
cli,
|
||||||
"doctor_report",
|
"doctor_report",
|
||||||
lambda platform: {
|
lambda *, platform, environment: {
|
||||||
"platform": platform,
|
"platform": platform,
|
||||||
"runtime_ok": True,
|
"runtime_ok": True,
|
||||||
"issues": [],
|
"issues": [],
|
||||||
"kvm": {"exists": True, "readable": True, "writable": True},
|
"kvm": {"exists": True, "readable": True, "writable": True},
|
||||||
|
"runtime": {"catalog_version": "4.5.0", "cache_dir": "/cache"},
|
||||||
|
"daily_loop": {
|
||||||
|
"environment": environment,
|
||||||
|
"status": "cold",
|
||||||
|
"installed": False,
|
||||||
|
"network_prepared": False,
|
||||||
|
"prepared_at": None,
|
||||||
|
"manifest_path": "/cache/.prepare/linux-x86_64/debian_12.json",
|
||||||
|
"reason": "daily loop has not been prepared yet",
|
||||||
|
"cache_dir": "/cache",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
cli.main()
|
cli.main()
|
||||||
output = capsys.readouterr().out
|
output = capsys.readouterr().out
|
||||||
assert "Runtime: PASS" in output
|
assert "Runtime: PASS" in output
|
||||||
|
assert "Daily loop: COLD (debian:12)" in output
|
||||||
|
assert "Run: pyro prepare debian:12" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_prepare_prints_human(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
class StubManager:
|
||||||
|
def prepare_daily_loop(
|
||||||
|
self,
|
||||||
|
environment: str,
|
||||||
|
*,
|
||||||
|
network: bool,
|
||||||
|
force: bool,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
assert environment == "debian:12"
|
||||||
|
assert network is True
|
||||||
|
assert force is False
|
||||||
|
return {
|
||||||
|
"environment": environment,
|
||||||
|
"status": "warm",
|
||||||
|
"prepared": True,
|
||||||
|
"reused": False,
|
||||||
|
"executed": True,
|
||||||
|
"network_prepared": True,
|
||||||
|
"prepared_at": 123.0,
|
||||||
|
"manifest_path": "/cache/.prepare/linux-x86_64/debian_12.json",
|
||||||
|
"cache_dir": "/cache",
|
||||||
|
"last_prepare_duration_ms": 456,
|
||||||
|
"reason": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
class StubPyro:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.manager = StubManager()
|
||||||
|
|
||||||
|
class StubParser:
|
||||||
|
def parse_args(self) -> argparse.Namespace:
|
||||||
|
return argparse.Namespace(
|
||||||
|
command="prepare",
|
||||||
|
environment="debian:12",
|
||||||
|
network=True,
|
||||||
|
force=False,
|
||||||
|
json=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||||
|
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||||
|
cli.main()
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "Prepare: debian:12" in output
|
||||||
|
assert "Daily loop: WARM" in output
|
||||||
|
assert "Result: prepared network_prepared=yes" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_prepare_prints_json_and_errors(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
class SuccessManager:
|
||||||
|
def prepare_daily_loop(
|
||||||
|
self,
|
||||||
|
environment: str,
|
||||||
|
*,
|
||||||
|
network: bool,
|
||||||
|
force: bool,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
assert environment == "debian:12"
|
||||||
|
assert network is False
|
||||||
|
assert force is True
|
||||||
|
return {"environment": environment, "reused": True}
|
||||||
|
|
||||||
|
class SuccessPyro:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.manager = SuccessManager()
|
||||||
|
|
||||||
|
class SuccessParser:
|
||||||
|
def parse_args(self) -> argparse.Namespace:
|
||||||
|
return argparse.Namespace(
|
||||||
|
command="prepare",
|
||||||
|
environment="debian:12",
|
||||||
|
network=False,
|
||||||
|
force=True,
|
||||||
|
json=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "_build_parser", lambda: SuccessParser())
|
||||||
|
monkeypatch.setattr(cli, "Pyro", SuccessPyro)
|
||||||
|
cli.main()
|
||||||
|
payload = json.loads(capsys.readouterr().out)
|
||||||
|
assert payload["reused"] is True
|
||||||
|
|
||||||
|
class ErrorManager:
|
||||||
|
def prepare_daily_loop(
|
||||||
|
self,
|
||||||
|
environment: str,
|
||||||
|
*,
|
||||||
|
network: bool,
|
||||||
|
force: bool,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
del environment, network, force
|
||||||
|
raise RuntimeError("prepare failed")
|
||||||
|
|
||||||
|
class ErrorPyro:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.manager = ErrorManager()
|
||||||
|
|
||||||
|
class ErrorParser:
|
||||||
|
def parse_args(self) -> argparse.Namespace:
|
||||||
|
return argparse.Namespace(
|
||||||
|
command="prepare",
|
||||||
|
environment="debian:12",
|
||||||
|
network=False,
|
||||||
|
force=False,
|
||||||
|
json=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "_build_parser", lambda: ErrorParser())
|
||||||
|
monkeypatch.setattr(cli, "Pyro", ErrorPyro)
|
||||||
|
with pytest.raises(SystemExit, match="1"):
|
||||||
|
cli.main()
|
||||||
|
error_payload = json.loads(capsys.readouterr().out)
|
||||||
|
assert error_payload["ok"] is False
|
||||||
|
assert error_payload["error"] == "prepare failed"
|
||||||
|
|
||||||
|
|
||||||
def test_cli_run_json_error_exits_nonzero(
|
def test_cli_run_json_error_exits_nonzero(
|
||||||
|
|
@ -4386,16 +4545,16 @@ def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
|
||||||
class StubParser:
|
class StubParser:
|
||||||
def parse_args(self) -> argparse.Namespace:
|
def parse_args(self) -> argparse.Namespace:
|
||||||
return argparse.Namespace(
|
return argparse.Namespace(
|
||||||
command="mcp",
|
command="mcp",
|
||||||
mcp_command="serve",
|
mcp_command="serve",
|
||||||
profile="workspace-core",
|
profile="workspace-core",
|
||||||
mode=None,
|
mode=None,
|
||||||
project_path="/repo",
|
project_path="/repo",
|
||||||
repo_url=None,
|
repo_url=None,
|
||||||
repo_ref=None,
|
repo_ref=None,
|
||||||
no_project_source=False,
|
no_project_source=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||||
|
|
@ -4526,7 +4685,7 @@ def test_cli_workspace_exec_passes_secret_env(
|
||||||
class StubPyro:
|
class StubPyro:
|
||||||
def exec_workspace(self, workspace_id: str, **kwargs: Any) -> dict[str, Any]:
|
def exec_workspace(self, workspace_id: str, **kwargs: Any) -> dict[str, Any]:
|
||||||
assert workspace_id == "ws-123"
|
assert workspace_id == "ws-123"
|
||||||
assert kwargs["command"] == "sh -lc 'test \"$API_TOKEN\" = \"expected\"'"
|
assert kwargs["command"] == 'sh -lc \'test "$API_TOKEN" = "expected"\''
|
||||||
assert kwargs["secret_env"] == {"API_TOKEN": "API_TOKEN", "TOKEN": "PIP_TOKEN"}
|
assert kwargs["secret_env"] == {"API_TOKEN": "API_TOKEN", "TOKEN": "PIP_TOKEN"}
|
||||||
return {"exit_code": 0, "stdout": "", "stderr": ""}
|
return {"exit_code": 0, "stdout": "", "stderr": ""}
|
||||||
|
|
||||||
|
|
|
||||||
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:
|
) -> None:
|
||||||
class StubParser:
|
class StubParser:
|
||||||
def parse_args(self) -> argparse.Namespace:
|
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, "_build_parser", lambda: StubParser())
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
doctor_module,
|
doctor_module,
|
||||||
"doctor_report",
|
"doctor_report",
|
||||||
lambda platform: {"platform": platform, "runtime_ok": True, "issues": []},
|
lambda *, platform, environment: {
|
||||||
|
"platform": platform,
|
||||||
|
"environment": environment,
|
||||||
|
"runtime_ok": True,
|
||||||
|
"issues": [],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
doctor_module.main()
|
doctor_module.main()
|
||||||
output = json.loads(capsys.readouterr().out)
|
output = json.loads(capsys.readouterr().out)
|
||||||
|
|
@ -32,3 +37,4 @@ def test_doctor_build_parser_defaults_platform() -> None:
|
||||||
parser = doctor_module._build_parser()
|
parser = doctor_module._build_parser()
|
||||||
args = parser.parse_args([])
|
args = parser.parse_args([])
|
||||||
assert args.platform == DEFAULT_PLATFORM
|
assert args.platform == DEFAULT_PLATFORM
|
||||||
|
assert args.environment == "debian:12"
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from pyro_mcp.cli import _build_parser
|
||||||
from pyro_mcp.contract import (
|
from pyro_mcp.contract import (
|
||||||
PUBLIC_CLI_COMMANDS,
|
PUBLIC_CLI_COMMANDS,
|
||||||
PUBLIC_CLI_DEMO_SUBCOMMANDS,
|
PUBLIC_CLI_DEMO_SUBCOMMANDS,
|
||||||
|
PUBLIC_CLI_DOCTOR_FLAGS,
|
||||||
PUBLIC_CLI_ENV_SUBCOMMANDS,
|
PUBLIC_CLI_ENV_SUBCOMMANDS,
|
||||||
PUBLIC_CLI_HOST_CONNECT_FLAGS,
|
PUBLIC_CLI_HOST_CONNECT_FLAGS,
|
||||||
PUBLIC_CLI_HOST_DOCTOR_FLAGS,
|
PUBLIC_CLI_HOST_DOCTOR_FLAGS,
|
||||||
|
|
@ -23,6 +24,7 @@ from pyro_mcp.contract import (
|
||||||
PUBLIC_CLI_HOST_SUBCOMMANDS,
|
PUBLIC_CLI_HOST_SUBCOMMANDS,
|
||||||
PUBLIC_CLI_MCP_SERVE_FLAGS,
|
PUBLIC_CLI_MCP_SERVE_FLAGS,
|
||||||
PUBLIC_CLI_MCP_SUBCOMMANDS,
|
PUBLIC_CLI_MCP_SUBCOMMANDS,
|
||||||
|
PUBLIC_CLI_PREPARE_FLAGS,
|
||||||
PUBLIC_CLI_RUN_FLAGS,
|
PUBLIC_CLI_RUN_FLAGS,
|
||||||
PUBLIC_CLI_WORKSPACE_CREATE_FLAGS,
|
PUBLIC_CLI_WORKSPACE_CREATE_FLAGS,
|
||||||
PUBLIC_CLI_WORKSPACE_DIFF_FLAGS,
|
PUBLIC_CLI_WORKSPACE_DIFF_FLAGS,
|
||||||
|
|
@ -113,6 +115,9 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
|
||||||
env_help_text = _subparser_choice(parser, "env").format_help()
|
env_help_text = _subparser_choice(parser, "env").format_help()
|
||||||
for subcommand_name in PUBLIC_CLI_ENV_SUBCOMMANDS:
|
for subcommand_name in PUBLIC_CLI_ENV_SUBCOMMANDS:
|
||||||
assert subcommand_name in env_help_text
|
assert subcommand_name in env_help_text
|
||||||
|
prepare_help_text = _subparser_choice(parser, "prepare").format_help()
|
||||||
|
for flag in PUBLIC_CLI_PREPARE_FLAGS:
|
||||||
|
assert flag in prepare_help_text
|
||||||
host_help_text = _subparser_choice(parser, "host").format_help()
|
host_help_text = _subparser_choice(parser, "host").format_help()
|
||||||
for subcommand_name in PUBLIC_CLI_HOST_SUBCOMMANDS:
|
for subcommand_name in PUBLIC_CLI_HOST_SUBCOMMANDS:
|
||||||
assert subcommand_name in host_help_text
|
assert subcommand_name in host_help_text
|
||||||
|
|
@ -136,6 +141,9 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
|
||||||
).format_help()
|
).format_help()
|
||||||
for flag in PUBLIC_CLI_HOST_REPAIR_FLAGS:
|
for flag in PUBLIC_CLI_HOST_REPAIR_FLAGS:
|
||||||
assert flag in host_repair_help_text
|
assert flag in host_repair_help_text
|
||||||
|
doctor_help_text = _subparser_choice(parser, "doctor").format_help()
|
||||||
|
for flag in PUBLIC_CLI_DOCTOR_FLAGS:
|
||||||
|
assert flag in doctor_help_text
|
||||||
mcp_help_text = _subparser_choice(parser, "mcp").format_help()
|
mcp_help_text = _subparser_choice(parser, "mcp").format_help()
|
||||||
for subcommand_name in PUBLIC_CLI_MCP_SUBCOMMANDS:
|
for subcommand_name in PUBLIC_CLI_MCP_SUBCOMMANDS:
|
||||||
assert subcommand_name in mcp_help_text
|
assert subcommand_name in mcp_help_text
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,32 @@ from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from pyro_mcp.daily_loop import DailyLoopManifest, prepare_manifest_path, write_prepare_manifest
|
||||||
from pyro_mcp.runtime import doctor_report, resolve_runtime_paths, runtime_capabilities
|
from pyro_mcp.runtime import doctor_report, resolve_runtime_paths, runtime_capabilities
|
||||||
|
from pyro_mcp.vm_environments import EnvironmentStore, get_environment
|
||||||
|
|
||||||
|
|
||||||
|
def _materialize_installed_environment(
|
||||||
|
environment_store: EnvironmentStore,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
) -> None:
|
||||||
|
spec = get_environment(name, runtime_paths=environment_store._runtime_paths)
|
||||||
|
install_dir = environment_store._install_dir(spec)
|
||||||
|
install_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(install_dir / "vmlinux").write_text("kernel\n", encoding="utf-8")
|
||||||
|
(install_dir / "rootfs.ext4").write_text("rootfs\n", encoding="utf-8")
|
||||||
|
(install_dir / "environment.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"name": spec.name,
|
||||||
|
"version": spec.version,
|
||||||
|
"source": "test-cache",
|
||||||
|
"source_digest": spec.source_digest,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_runtime_paths_default_bundle() -> None:
|
def test_resolve_runtime_paths_default_bundle() -> None:
|
||||||
|
|
@ -109,6 +134,7 @@ def test_doctor_report_has_runtime_fields() -> None:
|
||||||
assert "runtime_ok" in report
|
assert "runtime_ok" in report
|
||||||
assert "kvm" in report
|
assert "kvm" in report
|
||||||
assert "networking" in report
|
assert "networking" in report
|
||||||
|
assert "daily_loop" in report
|
||||||
if report["runtime_ok"]:
|
if report["runtime_ok"]:
|
||||||
runtime = report.get("runtime")
|
runtime = report.get("runtime")
|
||||||
assert isinstance(runtime, dict)
|
assert isinstance(runtime, dict)
|
||||||
|
|
@ -122,6 +148,61 @@ def test_doctor_report_has_runtime_fields() -> None:
|
||||||
assert "tun_available" in networking
|
assert "tun_available" in networking
|
||||||
|
|
||||||
|
|
||||||
|
def test_doctor_report_daily_loop_statuses(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("PYRO_ENVIRONMENT_CACHE_DIR", str(tmp_path))
|
||||||
|
cold_report = doctor_report(environment="debian:12")
|
||||||
|
cold_daily_loop = cold_report["daily_loop"]
|
||||||
|
assert cold_daily_loop["status"] == "cold"
|
||||||
|
assert cold_daily_loop["installed"] is False
|
||||||
|
|
||||||
|
paths = resolve_runtime_paths()
|
||||||
|
environment_store = EnvironmentStore(runtime_paths=paths, cache_dir=tmp_path)
|
||||||
|
_materialize_installed_environment(environment_store, name="debian:12")
|
||||||
|
|
||||||
|
installed_report = doctor_report(environment="debian:12")
|
||||||
|
installed_daily_loop = installed_report["daily_loop"]
|
||||||
|
assert installed_daily_loop["status"] == "cold"
|
||||||
|
assert installed_daily_loop["installed"] is True
|
||||||
|
|
||||||
|
manifest_path = prepare_manifest_path(
|
||||||
|
tmp_path,
|
||||||
|
platform="linux-x86_64",
|
||||||
|
environment="debian:12",
|
||||||
|
)
|
||||||
|
write_prepare_manifest(
|
||||||
|
manifest_path,
|
||||||
|
DailyLoopManifest(
|
||||||
|
environment="debian:12",
|
||||||
|
environment_version="1.0.0",
|
||||||
|
platform="linux-x86_64",
|
||||||
|
catalog_version=environment_store.catalog_version,
|
||||||
|
bundle_version=(
|
||||||
|
None
|
||||||
|
if paths.manifest.get("bundle_version") is None
|
||||||
|
else str(paths.manifest.get("bundle_version"))
|
||||||
|
),
|
||||||
|
prepared_at=123.0,
|
||||||
|
network_prepared=True,
|
||||||
|
last_prepare_duration_ms=456,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
warm_report = doctor_report(environment="debian:12")
|
||||||
|
warm_daily_loop = warm_report["daily_loop"]
|
||||||
|
assert warm_daily_loop["status"] == "warm"
|
||||||
|
assert warm_daily_loop["network_prepared"] is True
|
||||||
|
|
||||||
|
stale_manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
stale_manifest["catalog_version"] = "0.0.0"
|
||||||
|
manifest_path.write_text(json.dumps(stale_manifest), encoding="utf-8")
|
||||||
|
stale_report = doctor_report(environment="debian:12")
|
||||||
|
stale_daily_loop = stale_report["daily_loop"]
|
||||||
|
assert stale_daily_loop["status"] == "stale"
|
||||||
|
assert "catalog version changed" in str(stale_daily_loop["reason"])
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_capabilities_reports_real_bundle_flags() -> None:
|
def test_runtime_capabilities_reports_real_bundle_flags() -> None:
|
||||||
paths = resolve_runtime_paths()
|
paths = resolve_runtime_paths()
|
||||||
capabilities = runtime_capabilities(paths)
|
capabilities = runtime_capabilities(paths)
|
||||||
|
|
|
||||||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -715,7 +715,7 @@ crypto = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyro-mcp"
|
name = "pyro-mcp"
|
||||||
version = "4.4.0"
|
version = "4.5.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "mcp" },
|
{ name = "mcp" },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue