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:
Thales Maciel 2026-03-13 21:17:59 -03:00
parent d0cf6d8f21
commit 663241d5d2
26 changed files with 1592 additions and 199 deletions

View file

@ -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:

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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
``` ```

View file

@ -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
```

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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" }

View 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()

View file

@ -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()

View file

@ -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
View 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),
}

View 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()

View file

@ -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))

View file

@ -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

View file

@ -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)

View file

@ -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(

View file

@ -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
View 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

View 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"]

View file

@ -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"

View file

@ -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

View file

@ -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
View file

@ -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" },