From 663241d5d2686e4745b3725172178ad3eaa43aae Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Fri, 13 Mar 2026 21:17:59 -0300 Subject: [PATCH] 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`. --- CHANGELOG.md | 11 + Makefile | 10 +- README.md | 33 +- docs/first-run.md | 21 +- docs/install.md | 47 ++- docs/integrations.md | 14 + docs/public-contract.md | 17 +- docs/roadmap/llm-chat-ergonomics.md | 11 +- .../4.5.0-faster-daily-loops.md | 20 +- pyproject.toml | 2 +- scripts/daily_loop_smoke.py | 8 + src/pyro_mcp/cli.py | 178 +++++++-- src/pyro_mcp/contract.py | 4 +- src/pyro_mcp/daily_loop.py | 152 ++++++++ src/pyro_mcp/daily_loop_smoke.py | 131 +++++++ src/pyro_mcp/doctor.py | 4 +- src/pyro_mcp/runtime.py | 43 ++- src/pyro_mcp/vm_environments.py | 4 +- src/pyro_mcp/vm_manager.py | 242 ++++++++---- tests/test_cli.py | 241 ++++++++++-- tests/test_daily_loop.py | 359 ++++++++++++++++++ tests/test_daily_loop_smoke.py | 138 +++++++ tests/test_doctor.py | 10 +- tests/test_public_contract.py | 8 + tests/test_runtime.py | 81 ++++ uv.lock | 2 +- 26 files changed, 1592 insertions(+), 199 deletions(-) create mode 100644 scripts/daily_loop_smoke.py create mode 100644 src/pyro_mcp/daily_loop.py create mode 100644 src/pyro_mcp/daily_loop_smoke.py create mode 100644 tests/test_daily_loop.py create mode 100644 tests/test_daily_loop_smoke.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 982d57d..7d099a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable user-visible changes to `pyro-mcp` are documented here. +## 4.5.0 + +- Added `pyro prepare` as the machine-level warmup path for the daily local + loop, with cached reuse when the runtime, catalog, and environment state are + already warm. +- Extended `pyro doctor` with daily-loop readiness output so users can see + whether the machine is cold, warm, or stale for `debian:12` before they + reconnect a chat host. +- Added `make smoke-daily-loop` to prove the warmed repro/fix/reset path end + to end on a real guest-backed machine. + ## 4.4.0 - Added explicit named MCP/server modes for the main workspace workflows: diff --git a/Makefile b/Makefile index c76fe4e..465a2ab 100644 --- a/Makefile +++ b/Makefile @@ -18,8 +18,9 @@ TWINE_USERNAME ?= __token__ PYPI_REPOSITORY_URL ?= USE_CASE_ENVIRONMENT ?= debian:12 USE_CASE_SMOKE_FLAGS ?= +DAILY_LOOP_ENVIRONMENT ?= debian:12 -.PHONY: help setup lint format typecheck test check dist-check pypi-publish demo network-demo doctor ollama ollama-demo run-server install-hooks smoke-use-cases smoke-cold-start-validation smoke-repro-fix-loop smoke-parallel-workspaces smoke-untrusted-inspection smoke-review-eval runtime-bundle runtime-binaries runtime-kernel runtime-rootfs runtime-agent runtime-validate runtime-manifest runtime-sync runtime-clean runtime-fetch-binaries runtime-build-kernel-real runtime-build-rootfs-real runtime-materialize runtime-export-environment-oci runtime-export-official-environments-oci runtime-publish-environment-oci runtime-publish-official-environments-oci runtime-boot-check runtime-network-check +.PHONY: help setup lint format typecheck test check dist-check pypi-publish demo network-demo doctor ollama ollama-demo run-server install-hooks smoke-daily-loop smoke-use-cases smoke-cold-start-validation smoke-repro-fix-loop smoke-parallel-workspaces smoke-untrusted-inspection smoke-review-eval runtime-bundle runtime-binaries runtime-kernel runtime-rootfs runtime-agent runtime-validate runtime-manifest runtime-sync runtime-clean runtime-fetch-binaries runtime-build-kernel-real runtime-build-rootfs-real runtime-materialize runtime-export-environment-oci runtime-export-official-environments-oci runtime-publish-environment-oci runtime-publish-official-environments-oci runtime-boot-check runtime-network-check help: @printf '%s\n' \ @@ -36,6 +37,7 @@ help: ' demo Run the deterministic VM demo' \ ' network-demo Run the deterministic VM demo with guest networking enabled' \ ' doctor Show runtime and host diagnostics' \ + ' smoke-daily-loop Run the real guest-backed prepare plus reset daily-loop smoke' \ ' smoke-use-cases Run all real guest-backed workspace use-case smokes' \ ' smoke-cold-start-validation Run the cold-start repo validation smoke' \ ' smoke-repro-fix-loop Run the repro-plus-fix loop smoke' \ @@ -85,13 +87,14 @@ check: lint typecheck test dist-check: uv run python -m pyro_mcp.cli --version uv run python -m pyro_mcp.cli --help >/dev/null + uv run python -m pyro_mcp.cli prepare --help >/dev/null uv run python -m pyro_mcp.cli host --help >/dev/null uv run python -m pyro_mcp.cli host doctor >/dev/null uv run python -m pyro_mcp.cli mcp --help >/dev/null uv run python -m pyro_mcp.cli run --help >/dev/null uv run python -m pyro_mcp.cli env list >/dev/null uv run python -m pyro_mcp.cli env inspect debian:12 >/dev/null - uv run python -m pyro_mcp.cli doctor >/dev/null + uv run python -m pyro_mcp.cli doctor --environment debian:12 >/dev/null pypi-publish: @if [ -z "$$TWINE_PASSWORD" ]; then \ @@ -116,6 +119,9 @@ network-demo: doctor: uv run pyro doctor +smoke-daily-loop: + uv run python scripts/daily_loop_smoke.py --environment "$(DAILY_LOOP_ENVIRONMENT)" + smoke-use-cases: uv run python scripts/workspace_use_case_smoke.py --scenario all --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS) diff --git a/README.md b/README.md index f433117..06956d1 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ SDK-first platform. - Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md) - Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif) - Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif) -- What's new in 4.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/) ## Who It's For @@ -54,8 +54,7 @@ Use either of these equivalent quickstart paths: # Package without install python -m pip install uv uvx --from pyro-mcp pyro doctor -uvx --from pyro-mcp pyro env list -uvx --from pyro-mcp pyro env pull debian:12 +uvx --from pyro-mcp pyro prepare debian:12 uvx --from pyro-mcp pyro run debian:12 -- git --version ``` @@ -64,8 +63,7 @@ uvx --from pyro-mcp pyro run debian:12 -- git --version ```bash # Already installed pyro doctor -pyro env list -pyro env pull debian:12 +pyro prepare debian:12 pyro run debian:12 -- git --version ``` @@ -91,12 +89,21 @@ git version ... The first pull downloads an OCI environment from public Docker Hub, requires outbound HTTPS access to `registry-1.docker.io`, and needs local cache space -for the guest image. +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 -After the quickstart works, the intended next step is to connect a chat host in -one named mode. Use the helper flow first: +After the quickstart works, make the daily loop explicit before you connect the +chat host: + +```bash +uvx --from pyro-mcp pyro doctor --environment debian:12 +uvx --from pyro-mcp pyro prepare debian:12 +``` + +Then connect a chat host in one named mode. Use the helper flow first: ```bash uvx --from pyro-mcp pyro host connect codex --mode repro-fix @@ -198,13 +205,15 @@ snapshots, secrets, network policy, or disk tools. ## Zero To Hero 1. Validate the host with `pyro doctor`. -2. Pull `debian:12` and prove guest execution with `pyro run debian:12 -- git --version`. -3. Connect Claude Code, Codex, or OpenCode with one named mode such as +2. Warm the machine-level daily loop with `pyro prepare debian:12`. +3. Prove guest execution with `pyro run debian:12 -- git --version`. +4. Connect Claude Code, Codex, or OpenCode with one named mode such as `pyro host connect codex --mode repro-fix`, then fall back to raw `pyro mcp serve --mode ...` or the generic no-mode path when needed. -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. -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. That is the intended user journey. The terminal commands exist to validate and diff --git a/docs/first-run.md b/docs/first-run.md index 894067c..04af501 100644 --- a/docs/first-run.md +++ b/docs/first-run.md @@ -14,13 +14,16 @@ path is still being shaped. ## 1. Verify the host ```bash -$ uvx --from pyro-mcp pyro doctor +$ uvx --from pyro-mcp pyro doctor --environment debian:12 Platform: linux-x86_64 Runtime: PASS KVM: exists=yes readable=yes writable=yes Environment cache: /home/you/.cache/pyro-mcp/environments +Catalog version: 4.5.0 Capabilities: vm_boot=yes guest_exec=yes guest_network=yes Networking: tun=yes ip_forward=yes +Daily loop: COLD (debian:12) + Run: pyro prepare debian:12 ``` ## 2. Inspect the catalog @@ -71,6 +74,16 @@ streams, so they may appear in either order in terminals or capture tools. Use ## 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: ```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 and failure. +For the machine-level warmup plus retry story specifically: + +```bash +$ make smoke-daily-loop +``` + ## 9. Optional one-shot demo ```bash diff --git a/docs/install.md b/docs/install.md index ed03f26..c809a35 100644 --- a/docs/install.md +++ b/docs/install.md @@ -46,29 +46,27 @@ Use either of these equivalent evaluator paths: ```bash # Package without install uvx --from pyro-mcp pyro doctor -uvx --from pyro-mcp pyro env list -uvx --from pyro-mcp pyro env pull debian:12 +uvx --from pyro-mcp pyro prepare debian:12 uvx --from pyro-mcp pyro run debian:12 -- git --version ``` ```bash # Already installed pyro doctor -pyro env list -pyro env pull debian:12 +pyro prepare debian:12 pyro run debian:12 -- git --version ``` If you are running from a repo checkout instead, replace `pyro` with `uv run pyro`. -After that one-shot proof works, the intended next step is a named chat mode -through `pyro host connect` or `pyro host print-config`. +After that one-shot proof works, the intended next step is a warmed daily loop +plus a named chat mode through `pyro host connect` or `pyro host print-config`. ## 1. Check the host ```bash -uvx --from pyro-mcp pyro doctor +uvx --from pyro-mcp pyro doctor --environment debian:12 ``` Expected success signals: @@ -78,8 +76,11 @@ Platform: linux-x86_64 Runtime: PASS KVM: exists=yes readable=yes writable=yes Environment cache: /home/you/.cache/pyro-mcp/environments +Catalog version: 4.5.0 Capabilities: vm_boot=yes guest_exec=yes guest_network=yes Networking: tun=yes ip_forward=yes +Daily loop: COLD (debian:12) + Run: pyro prepare debian:12 ``` If `Runtime: FAIL`, stop here and use [troubleshooting.md](troubleshooting.md). @@ -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 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: @@ -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, snapshots, secrets, network policy, or disk tools. -## 6. Go from zero to hero +## 7. Go from zero to hero The intended user journey is: -1. validate the host with `pyro doctor` -2. pull `debian:12` +1. validate the host with `pyro doctor --environment debian:12` +2. warm the machine with `pyro prepare debian:12` 3. prove guest execution with `pyro run debian:12 -- git --version` 4. connect Claude Code, Codex, or OpenCode with one named mode such as `pyro host connect codex --mode repro-fix`, then use raw `pyro mcp serve --mode ...` or the generic no-mode path when needed -5. start with one use-case recipe from [use-cases/README.md](use-cases/README.md) -6. trust but verify with `make smoke-use-cases` +5. use `workspace reset` as the normal retry step inside that warmed loop +6. start with one use-case recipe from [use-cases/README.md](use-cases/README.md) +7. trust but verify with `make smoke-use-cases` If you want the shortest chat-first story, start with [use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md). -## 7. Manual terminal workspace flow +## 8. Manual terminal workspace flow If you want to inspect the workspace model directly from the terminal, use the companion flow below. This is for understanding and debugging the chat-host @@ -269,7 +281,7 @@ When you need deeper debugging or richer recipes, add: private tokens - `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 by a real Firecracker smoke pack: @@ -288,9 +300,8 @@ If you already installed the package, the same path works with plain `pyro ...`: ```bash uv tool install pyro-mcp pyro --version -pyro doctor -pyro env list -pyro env pull debian:12 +pyro doctor --environment debian:12 +pyro prepare debian:12 pyro run debian:12 -- git --version pyro mcp serve ``` diff --git a/docs/integrations.md b/docs/integrations.md index 1abf5a9..4974302 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -3,6 +3,7 @@ This page documents the intended product path for `pyro-mcp`: - validate the host with the CLI +- warm the daily loop with `pyro prepare debian:12` - run `pyro mcp serve` - connect a chat host - let the agent work inside disposable workspaces @@ -13,6 +14,13 @@ path is still being shaped. 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). +Recommended first commands before connecting a host: + +```bash +pyro doctor --environment debian:12 +pyro prepare debian:12 +``` + ## Recommended Modes Use a named mode when one workflow already matches the job: @@ -241,3 +249,9 @@ Validate the whole story with: ```bash make smoke-use-cases ``` + +For the machine-warmup plus reset/retry path specifically: + +```bash +make smoke-daily-loop +``` diff --git a/docs/public-contract.md b/docs/public-contract.md index 3d2265b..69412f0 100644 --- a/docs/public-contract.md +++ b/docs/public-contract.md @@ -30,11 +30,11 @@ documents the chat-host path the project is actively shaping. The intended user journey is: 1. `pyro doctor` -2. `pyro env list` -3. `pyro env pull debian:12` -4. `pyro run debian:12 -- git --version` -5. `pyro mcp serve` -6. connect Claude Code, Codex, or OpenCode +2. `pyro prepare debian:12` +3. `pyro run debian:12 -- git --version` +4. `pyro mcp serve` +5. connect Claude Code, Codex, or OpenCode +6. use `workspace reset` as the normal retry step 7. run one of the documented recipe-backed workflows 8. validate the whole story with `make smoke-use-cases` @@ -44,6 +44,7 @@ These terminal commands are the documented companion path for the chat-host product: - `pyro doctor` +- `pyro prepare` - `pyro env list` - `pyro env pull` - `pyro run` @@ -55,10 +56,12 @@ What to expect from that path: - `pyro run` fails if guest boot or guest exec is unavailable unless `--allow-host-compat` is set - `pyro run`, `pyro env list`, `pyro env pull`, `pyro env inspect`, - `pyro env prune`, and `pyro doctor` are human-readable by default and return - structured JSON with `--json` + `pyro env prune`, `pyro doctor`, and `pyro prepare` are human-readable by + default and return structured JSON with `--json` - the first official environment pull downloads from public Docker Hub into the local environment cache +- `pyro prepare debian:12` proves the warmed daily loop with one throwaway + workspace create, exec, reset, and delete cycle - `pyro demo` proves the one-shot create/start/exec/delete VM lifecycle end to end diff --git a/docs/roadmap/llm-chat-ergonomics.md b/docs/roadmap/llm-chat-ergonomics.md index be346fa..229d3da 100644 --- a/docs/roadmap/llm-chat-ergonomics.md +++ b/docs/roadmap/llm-chat-ergonomics.md @@ -6,7 +6,7 @@ goal: make the core agent-workspace use cases feel trivial from a chat-driven LLM interface. -Current baseline is `4.4.0`: +Current baseline is `4.5.0`: - `pyro mcp serve` is now the default product entrypoint - `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 14. [`4.3.0` Reviewable Agent Output](llm-chat-ergonomics/4.3.0-reviewable-agent-output.md) - Done 15. [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md) - Done -16. [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md) - Planned +16. [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md) - Done 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`, `inspect`, `cold-start`, or `review-eval` instead of choosing from the full generic workspace surface first. - -Planned next: - -- [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md) +- `4.5.0` adds `pyro prepare`, daily-loop readiness in `pyro doctor`, and a + real `make smoke-daily-loop` verification path so the local machine warmup + story is explicit before the chat host connects. ## Expected Outcome diff --git a/docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md b/docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md index 01c765c..ecbd8b8 100644 --- a/docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md +++ b/docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md @@ -1,6 +1,6 @@ # `4.5.0` Faster Daily Loops -Status: Planned +Status: Done ## Goal @@ -9,11 +9,11 @@ for normal work, not only for special high-isolation tasks. ## 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 -- a clearer fast reset or retry path for repeated repro-fix loops -- visible diagnostics for cache, prewarm, or ready-state health +- `pyro prepare [ENVIRONMENT] [--network] [--force] [--json]` +- `pyro doctor --environment ENVIRONMENT` daily-loop readiness output +- `make smoke-daily-loop` to prove the warmed machine plus reset/retry story The exact command names can still move, but the user-visible story needs to be: @@ -50,8 +50,8 @@ The exact command names can still move, but the user-visible story needs to be: ## Required Repo Updates -- docs updated to show the recommended daily-use fast path -- diagnostics and help text updated so the user can tell whether the machine is - already warm and ready -- at least one repeat-loop smoke or benchmark-style verification scenario - added to prevent regressions in the daily workflow +- docs now show the recommended daily-use fast path +- diagnostics and help text now show whether the machine is already warm and + ready +- the repo now includes `make smoke-daily-loop` as a repeat-loop verification + scenario for the daily workflow diff --git a/pyproject.toml b/pyproject.toml index 796ba47..5e9b649 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyro-mcp" -version = "4.4.0" +version = "4.5.0" description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM." readme = "README.md" license = { file = "LICENSE" } diff --git a/scripts/daily_loop_smoke.py b/scripts/daily_loop_smoke.py new file mode 100644 index 0000000..dc40980 --- /dev/null +++ b/scripts/daily_loop_smoke.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 + +"""Run the real guest-backed daily-loop smoke.""" + +from pyro_mcp.daily_loop_smoke import main + +if __name__ == "__main__": + main() diff --git a/src/pyro_mcp/cli.py b/src/pyro_mcp/cli.py index 5b5293b..79eb3a9 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -13,6 +13,7 @@ from typing import Any, cast from pyro_mcp import __version__ from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode from pyro_mcp.contract import PUBLIC_MCP_MODES, PUBLIC_MCP_PROFILES +from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT from pyro_mcp.demo import run_demo from pyro_mcp.host_helpers import ( HostDoctorEntry, @@ -154,6 +155,7 @@ def _print_doctor_human(payload: dict[str, Any]) -> None: ) runtime = payload.get("runtime") if isinstance(runtime, dict): + print(f"Catalog version: {str(runtime.get('catalog_version', 'unknown'))}") print(f"Environment cache: {str(runtime.get('cache_dir', 'unknown'))}") capabilities = runtime.get("capabilities") if isinstance(capabilities, dict): @@ -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"ip_forward={'yes' if bool(networking.get('ip_forward_enabled')) else 'no'}" ) + daily_loop = payload.get("daily_loop") + if isinstance(daily_loop, dict): + status = str(daily_loop.get("status", "cold")).upper() + environment = str(daily_loop.get("environment", DEFAULT_PREPARE_ENVIRONMENT)) + print(f"Daily loop: {status} ({environment})") + print( + " " + f"installed={'yes' if bool(daily_loop.get('installed')) else 'no'} " + f"network_prepared={'yes' if bool(daily_loop.get('network_prepared')) else 'no'}" + ) + prepared_at = daily_loop.get("prepared_at") + if prepared_at is not None: + print(f" prepared_at={prepared_at}") + reason = daily_loop.get("reason") + if isinstance(reason, str) and reason != "": + print(f" reason={reason}") + if str(daily_loop.get("status", "cold")) != "warm": + print(f" Run: pyro prepare {environment}") if isinstance(issues, list) and issues: print("Issues:") for issue in issues: print(f"- {issue}") +def _print_prepare_human(payload: dict[str, Any]) -> None: + environment = str(payload.get("environment", DEFAULT_PREPARE_ENVIRONMENT)) + status = str(payload.get("status", "cold")).upper() + print(f"Prepare: {environment}") + print(f"Daily loop: {status}") + print( + "Result: " + f"{'reused' if bool(payload.get('reused')) else 'prepared'} " + f"network_prepared={'yes' if bool(payload.get('network_prepared')) else 'no'}" + ) + print(f"Cache dir: {str(payload.get('cache_dir', 'unknown'))}") + print(f"Manifest: {str(payload.get('manifest_path', 'unknown'))}") + prepared_at = payload.get("prepared_at") + if prepared_at is not None: + print(f"Prepared at: {prepared_at}") + print(f"Duration: {int(payload.get('last_prepare_duration_ms', 0))} ms") + reason = payload.get("reason") + if isinstance(reason, str) and reason != "": + print(f"Reason: {reason}") + + def _build_host_server_config(args: argparse.Namespace) -> HostServerConfig: return HostServerConfig( installed_package=bool(getattr(args, "installed_package", False)), @@ -899,8 +940,7 @@ def _build_parser() -> argparse.ArgumentParser: """ Suggested zero-to-hero path: pyro doctor - pyro env list - pyro env pull debian:12 + pyro prepare debian:12 pyro run debian:12 -- git --version pyro host connect claude-code @@ -909,6 +949,11 @@ def _build_parser() -> argparse.ArgumentParser: pyro host connect codex pyro host print-config opencode + Daily local loop after the first warmup: + pyro doctor --environment debian:12 + pyro prepare debian:12 + pyro workspace reset WORKSPACE_ID + If you want terminal-level visibility into the workspace model: pyro workspace create debian:12 --seed-path ./repo --id-only pyro workspace sync push WORKSPACE_ID ./changes @@ -928,6 +973,51 @@ def _build_parser() -> argparse.ArgumentParser: parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") subparsers = parser.add_subparsers(dest="command", required=True, metavar="COMMAND") + prepare_parser = subparsers.add_parser( + "prepare", + help="Warm the local machine for the daily workspace loop.", + description=( + "Warm the recommended guest-backed daily loop by ensuring the " + "environment is installed and proving create, exec, reset, and " + "delete on one throwaway workspace." + ), + epilog=dedent( + f""" + Examples: + pyro prepare + pyro prepare {DEFAULT_PREPARE_ENVIRONMENT} + pyro prepare {DEFAULT_PREPARE_ENVIRONMENT} --network + pyro prepare {DEFAULT_PREPARE_ENVIRONMENT} --force + """ + ), + formatter_class=_HelpFormatter, + ) + prepare_parser.add_argument( + "environment", + nargs="?", + default=DEFAULT_PREPARE_ENVIRONMENT, + metavar="ENVIRONMENT", + help=( + "Curated environment to warm for the daily loop. Defaults to " + f"`{DEFAULT_PREPARE_ENVIRONMENT}`." + ), + ) + prepare_parser.add_argument( + "--network", + action="store_true", + help="Also warm guest networking by proving one egress-enabled workspace cycle.", + ) + prepare_parser.add_argument( + "--force", + action="store_true", + help="Rerun warmup even when a compatible warm manifest already exists.", + ) + prepare_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + env_parser = subparsers.add_parser( "env", help="Inspect and manage curated environments.", @@ -1245,10 +1335,7 @@ def _build_parser() -> argparse.ArgumentParser: mcp_serve_parser.add_argument( "--no-project-source", action="store_true", - help=( - "Disable automatic Git checkout detection from the current working " - "directory." - ), + help=("Disable automatic Git checkout detection from the current working directory."), ) run_parser = subparsers.add_parser( @@ -1306,8 +1393,7 @@ def _build_parser() -> argparse.ArgumentParser: "--allow-host-compat", action="store_true", help=( - "Opt into host-side compatibility execution if guest boot or guest exec " - "is unavailable." + "Opt into host-side compatibility execution if guest boot or guest exec is unavailable." ), ) run_parser.add_argument( @@ -1428,8 +1514,7 @@ def _build_parser() -> argparse.ArgumentParser: "--allow-host-compat", action="store_true", help=( - "Opt into host-side compatibility execution if guest boot or guest exec " - "is unavailable." + "Opt into host-side compatibility execution if guest boot or guest exec is unavailable." ), ) workspace_create_parser.add_argument( @@ -1479,8 +1564,7 @@ def _build_parser() -> argparse.ArgumentParser: "exec", help="Run one command inside an existing workspace.", description=( - "Run one non-interactive command in the persistent `/workspace` " - "for a workspace." + "Run one non-interactive command in the persistent `/workspace` for a workspace." ), epilog=dedent( """ @@ -1716,8 +1800,7 @@ def _build_parser() -> argparse.ArgumentParser: "created automatically." ), epilog=( - "Example:\n" - " pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py" + "Example:\n pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py" ), formatter_class=_HelpFormatter, ) @@ -1909,8 +1992,7 @@ def _build_parser() -> argparse.ArgumentParser: "start", help="Start one stopped workspace without resetting it.", description=( - "Start a previously stopped workspace from its preserved rootfs and " - "workspace state." + "Start a previously stopped workspace from its preserved rootfs and workspace state." ), epilog="Example:\n pyro workspace start WORKSPACE_ID", formatter_class=_HelpFormatter, @@ -2036,8 +2118,7 @@ def _build_parser() -> argparse.ArgumentParser: "shell", help="Open and manage persistent interactive shells.", description=( - "Open one or more persistent interactive PTY shell sessions inside a started " - "workspace." + "Open one or more persistent interactive PTY shell sessions inside a started workspace." ), epilog=dedent( """ @@ -2520,8 +2601,7 @@ while true; do sleep 60; done' "logs", help="Show command history for one workspace.", description=( - "Show persisted command history, including stdout and stderr, " - "for one workspace." + "Show persisted command history, including stdout and stderr, for one workspace." ), epilog="Example:\n pyro workspace logs WORKSPACE_ID", formatter_class=_HelpFormatter, @@ -2557,11 +2637,16 @@ while true; do sleep 60; done' doctor_parser = subparsers.add_parser( "doctor", help="Inspect runtime and host diagnostics.", - description="Check host prerequisites and embedded runtime health before your first run.", + description=( + "Check host prerequisites and embedded runtime health, plus " + "daily-loop warmth before your first run or before reconnecting a " + "chat host." + ), epilog=dedent( """ Examples: pyro doctor + pyro doctor --environment debian:12 pyro doctor --json """ ), @@ -2572,6 +2657,14 @@ while true; do sleep 60; done' default=DEFAULT_PLATFORM, help="Runtime platform to inspect.", ) + doctor_parser.add_argument( + "--environment", + default=DEFAULT_PREPARE_ENVIRONMENT, + help=( + "Environment to inspect for the daily-loop warm manifest. " + f"Defaults to `{DEFAULT_PREPARE_ENVIRONMENT}`." + ), + ) doctor_parser.add_argument( "--json", action="store_true", @@ -2734,6 +2827,24 @@ def _parse_workspace_publish_options(values: list[str]) -> list[dict[str, int | def main() -> None: args = _build_parser().parse_args() pyro = Pyro() + if args.command == "prepare": + try: + payload = pyro.manager.prepare_daily_loop( + args.environment, + network=bool(args.network), + force=bool(args.force), + ) + except Exception as exc: # noqa: BLE001 + if bool(args.json): + _print_json({"ok": False, "error": str(exc)}) + else: + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + if bool(args.json): + _print_json(payload) + else: + _print_prepare_human(payload) + return if args.command == "env": if args.env_command == "list": list_payload: dict[str, Any] = { @@ -2881,10 +2992,7 @@ def main() -> None: if args.command == "workspace": if args.workspace_command == "create": secrets = [ - *( - _parse_workspace_secret_option(value) - for value in getattr(args, "secret", []) - ), + *(_parse_workspace_secret_option(value) for value in getattr(args, "secret", [])), *( _parse_workspace_secret_file_option(value) for value in getattr(args, "secret_file", []) @@ -2919,9 +3027,7 @@ def main() -> None: return if args.workspace_command == "update": labels = _parse_workspace_label_options(getattr(args, "label", [])) - clear_labels = _parse_workspace_clear_label_options( - getattr(args, "clear_label", []) - ) + clear_labels = _parse_workspace_clear_label_options(getattr(args, "clear_label", [])) try: payload = pyro.update_workspace( args.workspace_id, @@ -3527,7 +3633,17 @@ def main() -> None: print(f"Deleted workspace: {str(payload.get('workspace_id', 'unknown'))}") return if args.command == "doctor": - payload = doctor_report(platform=args.platform) + try: + payload = doctor_report( + platform=args.platform, + environment=args.environment, + ) + except Exception as exc: # noqa: BLE001 + if bool(args.json): + _print_json({"ok": False, "error": str(exc)}) + else: + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc if bool(args.json): _print_json(payload) else: @@ -3558,3 +3674,7 @@ def main() -> None: return result = run_demo(network=bool(args.network)) _print_json(result) + + +if __name__ == "__main__": + main() diff --git a/src/pyro_mcp/contract.py b/src/pyro_mcp/contract.py index bebae39..a7b2ba1 100644 --- a/src/pyro_mcp/contract.py +++ b/src/pyro_mcp/contract.py @@ -2,9 +2,10 @@ 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_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune") +PUBLIC_CLI_DOCTOR_FLAGS = ("--platform", "--environment", "--json") PUBLIC_CLI_HOST_SUBCOMMANDS = ("connect", "doctor", "print-config", "repair") PUBLIC_CLI_HOST_COMMON_FLAGS = ( "--installed-package", @@ -28,6 +29,7 @@ PUBLIC_CLI_MCP_SERVE_FLAGS = ( "--repo-ref", "--no-project-source", ) +PUBLIC_CLI_PREPARE_FLAGS = ("--network", "--force", "--json") PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = ( "create", "delete", diff --git a/src/pyro_mcp/daily_loop.py b/src/pyro_mcp/daily_loop.py new file mode 100644 index 0000000..164e1ba --- /dev/null +++ b/src/pyro_mcp/daily_loop.py @@ -0,0 +1,152 @@ +"""Machine-level daily-loop warmup state for the CLI prepare flow.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +DEFAULT_PREPARE_ENVIRONMENT = "debian:12" +PREPARE_MANIFEST_LAYOUT_VERSION = 1 +DailyLoopStatus = Literal["cold", "warm", "stale"] + + +def _environment_key(environment: str) -> str: + return environment.replace("/", "_").replace(":", "_") + + +@dataclass(frozen=True) +class DailyLoopManifest: + """Persisted machine-readiness proof for one environment on one platform.""" + + environment: str + environment_version: str + platform: str + catalog_version: str + bundle_version: str | None + prepared_at: float + network_prepared: bool + last_prepare_duration_ms: int + + def to_payload(self) -> dict[str, Any]: + return { + "layout_version": PREPARE_MANIFEST_LAYOUT_VERSION, + "environment": self.environment, + "environment_version": self.environment_version, + "platform": self.platform, + "catalog_version": self.catalog_version, + "bundle_version": self.bundle_version, + "prepared_at": self.prepared_at, + "network_prepared": self.network_prepared, + "last_prepare_duration_ms": self.last_prepare_duration_ms, + } + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> "DailyLoopManifest": + return cls( + environment=str(payload["environment"]), + environment_version=str(payload["environment_version"]), + platform=str(payload["platform"]), + catalog_version=str(payload["catalog_version"]), + bundle_version=( + None if payload.get("bundle_version") is None else str(payload["bundle_version"]) + ), + prepared_at=float(payload["prepared_at"]), + network_prepared=bool(payload.get("network_prepared", False)), + last_prepare_duration_ms=int(payload.get("last_prepare_duration_ms", 0)), + ) + + +def prepare_manifest_path(cache_dir: Path, *, platform: str, environment: str) -> Path: + return cache_dir / ".prepare" / platform / f"{_environment_key(environment)}.json" + + +def load_prepare_manifest(path: Path) -> tuple[DailyLoopManifest | None, str | None]: + if not path.exists(): + return None, None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return None, f"prepare manifest is unreadable: {exc}" + if not isinstance(payload, dict): + return None, "prepare manifest is not a JSON object" + try: + manifest = DailyLoopManifest.from_payload(payload) + except (KeyError, TypeError, ValueError) as exc: + return None, f"prepare manifest is invalid: {exc}" + return manifest, None + + +def write_prepare_manifest(path: Path, manifest: DailyLoopManifest) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(manifest.to_payload(), indent=2, sort_keys=True), + encoding="utf-8", + ) + + +def evaluate_daily_loop_status( + *, + environment: str, + environment_version: str, + platform: str, + catalog_version: str, + bundle_version: str | None, + installed: bool, + manifest: DailyLoopManifest | None, + manifest_error: str | None = None, +) -> tuple[DailyLoopStatus, str | None]: + if manifest_error is not None: + return "stale", manifest_error + if manifest is None: + if not installed: + return "cold", "environment is not installed" + return "cold", "daily loop has not been prepared yet" + if not installed: + return "stale", "environment install is missing" + if manifest.environment != environment: + return "stale", "prepare manifest environment does not match the selected environment" + if manifest.environment_version != environment_version: + return "stale", "environment version changed since the last prepare run" + if manifest.platform != platform: + return "stale", "platform changed since the last prepare run" + if manifest.catalog_version != catalog_version: + return "stale", "catalog version changed since the last prepare run" + if manifest.bundle_version != bundle_version: + return "stale", "runtime bundle version changed since the last prepare run" + return "warm", None + + +def prepare_request_is_satisfied( + manifest: DailyLoopManifest | None, + *, + require_network: bool, +) -> bool: + if manifest is None: + return False + if require_network and not manifest.network_prepared: + return False + return True + + +def serialize_daily_loop_report( + *, + environment: str, + status: DailyLoopStatus, + installed: bool, + cache_dir: Path, + manifest_path: Path, + reason: str | None, + manifest: DailyLoopManifest | None, +) -> dict[str, Any]: + return { + "environment": environment, + "status": status, + "installed": installed, + "network_prepared": bool(manifest.network_prepared) if manifest is not None else False, + "prepared_at": None if manifest is None else manifest.prepared_at, + "manifest_path": str(manifest_path), + "reason": reason, + "cache_dir": str(cache_dir), + } diff --git a/src/pyro_mcp/daily_loop_smoke.py b/src/pyro_mcp/daily_loop_smoke.py new file mode 100644 index 0000000..4cd82c7 --- /dev/null +++ b/src/pyro_mcp/daily_loop_smoke.py @@ -0,0 +1,131 @@ +"""Real guest-backed smoke for the daily local prepare and reset loop.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +from pyro_mcp.api import Pyro +from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT + + +def _log(message: str) -> None: + print(f"[daily-loop] {message}", flush=True) + + +def _write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def _run_prepare(environment: str) -> dict[str, object]: + proc = subprocess.run( # noqa: S603 + [sys.executable, "-m", "pyro_mcp.cli", "prepare", environment, "--json"], + text=True, + capture_output=True, + check=False, + ) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "pyro prepare failed") + payload = json.loads(proc.stdout) + if not isinstance(payload, dict): + raise RuntimeError("pyro prepare did not return a JSON object") + return payload + + +def run_daily_loop_smoke(*, environment: str = DEFAULT_PREPARE_ENVIRONMENT) -> None: + _log(f"prepare environment={environment}") + first_prepare = _run_prepare(environment) + assert bool(first_prepare["prepared"]) is True, first_prepare + second_prepare = _run_prepare(environment) + assert bool(second_prepare["reused"]) is True, second_prepare + + pyro = Pyro() + with tempfile.TemporaryDirectory(prefix="pyro-daily-loop-") as temp_dir: + root = Path(temp_dir) + seed_dir = root / "seed" + export_dir = root / "export" + _write_text(seed_dir / "message.txt", "broken\n") + _write_text( + seed_dir / "check.sh", + "#!/bin/sh\n" + "set -eu\n" + "value=$(cat message.txt)\n" + '[ "$value" = "fixed" ] || {\n' + " printf 'expected fixed got %s\\n' \"$value\" >&2\n" + " exit 1\n" + "}\n" + "printf '%s\\n' \"$value\"\n", + ) + + workspace_id: str | None = None + try: + created = pyro.create_workspace( + environment=environment, + seed_path=seed_dir, + name="daily-loop", + labels={"suite": "daily-loop-smoke"}, + ) + workspace_id = str(created["workspace_id"]) + _log(f"workspace_id={workspace_id}") + + failing = pyro.exec_workspace(workspace_id, command="sh check.sh") + assert int(failing["exit_code"]) != 0, failing + + patched = pyro.apply_workspace_patch( + workspace_id, + patch=("--- a/message.txt\n+++ b/message.txt\n@@ -1 +1 @@\n-broken\n+fixed\n"), + ) + assert bool(patched["changed"]) is True, patched + + passing = pyro.exec_workspace(workspace_id, command="sh check.sh") + assert int(passing["exit_code"]) == 0, passing + assert str(passing["stdout"]) == "fixed\n", passing + + export_path = export_dir / "message.txt" + exported = pyro.export_workspace( + workspace_id, + "message.txt", + output_path=export_path, + ) + assert export_path.read_text(encoding="utf-8") == "fixed\n" + assert str(exported["artifact_type"]) == "file", exported + + reset = pyro.reset_workspace(workspace_id) + assert int(reset["reset_count"]) == 1, reset + + rerun = pyro.exec_workspace(workspace_id, command="sh check.sh") + assert int(rerun["exit_code"]) != 0, rerun + reset_read = pyro.read_workspace_file(workspace_id, "message.txt") + assert str(reset_read["content"]) == "broken\n", reset_read + finally: + if workspace_id is not None: + try: + pyro.delete_workspace(workspace_id) + except Exception: + pass + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Run the real guest-backed daily-loop prepare and reset smoke.", + ) + parser.add_argument( + "--environment", + default=DEFAULT_PREPARE_ENVIRONMENT, + help=f"Environment to warm and test. Defaults to `{DEFAULT_PREPARE_ENVIRONMENT}`.", + ) + return parser + + +def main() -> None: + args = build_arg_parser().parse_args() + run_daily_loop_smoke(environment=args.environment) + + +if __name__ == "__main__": + main() diff --git a/src/pyro_mcp/doctor.py b/src/pyro_mcp/doctor.py index 296fedb..1de3ba3 100644 --- a/src/pyro_mcp/doctor.py +++ b/src/pyro_mcp/doctor.py @@ -5,16 +5,18 @@ from __future__ import annotations import argparse import json +from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Inspect bundled runtime health for pyro-mcp.") parser.add_argument("--platform", default=DEFAULT_PLATFORM) + parser.add_argument("--environment", default=DEFAULT_PREPARE_ENVIRONMENT) return parser def main() -> None: args = _build_parser().parse_args() - report = doctor_report(platform=args.platform) + report = doctor_report(platform=args.platform, environment=args.environment) print(json.dumps(report, indent=2, sort_keys=True)) diff --git a/src/pyro_mcp/runtime.py b/src/pyro_mcp/runtime.py index 832e666..24779a3 100644 --- a/src/pyro_mcp/runtime.py +++ b/src/pyro_mcp/runtime.py @@ -11,6 +11,13 @@ from dataclasses import dataclass from pathlib import Path from typing import Any +from pyro_mcp.daily_loop import ( + DEFAULT_PREPARE_ENVIRONMENT, + evaluate_daily_loop_status, + load_prepare_manifest, + prepare_manifest_path, + serialize_daily_loop_report, +) from pyro_mcp.vm_network import TapNetworkManager DEFAULT_PLATFORM = "linux-x86_64" @@ -200,7 +207,11 @@ def runtime_capabilities(paths: RuntimePaths) -> RuntimeCapabilities: ) -def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]: +def doctor_report( + *, + platform: str = DEFAULT_PLATFORM, + environment: str = DEFAULT_PREPARE_ENVIRONMENT, +) -> dict[str, Any]: """Build a runtime diagnostics report.""" report: dict[str, Any] = { "platform": platform, @@ -258,6 +269,36 @@ def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]: "cache_dir": str(environment_store.cache_dir), "environments": environment_store.list_environments(), } + environment_details = environment_store.inspect_environment(environment) + manifest_path = prepare_manifest_path( + environment_store.cache_dir, + platform=platform, + environment=environment, + ) + manifest, manifest_error = load_prepare_manifest(manifest_path) + status, reason = evaluate_daily_loop_status( + environment=environment, + environment_version=str(environment_details["version"]), + platform=platform, + catalog_version=environment_store.catalog_version, + bundle_version=( + None + if paths.manifest.get("bundle_version") is None + else str(paths.manifest["bundle_version"]) + ), + installed=bool(environment_details["installed"]), + manifest=manifest, + manifest_error=manifest_error, + ) + report["daily_loop"] = serialize_daily_loop_report( + environment=environment, + status=status, + installed=bool(environment_details["installed"]), + cache_dir=environment_store.cache_dir, + manifest_path=manifest_path, + reason=reason, + manifest=manifest, + ) if not report["kvm"]["exists"]: report["issues"] = ["/dev/kvm is not available on this host"] return report diff --git a/src/pyro_mcp/vm_environments.py b/src/pyro_mcp/vm_environments.py index 74b1fe6..bd65b56 100644 --- a/src/pyro_mcp/vm_environments.py +++ b/src/pyro_mcp/vm_environments.py @@ -19,7 +19,7 @@ from typing import Any from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths DEFAULT_ENVIRONMENT_VERSION = "1.0.0" -DEFAULT_CATALOG_VERSION = "4.4.0" +DEFAULT_CATALOG_VERSION = "4.5.0" OCI_MANIFEST_ACCEPT = ", ".join( ( "application/vnd.oci.image.index.v1+json", @@ -48,7 +48,7 @@ class VmEnvironment: oci_repository: str | None = None oci_reference: str | None = None source_digest: str | None = None - compatibility: str = ">=4.4.0,<5.0.0" + compatibility: str = ">=4.5.0,<5.0.0" @dataclass(frozen=True) diff --git a/src/pyro_mcp/vm_manager.py b/src/pyro_mcp/vm_manager.py index 1e99682..669b9cd 100644 --- a/src/pyro_mcp/vm_manager.py +++ b/src/pyro_mcp/vm_manager.py @@ -24,6 +24,15 @@ from dataclasses import dataclass, field from pathlib import Path, PurePosixPath from typing import Any, Literal, cast +from pyro_mcp.daily_loop import ( + DailyLoopManifest, + evaluate_daily_loop_status, + load_prepare_manifest, + prepare_manifest_path, + prepare_request_is_satisfied, + serialize_daily_loop_report, + write_prepare_manifest, +) from pyro_mcp.runtime import ( RuntimeCapabilities, RuntimePaths, @@ -288,9 +297,7 @@ class WorkspaceRecord: network=_deserialize_network(payload.get("network")), name=_normalize_workspace_name(_optional_str(payload.get("name")), allow_none=True), labels=_normalize_workspace_labels(payload.get("labels")), - last_activity_at=float( - payload.get("last_activity_at", float(payload["created_at"])) - ), + last_activity_at=float(payload.get("last_activity_at", float(payload["created_at"]))), command_count=int(payload.get("command_count", 0)), last_command=_optional_dict(payload.get("last_command")), workspace_seed=_workspace_seed_dict(payload.get("workspace_seed")), @@ -544,9 +551,7 @@ class WorkspacePublishedPortRecord: host=str(payload.get("host", DEFAULT_PUBLISHED_PORT_HOST)), protocol=str(payload.get("protocol", "tcp")), proxy_pid=( - None - if payload.get("proxy_pid") is None - else int(payload.get("proxy_pid", 0)) + None if payload.get("proxy_pid") is None else int(payload.get("proxy_pid", 0)) ), ) @@ -921,9 +926,7 @@ def _validate_workspace_file_read_max_bytes(max_bytes: int) -> int: if max_bytes <= 0: raise ValueError("max_bytes must be positive") if max_bytes > WORKSPACE_FILE_MAX_BYTES: - raise ValueError( - f"max_bytes must be at most {WORKSPACE_FILE_MAX_BYTES} bytes" - ) + raise ValueError(f"max_bytes must be at most {WORKSPACE_FILE_MAX_BYTES} bytes") return max_bytes @@ -951,9 +954,7 @@ def _decode_workspace_patch_text(path: str, content_bytes: bytes) -> str: try: return content_bytes.decode("utf-8") except UnicodeDecodeError as exc: - raise RuntimeError( - f"workspace patch only supports UTF-8 text files: {path}" - ) from exc + raise RuntimeError(f"workspace patch only supports UTF-8 text files: {path}") from exc def _normalize_archive_member_name(name: str) -> PurePosixPath: @@ -1043,9 +1044,7 @@ def _prepare_workspace_secrets( has_value = "value" in item has_file_path = "file_path" in item if has_value == has_file_path: - raise ValueError( - f"secret {name!r} must provide exactly one of 'value' or 'file_path'" - ) + raise ValueError(f"secret {name!r} must provide exactly one of 'value' or 'file_path'") source_kind: WorkspaceSecretSourceKind if has_value: value = _validate_workspace_secret_value(name, str(item["value"])) @@ -1525,9 +1524,7 @@ def _normalize_workspace_published_port_specs( ) dedupe_key = (spec.host_port, spec.guest_port) if dedupe_key in seen_guest_ports: - raise ValueError( - "published ports must not repeat the same host/guest port mapping" - ) + raise ValueError("published ports must not repeat the same host/guest port mapping") seen_guest_ports.add(dedupe_key) normalized.append(spec) return normalized @@ -1790,7 +1787,7 @@ def _start_local_service( ), "status=$?", f"printf '%s' \"$status\" > {shlex.quote(str(status_path))}", - "exit \"$status\"", + 'exit "$status"', ] ) + "\n", @@ -1973,9 +1970,7 @@ def _patch_rootfs_runtime_file( ) -> None: debugfs_path = shutil.which("debugfs") if debugfs_path is None: - raise RuntimeError( - "debugfs is required to seed workspaces on guest-backed runtimes" - ) + raise RuntimeError("debugfs is required to seed workspaces on guest-backed runtimes") with tempfile.TemporaryDirectory(prefix=f"pyro-{asset_label}-") as temp_dir: staged_path = Path(temp_dir) / Path(destination_path).name shutil.copy2(source_path, staged_path) @@ -3634,6 +3629,152 @@ class VmManager: def prune_environments(self) -> dict[str, object]: return self._environment_store.prune_environments() + def prepare_daily_loop( + self, + environment: str, + *, + network: bool = False, + force: bool = False, + ) -> dict[str, Any]: + spec = get_environment(environment, runtime_paths=self._runtime_paths) + if self._backend_name != "firecracker": + raise RuntimeError("pyro prepare requires a guest-backed runtime and is unavailable") + if not self._runtime_capabilities.supports_vm_boot: + reason = self._runtime_capabilities.reason or "runtime does not support guest boot" + raise RuntimeError( + f"pyro prepare requires guest-backed workspace boot and is unavailable: {reason}" + ) + if not self._runtime_capabilities.supports_guest_exec: + reason = self._runtime_capabilities.reason or ( + "runtime does not support guest command execution" + ) + raise RuntimeError( + f"pyro prepare requires guest command execution and is unavailable: {reason}" + ) + if network and not self._runtime_capabilities.supports_guest_network: + reason = self._runtime_capabilities.reason or ( + "runtime does not support guest networking" + ) + raise RuntimeError( + f"pyro prepare --network requires guest networking and is unavailable: {reason}" + ) + + runtime_paths = self._runtime_paths + if runtime_paths is None: + raise RuntimeError("runtime paths are unavailable for pyro prepare") + platform = str(runtime_paths.manifest.get("platform", "linux-x86_64")) + bundle_version = cast(str | None, runtime_paths.manifest.get("bundle_version")) + manifest_path = prepare_manifest_path( + self._environment_store.cache_dir, + platform=platform, + environment=environment, + ) + manifest, manifest_error = load_prepare_manifest(manifest_path) + status, status_reason = evaluate_daily_loop_status( + environment=environment, + environment_version=spec.version, + platform=platform, + catalog_version=self._environment_store.catalog_version, + bundle_version=bundle_version, + installed=bool(self.inspect_environment(environment)["installed"]), + manifest=manifest, + manifest_error=manifest_error, + ) + if ( + not force + and status == "warm" + and prepare_request_is_satisfied(manifest, require_network=network) + ): + if manifest is None: + raise AssertionError("warm prepare state requires a manifest") + payload = serialize_daily_loop_report( + environment=environment, + status="warm", + installed=True, + cache_dir=self._environment_store.cache_dir, + manifest_path=manifest_path, + reason="reused existing warm manifest", + manifest=manifest, + ) + payload.update( + { + "prepared": True, + "reused": True, + "executed": False, + "forced": force, + "network_requested": network, + "last_prepare_duration_ms": manifest.last_prepare_duration_ms, + } + ) + return payload + + self._environment_store.ensure_installed(environment) + started = time.monotonic() + workspace_id: str | None = None + execution_mode = "pending" + try: + created = self.create_workspace( + environment=environment, + network_policy="egress" if network else "off", + allow_host_compat=False, + ) + workspace_id = str(created["workspace_id"]) + exec_result = self.exec_workspace( + workspace_id, + command="pwd", + timeout_seconds=DEFAULT_TIMEOUT_SECONDS, + ) + execution_mode = str(exec_result.get("execution_mode", "unknown")) + if int(exec_result.get("exit_code", 1)) != 0: + raise RuntimeError("prepare guest exec failed") + if str(exec_result.get("stdout", "")) != f"{WORKSPACE_GUEST_PATH}\n": + raise RuntimeError("prepare guest exec returned an unexpected working directory") + self.reset_workspace(workspace_id) + finally: + if workspace_id is not None: + try: + self.delete_workspace(workspace_id, reason="prepare_cleanup") + except Exception: + pass + + duration_ms = int((time.monotonic() - started) * 1000) + prepared_at = time.time() + preserved_network_prepared = bool( + manifest is not None and status == "warm" and manifest.network_prepared + ) + prepared_manifest = DailyLoopManifest( + environment=environment, + environment_version=spec.version, + platform=platform, + catalog_version=self._environment_store.catalog_version, + bundle_version=bundle_version, + prepared_at=prepared_at, + network_prepared=network or preserved_network_prepared, + last_prepare_duration_ms=duration_ms, + ) + write_prepare_manifest(manifest_path, prepared_manifest) + payload = serialize_daily_loop_report( + environment=environment, + status="warm", + installed=True, + cache_dir=self._environment_store.cache_dir, + manifest_path=manifest_path, + reason=status_reason, + manifest=prepared_manifest, + ) + payload.update( + { + "prepared": True, + "reused": False, + "executed": True, + "forced": force, + "network_requested": network, + "last_prepare_duration_ms": duration_ms, + "execution_mode": execution_mode, + } + ) + return payload + def create_vm( self, *, @@ -3859,9 +4000,7 @@ class VmManager: raise RuntimeError( f"max active VMs reached ({self._max_active_vms}); delete old VMs first" ) - self._require_workspace_network_policy_support( - network_policy=normalized_network_policy - ) + self._require_workspace_network_policy_support(network_policy=normalized_network_policy) self._backend.create(instance) if self._runtime_capabilities.supports_guest_exec: self._ensure_workspace_guest_bootstrap_support(instance) @@ -3963,9 +4102,7 @@ class VmManager: "destination": str(workspace_sync["destination"]), "entry_count": int(workspace_sync["entry_count"]), "bytes_written": int(workspace_sync["bytes_written"]), - "execution_mode": str( - instance.metadata.get("execution_mode", "pending") - ), + "execution_mode": str(instance.metadata.get("execution_mode", "pending")), }, ) self._save_workspace_locked(workspace) @@ -4397,9 +4534,7 @@ class VmManager: payload={ "summary": dict(summary), "entries": [dict(entry) for entry in entries[:10]], - "execution_mode": str( - instance.metadata.get("execution_mode", "pending") - ), + "execution_mode": str(instance.metadata.get("execution_mode", "pending")), }, ) self._save_workspace_locked(workspace) @@ -4568,9 +4703,7 @@ class VmManager: recreated = workspace.to_instance( workdir=self._workspace_runtime_dir(workspace.workspace_id) ) - self._require_workspace_network_policy_support( - network_policy=workspace.network_policy - ) + self._require_workspace_network_policy_support(network_policy=workspace.network_policy) self._backend.create(recreated) if self._runtime_capabilities.supports_guest_exec: self._ensure_workspace_guest_bootstrap_support(recreated) @@ -4764,9 +4897,7 @@ class VmManager: if wait_for_idle_ms is not None and ( wait_for_idle_ms <= 0 or wait_for_idle_ms > MAX_SHELL_WAIT_FOR_IDLE_MS ): - raise ValueError( - f"wait_for_idle_ms must be between 1 and {MAX_SHELL_WAIT_FOR_IDLE_MS}" - ) + raise ValueError(f"wait_for_idle_ms must be between 1 and {MAX_SHELL_WAIT_FOR_IDLE_MS}") with self._lock: workspace = self._load_workspace_locked(workspace_id) instance = self._workspace_instance_for_live_shell_locked(workspace) @@ -5041,8 +5172,7 @@ class VmManager: if normalized_published_ports: if workspace.network_policy != "egress+published-ports": raise RuntimeError( - "published ports require workspace network_policy " - "'egress+published-ports'" + "published ports require workspace network_policy 'egress+published-ports'" ) if instance.network is None: raise RuntimeError( @@ -5447,11 +5577,7 @@ class VmManager: if not isinstance(entry, dict): continue 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"] = { "available": True, @@ -5509,9 +5635,7 @@ class VmManager: self._stop_workspace_services_locked(workspace, instance) self._close_workspace_shells_locked(workspace, instance) try: - self._require_workspace_network_policy_support( - network_policy=workspace.network_policy - ) + self._require_workspace_network_policy_support(network_policy=workspace.network_policy) if self._runtime_capabilities.supports_guest_exec: self._ensure_workspace_guest_bootstrap_support(instance) with self._lock: @@ -5694,9 +5818,7 @@ class VmManager: "execution_mode": workspace.metadata.get("execution_mode", "pending"), "workspace_path": WORKSPACE_GUEST_PATH, "workspace_seed": _workspace_seed_dict(workspace.workspace_seed), - "secrets": [ - _serialize_workspace_secret_public(secret) for secret in workspace.secrets - ], + "secrets": [_serialize_workspace_secret_public(secret) for secret in workspace.secrets], "command_count": workspace.command_count, "last_command": workspace.last_command, "reset_count": workspace.reset_count, @@ -5867,9 +5989,7 @@ class VmManager: env_values: dict[str, str] = {} for secret_name, env_name in secret_env.items(): if secret_name not in secret_values: - raise ValueError( - f"secret_env references unknown workspace secret {secret_name!r}" - ) + raise ValueError(f"secret_env references unknown workspace secret {secret_name!r}") env_values[env_name] = secret_values[secret_name] return env_values @@ -6019,13 +6139,10 @@ class VmManager: bytes_written=bytes_written, cleanup_dir=cleanup_dir, ) - if ( - not resolved_source_path.is_file() - or not _is_supported_seed_archive(resolved_source_path) + if not resolved_source_path.is_file() or not _is_supported_seed_archive( + resolved_source_path ): - raise ValueError( - "seed_path must be a directory or a .tar/.tar.gz/.tgz archive" - ) + raise ValueError("seed_path must be a directory or a .tar/.tar.gz/.tgz archive") entry_count, bytes_written = _inspect_seed_archive(resolved_source_path) return PreparedWorkspaceSeed( mode="tar_archive", @@ -6128,8 +6245,7 @@ class VmManager: rootfs_path = Path(raw_rootfs_image) if not rootfs_path.exists(): raise RuntimeError( - f"workspace {workspace.workspace_id!r} rootfs image is unavailable at " - f"{rootfs_path}" + f"workspace {workspace.workspace_id!r} rootfs image is unavailable at {rootfs_path}" ) return rootfs_path @@ -6146,9 +6262,7 @@ class VmManager: f"workspace {workspace.workspace_id!r} must be stopped before {operation_name}" ) if workspace.metadata.get("execution_mode") == "host_compat": - raise RuntimeError( - f"{operation_name} is unavailable for host_compat workspaces" - ) + raise RuntimeError(f"{operation_name} is unavailable for host_compat workspaces") return self._workspace_rootfs_image_path_locked(workspace) def _scrub_workspace_runtime_state_locked( diff --git a/tests/test_cli.py b/tests/test_cli.py index 3b48e23..ecb6e74 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -29,14 +29,16 @@ def test_cli_help_guides_first_run() -> None: assert "Suggested zero-to-hero path:" in help_text assert "pyro doctor" in help_text - assert "pyro env list" in help_text - assert "pyro env pull debian:12" in help_text + assert "pyro prepare debian:12" in help_text assert "pyro run debian:12 -- git --version" in help_text assert "pyro host connect claude-code" in help_text assert "Connect a chat host after that:" in help_text assert "pyro host connect claude-code" in help_text assert "pyro host connect codex" in help_text assert "pyro host print-config opencode" in help_text + assert "Daily local loop after the first warmup:" in help_text + assert "pyro doctor --environment debian:12" in help_text + assert "pyro workspace reset WORKSPACE_ID" in help_text assert "If you want terminal-level visibility into the workspace model:" in help_text assert "pyro workspace exec WORKSPACE_ID -- cat note.txt" in help_text assert "pyro workspace summary WORKSPACE_ID" in help_text @@ -60,6 +62,12 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None: assert "pyro env pull debian:12" in env_help assert "downloads from public Docker Hub" in env_help + prepare_help = _subparser_choice(parser, "prepare").format_help() + assert "Warm the recommended guest-backed daily loop" in prepare_help + assert "pyro prepare debian:12 --network" in prepare_help + assert "--network" in prepare_help + assert "--force" in prepare_help + host_help = _subparser_choice(parser, "host").format_help() assert "Connect or repair the supported Claude Code, Codex, and OpenCode" in host_help assert "pyro host connect claude-code" in host_help @@ -87,6 +95,8 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None: doctor_help = _subparser_choice(parser, "doctor").format_help() assert "Check host prerequisites and embedded runtime health" in doctor_help + assert "--environment" in doctor_help + assert "pyro doctor --environment debian:12" in doctor_help assert "pyro doctor --json" in doctor_help demo_help = _subparser_choice(parser, "demo").format_help() @@ -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 list" in workspace_help assert ( - "pyro workspace update WORKSPACE_ID --name retry-run --label owner=codex" - in workspace_help + "pyro workspace update WORKSPACE_ID --name retry-run --label owner=codex" in workspace_help ) assert "pyro workspace sync push WORKSPACE_ID ./repo --dest src" in workspace_help assert "pyro workspace exec WORKSPACE_ID" in workspace_help @@ -476,13 +485,22 @@ def test_cli_doctor_prints_json( ) -> None: class StubParser: def parse_args(self) -> argparse.Namespace: - return argparse.Namespace(command="doctor", platform="linux-x86_64", json=True) + return argparse.Namespace( + command="doctor", + platform="linux-x86_64", + environment="debian:12", + json=True, + ) monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) monkeypatch.setattr( cli, "doctor_report", - lambda platform: {"platform": platform, "runtime_ok": True}, + lambda *, platform, environment: { + "platform": platform, + "environment": environment, + "runtime_ok": True, + }, ) cli.main() output = json.loads(capsys.readouterr().out) @@ -701,7 +719,7 @@ def test_cli_requires_command_preserves_shell_argument_boundaries() -> None: command = cli._require_command( ["--", "sh", "-lc", 'printf "hello from workspace\\n" > note.txt'] ) - assert command == 'sh -lc \'printf "hello from workspace\\n" > note.txt\'' + assert command == "sh -lc 'printf \"hello from workspace\\n\" > note.txt'" def test_cli_read_utf8_text_file_rejects_non_utf8(tmp_path: Path) -> None: @@ -977,10 +995,7 @@ def test_cli_workspace_exec_prints_human_output( cli.main() captured = capsys.readouterr() assert captured.out == "hello\n" - assert ( - "[workspace-exec] workspace_id=workspace-123 sequence=2 cwd=/workspace" - in captured.err - ) + assert "[workspace-exec] workspace_id=workspace-123 sequence=2 cwd=/workspace" in captured.err def test_print_workspace_summary_human_handles_last_command_and_secret_filtering( @@ -1451,13 +1466,7 @@ def test_cli_workspace_patch_apply_reads_patch_file( tmp_path: Path, ) -> None: patch_path = tmp_path / "fix.patch" - patch_text = ( - "--- a/src/app.py\n" - "+++ b/src/app.py\n" - "@@ -1 +1 @@\n" - "-print('hi')\n" - "+print('hello')\n" - ) + patch_text = "--- a/src/app.py\n+++ b/src/app.py\n@@ -1 +1 @@\n-print('hi')\n+print('hello')\n" patch_path.write_text(patch_text, encoding="utf-8") class StubPyro: @@ -1905,10 +1914,7 @@ def test_cli_workspace_diff_prints_human_output( monkeypatch.setattr(cli, "Pyro", StubPyro) cli.main() output = capsys.readouterr().out - assert ( - "[workspace-diff] workspace_id=workspace-123 total=1 added=0 modified=1" - in output - ) + assert "[workspace-diff] workspace_id=workspace-123 total=1 added=0 modified=1" in output assert "--- a/note.txt" in output @@ -2355,8 +2361,7 @@ def test_cli_workspace_sync_push_prints_human( output = capsys.readouterr().out assert "[workspace-sync] workspace_id=workspace-123 mode=directory source=/tmp/repo" in output assert ( - "destination=/workspace entry_count=2 bytes_written=12 " - "execution_mode=guest_vsock" + "destination=/workspace entry_count=2 bytes_written=12 execution_mode=guest_vsock" ) in output @@ -2659,9 +2664,7 @@ def test_cli_workspace_summary_prints_human( }, "snapshots": { "named_count": 1, - "recent": [ - {"event_kind": "snapshot_create", "snapshot_name": "checkpoint"} - ], + "recent": [{"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 "--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 codex_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 +def test_daily_loop_docs_are_aligned() -> None: + readme = Path("README.md").read_text(encoding="utf-8") + install = Path("docs/install.md").read_text(encoding="utf-8") + first_run = Path("docs/first-run.md").read_text(encoding="utf-8") + integrations = Path("docs/integrations.md").read_text(encoding="utf-8") + + assert "pyro prepare debian:12" in readme + assert "pyro prepare debian:12" in install + assert "pyro prepare debian:12" in first_run + assert "pyro prepare debian:12" in integrations + assert "pyro doctor --environment debian:12" in readme + assert "pyro doctor --environment debian:12" in install + assert "pyro doctor --environment debian:12" in first_run + + def test_workspace_summary_docs_are_aligned() -> None: readme = Path("README.md").read_text(encoding="utf-8") install = Path("docs/install.md").read_text(encoding="utf-8") @@ -4307,22 +4325,163 @@ def test_cli_doctor_prints_human( ) -> None: class StubParser: def parse_args(self) -> argparse.Namespace: - return argparse.Namespace(command="doctor", platform="linux-x86_64", json=False) + return argparse.Namespace( + command="doctor", + platform="linux-x86_64", + environment="debian:12", + json=False, + ) monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) monkeypatch.setattr( cli, "doctor_report", - lambda platform: { + lambda *, platform, environment: { "platform": platform, "runtime_ok": True, "issues": [], "kvm": {"exists": True, "readable": True, "writable": True}, + "runtime": {"catalog_version": "4.5.0", "cache_dir": "/cache"}, + "daily_loop": { + "environment": environment, + "status": "cold", + "installed": False, + "network_prepared": False, + "prepared_at": None, + "manifest_path": "/cache/.prepare/linux-x86_64/debian_12.json", + "reason": "daily loop has not been prepared yet", + "cache_dir": "/cache", + }, }, ) cli.main() output = capsys.readouterr().out assert "Runtime: PASS" in output + assert "Daily loop: COLD (debian:12)" in output + assert "Run: pyro prepare debian:12" in output + + +def test_cli_prepare_prints_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubManager: + def prepare_daily_loop( + self, + environment: str, + *, + network: bool, + force: bool, + ) -> dict[str, object]: + assert environment == "debian:12" + assert network is True + assert force is False + return { + "environment": environment, + "status": "warm", + "prepared": True, + "reused": False, + "executed": True, + "network_prepared": True, + "prepared_at": 123.0, + "manifest_path": "/cache/.prepare/linux-x86_64/debian_12.json", + "cache_dir": "/cache", + "last_prepare_duration_ms": 456, + "reason": None, + } + + class StubPyro: + def __init__(self) -> None: + self.manager = StubManager() + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="prepare", + environment="debian:12", + network=True, + force=False, + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = capsys.readouterr().out + assert "Prepare: debian:12" in output + assert "Daily loop: WARM" in output + assert "Result: prepared network_prepared=yes" in output + + +def test_cli_prepare_prints_json_and_errors( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class SuccessManager: + def prepare_daily_loop( + self, + environment: str, + *, + network: bool, + force: bool, + ) -> dict[str, object]: + assert environment == "debian:12" + assert network is False + assert force is True + return {"environment": environment, "reused": True} + + class SuccessPyro: + def __init__(self) -> None: + self.manager = SuccessManager() + + class SuccessParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="prepare", + environment="debian:12", + network=False, + force=True, + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: SuccessParser()) + monkeypatch.setattr(cli, "Pyro", SuccessPyro) + cli.main() + payload = json.loads(capsys.readouterr().out) + assert payload["reused"] is True + + class ErrorManager: + def prepare_daily_loop( + self, + environment: str, + *, + network: bool, + force: bool, + ) -> dict[str, object]: + del environment, network, force + raise RuntimeError("prepare failed") + + class ErrorPyro: + def __init__(self) -> None: + self.manager = ErrorManager() + + class ErrorParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="prepare", + environment="debian:12", + network=False, + force=False, + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: ErrorParser()) + monkeypatch.setattr(cli, "Pyro", ErrorPyro) + with pytest.raises(SystemExit, match="1"): + cli.main() + error_payload = json.loads(capsys.readouterr().out) + assert error_payload["ok"] is False + assert error_payload["error"] == "prepare failed" def test_cli_run_json_error_exits_nonzero( @@ -4386,16 +4545,16 @@ def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None: class StubParser: def parse_args(self) -> argparse.Namespace: - return argparse.Namespace( - command="mcp", - mcp_command="serve", - profile="workspace-core", - mode=None, - project_path="/repo", - repo_url=None, - repo_ref=None, - no_project_source=False, - ) + return argparse.Namespace( + command="mcp", + mcp_command="serve", + profile="workspace-core", + mode=None, + project_path="/repo", + repo_url=None, + repo_ref=None, + no_project_source=False, + ) monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) monkeypatch.setattr(cli, "Pyro", StubPyro) @@ -4526,7 +4685,7 @@ def test_cli_workspace_exec_passes_secret_env( class StubPyro: def exec_workspace(self, workspace_id: str, **kwargs: Any) -> dict[str, Any]: assert workspace_id == "ws-123" - assert kwargs["command"] == "sh -lc 'test \"$API_TOKEN\" = \"expected\"'" + assert kwargs["command"] == 'sh -lc \'test "$API_TOKEN" = "expected"\'' assert kwargs["secret_env"] == {"API_TOKEN": "API_TOKEN", "TOKEN": "PIP_TOKEN"} return {"exit_code": 0, "stdout": "", "stderr": ""} diff --git a/tests/test_daily_loop.py b/tests/test_daily_loop.py new file mode 100644 index 0000000..b511158 --- /dev/null +++ b/tests/test_daily_loop.py @@ -0,0 +1,359 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from pyro_mcp.daily_loop import ( + DailyLoopManifest, + evaluate_daily_loop_status, + load_prepare_manifest, + prepare_manifest_path, + prepare_request_is_satisfied, +) +from pyro_mcp.runtime import RuntimeCapabilities +from pyro_mcp.vm_manager import VmManager + + +def test_prepare_daily_loop_executes_then_reuses( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "state", + cache_dir=tmp_path / "cache", + ) + monkeypatch.setattr(manager, "_backend_name", "firecracker") + monkeypatch.setattr( + manager, + "_runtime_capabilities", + RuntimeCapabilities( + supports_vm_boot=True, + supports_guest_exec=True, + supports_guest_network=True, + ), + ) + monkeypatch.setattr( + manager, + "inspect_environment", + lambda environment: {"installed": True}, + ) + monkeypatch.setattr( + manager._environment_store, + "ensure_installed", + lambda environment: object(), + ) + + observed: dict[str, object] = {} + + def fake_create_workspace(**kwargs: object) -> dict[str, object]: + observed["network_policy"] = kwargs["network_policy"] + return {"workspace_id": "ws-123"} + + def fake_exec_workspace( + workspace_id: str, + *, + command: str, + timeout_seconds: int = 30, + secret_env: dict[str, str] | None = None, + ) -> dict[str, object]: + observed["exec"] = { + "workspace_id": workspace_id, + "command": command, + "timeout_seconds": timeout_seconds, + "secret_env": secret_env, + } + return { + "workspace_id": workspace_id, + "stdout": "/workspace\n", + "stderr": "", + "exit_code": 0, + "duration_ms": 1, + "execution_mode": "guest_vsock", + } + + def fake_reset_workspace( + workspace_id: str, + *, + snapshot: str = "baseline", + ) -> dict[str, object]: + observed["reset"] = {"workspace_id": workspace_id, "snapshot": snapshot} + return {"workspace_id": workspace_id} + + def fake_delete_workspace( + workspace_id: str, + *, + reason: str = "explicit_delete", + ) -> dict[str, object]: + observed["delete"] = {"workspace_id": workspace_id, "reason": reason} + return {"workspace_id": workspace_id, "deleted": True} + + monkeypatch.setattr(manager, "create_workspace", fake_create_workspace) + monkeypatch.setattr(manager, "exec_workspace", fake_exec_workspace) + monkeypatch.setattr(manager, "reset_workspace", fake_reset_workspace) + monkeypatch.setattr(manager, "delete_workspace", fake_delete_workspace) + + first = manager.prepare_daily_loop("debian:12") + assert first["prepared"] is True + assert first["executed"] is True + assert first["reused"] is False + assert first["network_prepared"] is False + assert first["execution_mode"] == "guest_vsock" + assert observed["network_policy"] == "off" + assert observed["exec"] == { + "workspace_id": "ws-123", + "command": "pwd", + "timeout_seconds": 30, + "secret_env": None, + } + assert observed["reset"] == {"workspace_id": "ws-123", "snapshot": "baseline"} + assert observed["delete"] == {"workspace_id": "ws-123", "reason": "prepare_cleanup"} + + second = manager.prepare_daily_loop("debian:12") + assert second["prepared"] is True + assert second["executed"] is False + assert second["reused"] is True + assert second["reason"] == "reused existing warm manifest" + + +def test_prepare_daily_loop_force_and_network_upgrade_manifest( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "state", + cache_dir=tmp_path / "cache", + ) + monkeypatch.setattr(manager, "_backend_name", "firecracker") + monkeypatch.setattr( + manager, + "_runtime_capabilities", + RuntimeCapabilities( + supports_vm_boot=True, + supports_guest_exec=True, + supports_guest_network=True, + ), + ) + monkeypatch.setattr( + manager, + "inspect_environment", + lambda environment: {"installed": True}, + ) + monkeypatch.setattr( + manager._environment_store, + "ensure_installed", + lambda environment: object(), + ) + + observed_policies: list[str] = [] + + def fake_create_workspace(**kwargs: object) -> dict[str, object]: + observed_policies.append(str(kwargs["network_policy"])) + return {"workspace_id": "ws-1"} + + monkeypatch.setattr(manager, "create_workspace", fake_create_workspace) + monkeypatch.setattr( + manager, + "exec_workspace", + lambda workspace_id, **kwargs: { + "workspace_id": workspace_id, + "stdout": "/workspace\n", + "stderr": "", + "exit_code": 0, + "duration_ms": 1, + "execution_mode": "guest_vsock", + }, + ) + monkeypatch.setattr( + manager, + "reset_workspace", + lambda workspace_id, **kwargs: {"workspace_id": workspace_id}, + ) + monkeypatch.setattr( + manager, + "delete_workspace", + lambda workspace_id, **kwargs: {"workspace_id": workspace_id, "deleted": True}, + ) + + manager.prepare_daily_loop("debian:12") + payload = manager.prepare_daily_loop("debian:12", network=True, force=True) + assert payload["executed"] is True + assert payload["network_prepared"] is True + assert observed_policies == ["off", "egress"] + + manifest_path = prepare_manifest_path( + tmp_path / "cache", + platform="linux-x86_64", + environment="debian:12", + ) + manifest, manifest_error = load_prepare_manifest(manifest_path) + assert manifest_error is None + if manifest is None: + raise AssertionError("expected prepare manifest") + assert manifest.network_prepared is True + + +def test_prepare_daily_loop_requires_guest_capabilities(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "state", + cache_dir=tmp_path / "cache", + ) + with pytest.raises(RuntimeError, match="guest-backed runtime"): + manager.prepare_daily_loop("debian:12") + + +def test_load_prepare_manifest_reports_invalid_json(tmp_path: Path) -> None: + manifest_path = prepare_manifest_path( + tmp_path, + platform="linux-x86_64", + environment="debian:12", + ) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text("{broken", encoding="utf-8") + + manifest, error = load_prepare_manifest(manifest_path) + assert manifest is None + assert error is not None + + +def test_prepare_manifest_round_trip(tmp_path: Path) -> None: + manifest_path = prepare_manifest_path( + tmp_path, + platform="linux-x86_64", + environment="debian:12", + ) + manifest = DailyLoopManifest( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + prepared_at=123.0, + network_prepared=True, + last_prepare_duration_ms=456, + ) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text(json.dumps(manifest.to_payload()), encoding="utf-8") + + loaded, error = load_prepare_manifest(manifest_path) + assert error is None + assert loaded == manifest + + +def test_load_prepare_manifest_rejects_non_object(tmp_path: Path) -> None: + manifest_path = prepare_manifest_path( + tmp_path, + platform="linux-x86_64", + environment="debian:12", + ) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text('["not-an-object"]', encoding="utf-8") + + manifest, error = load_prepare_manifest(manifest_path) + assert manifest is None + assert error == "prepare manifest is not a JSON object" + + +def test_load_prepare_manifest_rejects_invalid_payload(tmp_path: Path) -> None: + manifest_path = prepare_manifest_path( + tmp_path, + platform="linux-x86_64", + environment="debian:12", + ) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text(json.dumps({"environment": "debian:12"}), encoding="utf-8") + + manifest, error = load_prepare_manifest(manifest_path) + assert manifest is None + assert error is not None + assert "prepare manifest is invalid" in error + + +def test_evaluate_daily_loop_status_edge_cases() -> None: + manifest = DailyLoopManifest( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + prepared_at=1.0, + network_prepared=False, + last_prepare_duration_ms=2, + ) + + assert evaluate_daily_loop_status( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + installed=True, + manifest=manifest, + manifest_error="broken manifest", + ) == ("stale", "broken manifest") + assert evaluate_daily_loop_status( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + installed=False, + manifest=manifest, + ) == ("stale", "environment install is missing") + assert evaluate_daily_loop_status( + environment="debian:12-build", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + installed=True, + manifest=manifest, + ) == ("stale", "prepare manifest environment does not match the selected environment") + assert evaluate_daily_loop_status( + environment="debian:12", + environment_version="2.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + installed=True, + manifest=manifest, + ) == ("stale", "environment version changed since the last prepare run") + assert evaluate_daily_loop_status( + environment="debian:12", + environment_version="1.0.0", + platform="linux-aarch64", + catalog_version="4.5.0", + bundle_version="bundle-1", + installed=True, + manifest=manifest, + ) == ("stale", "platform changed since the last prepare run") + assert evaluate_daily_loop_status( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-2", + installed=True, + manifest=manifest, + ) == ("stale", "runtime bundle version changed since the last prepare run") + + +def test_prepare_request_is_satisfied_network_gate() -> None: + manifest = DailyLoopManifest( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + prepared_at=1.0, + network_prepared=False, + last_prepare_duration_ms=2, + ) + + assert prepare_request_is_satisfied(None, require_network=False) is False + assert prepare_request_is_satisfied(manifest, require_network=True) is False + assert prepare_request_is_satisfied(manifest, require_network=False) is True diff --git a/tests/test_daily_loop_smoke.py b/tests/test_daily_loop_smoke.py new file mode 100644 index 0000000..2d75fbc --- /dev/null +++ b/tests/test_daily_loop_smoke.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import pyro_mcp.daily_loop_smoke as smoke_module + + +class _FakePyro: + def __init__(self) -> None: + self.workspace_id = "ws-1" + self.message = "broken\n" + self.deleted = False + + def create_workspace( + self, + *, + environment: str, + seed_path: Path, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> dict[str, object]: + assert environment == "debian:12" + assert seed_path.is_dir() + assert name == "daily-loop" + assert labels == {"suite": "daily-loop-smoke"} + return {"workspace_id": self.workspace_id} + + def exec_workspace(self, workspace_id: str, *, command: str) -> dict[str, object]: + assert workspace_id == self.workspace_id + if command != "sh check.sh": + raise AssertionError(f"unexpected command: {command}") + if self.message == "fixed\n": + return {"exit_code": 0, "stdout": "fixed\n"} + return {"exit_code": 1, "stderr": "expected fixed got broken\n"} + + def apply_workspace_patch(self, workspace_id: str, *, patch: str) -> dict[str, object]: + assert workspace_id == self.workspace_id + assert "+fixed" in patch + self.message = "fixed\n" + return {"changed": True} + + def export_workspace( + self, + workspace_id: str, + path: str, + *, + output_path: Path, + ) -> dict[str, object]: + assert workspace_id == self.workspace_id + assert path == "message.txt" + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(self.message, encoding="utf-8") + return {"artifact_type": "file"} + + def reset_workspace(self, workspace_id: str) -> dict[str, object]: + assert workspace_id == self.workspace_id + self.message = "broken\n" + return {"reset_count": 1} + + def read_workspace_file(self, workspace_id: str, path: str) -> dict[str, object]: + assert workspace_id == self.workspace_id + assert path == "message.txt" + return {"content": self.message} + + def delete_workspace(self, workspace_id: str) -> dict[str, object]: + assert workspace_id == self.workspace_id + self.deleted = True + return {"workspace_id": workspace_id, "deleted": True} + + +def test_run_prepare_parses_json(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace( + returncode=0, + stdout=json.dumps({"prepared": True}), + stderr="", + ), + ) + payload = smoke_module._run_prepare("debian:12") + assert payload == {"prepared": True} + + +def test_run_prepare_raises_on_failure(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace( + returncode=1, + stdout="", + stderr="prepare failed", + ), + ) + with pytest.raises(RuntimeError, match="prepare failed"): + smoke_module._run_prepare("debian:12") + + +def test_run_daily_loop_smoke_happy_path(monkeypatch: pytest.MonkeyPatch) -> None: + prepare_calls: list[str] = [] + fake_pyro = _FakePyro() + + def fake_run_prepare(environment: str) -> dict[str, object]: + prepare_calls.append(environment) + return {"prepared": True, "reused": len(prepare_calls) > 1} + + monkeypatch.setattr(smoke_module, "_run_prepare", fake_run_prepare) + monkeypatch.setattr(smoke_module, "Pyro", lambda: fake_pyro) + + smoke_module.run_daily_loop_smoke(environment="debian:12") + + assert prepare_calls == ["debian:12", "debian:12"] + assert fake_pyro.deleted is True + + +def test_main_runs_selected_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + observed: list[str] = [] + monkeypatch.setattr( + smoke_module, + "run_daily_loop_smoke", + lambda *, environment: observed.append(environment), + ) + monkeypatch.setattr( + smoke_module, + "build_arg_parser", + lambda: SimpleNamespace( + parse_args=lambda: SimpleNamespace(environment="debian:12-build") + ), + ) + smoke_module.main() + assert observed == ["debian:12-build"] diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 9629771..3c3196e 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -15,13 +15,18 @@ def test_doctor_main_prints_json( ) -> None: class StubParser: def parse_args(self) -> argparse.Namespace: - return argparse.Namespace(platform="linux-x86_64") + return argparse.Namespace(platform="linux-x86_64", environment="debian:12") monkeypatch.setattr(doctor_module, "_build_parser", lambda: StubParser()) monkeypatch.setattr( doctor_module, "doctor_report", - lambda platform: {"platform": platform, "runtime_ok": True, "issues": []}, + lambda *, platform, environment: { + "platform": platform, + "environment": environment, + "runtime_ok": True, + "issues": [], + }, ) doctor_module.main() output = json.loads(capsys.readouterr().out) @@ -32,3 +37,4 @@ def test_doctor_build_parser_defaults_platform() -> None: parser = doctor_module._build_parser() args = parser.parse_args([]) assert args.platform == DEFAULT_PLATFORM + assert args.environment == "debian:12" diff --git a/tests/test_public_contract.py b/tests/test_public_contract.py index 31fbdf1..2f5385a 100644 --- a/tests/test_public_contract.py +++ b/tests/test_public_contract.py @@ -15,6 +15,7 @@ from pyro_mcp.cli import _build_parser from pyro_mcp.contract import ( PUBLIC_CLI_COMMANDS, PUBLIC_CLI_DEMO_SUBCOMMANDS, + PUBLIC_CLI_DOCTOR_FLAGS, PUBLIC_CLI_ENV_SUBCOMMANDS, PUBLIC_CLI_HOST_CONNECT_FLAGS, PUBLIC_CLI_HOST_DOCTOR_FLAGS, @@ -23,6 +24,7 @@ from pyro_mcp.contract import ( PUBLIC_CLI_HOST_SUBCOMMANDS, PUBLIC_CLI_MCP_SERVE_FLAGS, PUBLIC_CLI_MCP_SUBCOMMANDS, + PUBLIC_CLI_PREPARE_FLAGS, PUBLIC_CLI_RUN_FLAGS, PUBLIC_CLI_WORKSPACE_CREATE_FLAGS, PUBLIC_CLI_WORKSPACE_DIFF_FLAGS, @@ -113,6 +115,9 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None: env_help_text = _subparser_choice(parser, "env").format_help() for subcommand_name in PUBLIC_CLI_ENV_SUBCOMMANDS: assert subcommand_name in env_help_text + prepare_help_text = _subparser_choice(parser, "prepare").format_help() + for flag in PUBLIC_CLI_PREPARE_FLAGS: + assert flag in prepare_help_text host_help_text = _subparser_choice(parser, "host").format_help() for subcommand_name in PUBLIC_CLI_HOST_SUBCOMMANDS: assert subcommand_name in host_help_text @@ -136,6 +141,9 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None: ).format_help() for flag in PUBLIC_CLI_HOST_REPAIR_FLAGS: assert flag in host_repair_help_text + doctor_help_text = _subparser_choice(parser, "doctor").format_help() + for flag in PUBLIC_CLI_DOCTOR_FLAGS: + assert flag in doctor_help_text mcp_help_text = _subparser_choice(parser, "mcp").format_help() for subcommand_name in PUBLIC_CLI_MCP_SUBCOMMANDS: assert subcommand_name in mcp_help_text diff --git a/tests/test_runtime.py b/tests/test_runtime.py index a2b9004..60ebc4b 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -6,7 +6,32 @@ from pathlib import Path import pytest +from pyro_mcp.daily_loop import DailyLoopManifest, prepare_manifest_path, write_prepare_manifest from pyro_mcp.runtime import doctor_report, resolve_runtime_paths, runtime_capabilities +from pyro_mcp.vm_environments import EnvironmentStore, get_environment + + +def _materialize_installed_environment( + environment_store: EnvironmentStore, + *, + name: str, +) -> None: + spec = get_environment(name, runtime_paths=environment_store._runtime_paths) + install_dir = environment_store._install_dir(spec) + install_dir.mkdir(parents=True, exist_ok=True) + (install_dir / "vmlinux").write_text("kernel\n", encoding="utf-8") + (install_dir / "rootfs.ext4").write_text("rootfs\n", encoding="utf-8") + (install_dir / "environment.json").write_text( + json.dumps( + { + "name": spec.name, + "version": spec.version, + "source": "test-cache", + "source_digest": spec.source_digest, + } + ), + encoding="utf-8", + ) def test_resolve_runtime_paths_default_bundle() -> None: @@ -109,6 +134,7 @@ def test_doctor_report_has_runtime_fields() -> None: assert "runtime_ok" in report assert "kvm" in report assert "networking" in report + assert "daily_loop" in report if report["runtime_ok"]: runtime = report.get("runtime") assert isinstance(runtime, dict) @@ -122,6 +148,61 @@ def test_doctor_report_has_runtime_fields() -> None: assert "tun_available" in networking +def test_doctor_report_daily_loop_statuses( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("PYRO_ENVIRONMENT_CACHE_DIR", str(tmp_path)) + cold_report = doctor_report(environment="debian:12") + cold_daily_loop = cold_report["daily_loop"] + assert cold_daily_loop["status"] == "cold" + assert cold_daily_loop["installed"] is False + + paths = resolve_runtime_paths() + environment_store = EnvironmentStore(runtime_paths=paths, cache_dir=tmp_path) + _materialize_installed_environment(environment_store, name="debian:12") + + installed_report = doctor_report(environment="debian:12") + installed_daily_loop = installed_report["daily_loop"] + assert installed_daily_loop["status"] == "cold" + assert installed_daily_loop["installed"] is True + + manifest_path = prepare_manifest_path( + tmp_path, + platform="linux-x86_64", + environment="debian:12", + ) + write_prepare_manifest( + manifest_path, + DailyLoopManifest( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version=environment_store.catalog_version, + bundle_version=( + None + if paths.manifest.get("bundle_version") is None + else str(paths.manifest.get("bundle_version")) + ), + prepared_at=123.0, + network_prepared=True, + last_prepare_duration_ms=456, + ), + ) + warm_report = doctor_report(environment="debian:12") + warm_daily_loop = warm_report["daily_loop"] + assert warm_daily_loop["status"] == "warm" + assert warm_daily_loop["network_prepared"] is True + + stale_manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + stale_manifest["catalog_version"] = "0.0.0" + manifest_path.write_text(json.dumps(stale_manifest), encoding="utf-8") + stale_report = doctor_report(environment="debian:12") + stale_daily_loop = stale_report["daily_loop"] + assert stale_daily_loop["status"] == "stale" + assert "catalog version changed" in str(stale_daily_loop["reason"]) + + def test_runtime_capabilities_reports_real_bundle_flags() -> None: paths = resolve_runtime_paths() capabilities = runtime_capabilities(paths) diff --git a/uv.lock b/uv.lock index b554f16..14f3147 100644 --- a/uv.lock +++ b/uv.lock @@ -715,7 +715,7 @@ crypto = [ [[package]] name = "pyro-mcp" -version = "4.4.0" +version = "4.5.0" source = { editable = "." } dependencies = [ { name = "mcp" },